♻️ Review on switcher component

This commit is contained in:
Eva Marco 2025-10-03 15:02:31 +02:00
parent f905dfc699
commit 2de6b6460e
4 changed files with 130 additions and 321 deletions

View File

@ -9,51 +9,45 @@
(:require
[app.common.data :as d]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private schema:switcher
[:map
[:id {:optional true} :string]
[:label {:optional true} :string]
[:checked {:optional true} :boolean]
[:default-checked {:optional true} :boolean]
[:on-change {:optional true} [:maybe fn?]]
[:disabled {:optional true} :boolean]
[:size {:optional true} [:enum "sm" "md" "lg"]]
[:aria-label {:optional true} [:maybe :string]]
[:class {:optional true} :string]])
[:and
[:map
[:id {:optional true} :string]
[:label {:optional true} [:maybe :string]]
[:aria-label {:optional true} [:maybe :string]]
[:default-checked {:optional true} :boolean]
[:on-change {:optional true} [:maybe fn?]]
[:disabled {:optional true} :boolean]
[:size {:optional true} [:enum "sm" "md" "lg"]]
[:class {:optional true} :string]]
[:fn {:error/message "Invalid Props"}
(fn [props]
(or (contains? props :label)
(contains? props :aria-label)))]])
(mf/defc switcher*
{::mf/forward-ref true
::mf/schema schema:switcher}
[{:keys [id label checked default-checked on-change disabled size aria-label class] :rest props} ref]
{::mf/schema schema:switcher}
[{:keys [id label default-checked on-change disabled size aria-label class] :rest props}]
(let [id (or id (mf/use-id))
size (d/nilv size "md")
size (d/nilv size " md ")
disabled (d/nilv disabled false)
;; TODO: review which one is better
;; Internal state for uncontrolled mode
internal-checked* (mf/use-state (d/nilv default-checked false))
internal-checked (deref internal-checked*)
;; Determine if controlled or uncontrolled
controlled? (some? checked)
current-checked (if controlled? checked internal-checked)
is-checked* (mf/use-state (d/nilv default-checked false))
is-checked (deref is-checked*)
;; Toggle handler
handle-toggle
(mf/use-fn
(mf/deps controlled? current-checked on-change internal-checked* disabled)
(fn [event]
(mf/deps on-change is-checked* disabled)
(fn []
(when-not disabled
(let [new-checked (not current-checked)]
(when-not controlled?
(reset! internal-checked* new-checked))
(let [new-checked (not is-checked)]
(reset! is-checked* new-checked)
(when on-change
(on-change new-checked event))))))
(on-change new-checked))))))
;; Keyboard events
handle-keydown
@ -64,44 +58,30 @@
(dom/prevent-default event)
(handle-toggle event))))
;; Label click handler
handle-label-click
(mf/use-fn
(mf/deps handle-toggle)
(fn [event]
(dom/prevent-default event)
(handle-toggle event)))
has-label (not (str/blank? label))
effective-aria-label (if has-label
(or aria-label label)
(tr "ds.switcher.aria-label"))
props (mf/spread-props props {:id id
:ref ref
:role "switch"
:tabIndex (if disabled -1 0)
:aria-checked current-checked
:aria-disabled disabled
:aria-label effective-aria-label
:class (stl/css-case :switcher true
:switcher-checked current-checked
:switcher-disabled disabled
:switcher-sm (= size "sm")
:switcher-md (= size "md")
:switcher-lg (= size "lg"))
:aria-label (when-not has-label
aria-label)
:class [class (stl/css :switcher)]
:aria-checked is-checked
:disabled disabled
:on-click handle-toggle
:on-key-down handle-keydown})]
:on-key-down handle-keydown
:tab-index (if disabled -1 0)})]
[:div {:class [class (stl/css :switcher-wrapper)]}
[:> :div props
(when has-label
[:label {:for id
:class (stl/css-case :switcher-label true
:switcher-label-disabled disabled)
:on-click handle-label-click}
:switcher-label-disabled disabled)}
label])
[:> :div props
[:div {:class (stl/css-case :switcher-track true
:switcher-track-disabled disabled)}
[:div {:class (stl/css-case :switcher-thumb true
:switcher-thumb-disabled disabled)}]]]]))
[:div {:class (stl/css-case :switcher-track true
:switcher-checked is-checked
:switcher-sm (= size "sm")
:switcher-md (= size "md")
:switcher-lg (= size "lg")
:switcher-track-disabled-checked (and is-checked disabled))}
[:div {:class (stl/css-case :switcher-thumb true
:switcher-thumb-disabled disabled)}]]]))

View File

@ -11,9 +11,9 @@ import * as Switcher from "./switcher.stories";
# Switcher
The `switcher*` component is a toggle control that allows users to switch between two states (on/off). It provides both controlled and uncontrolled modes, making it suitable for various use cases across the interface.
The `switcher*` component is a toggle control that allows users to switch between two states (on/off).
<Canvas of={Switcher.DefaultUncontrolled} />
<Canvas of={Switcher.Default} />
## Anatomy
@ -23,43 +23,8 @@ The switcher component consists of three main parts:
- **Track**: The pill-shaped background that indicates the current state
- **Thumb**: The circular knob that moves between positions
## Props
| Name | Type | Default | Description | Required |
| ---- | ---- | ------- | ----------- | -------- |
| `id` | `string` | auto-generated | Unique identifier for the switcher | No |
| `label` | `string` | - | Text label displayed next to the switcher | No |
| `checked` | `boolean` | - | Controlled checked state | No |
| `default-checked` | `boolean` | `false` | Initial checked state for uncontrolled mode | No |
| `on-change` | `function` | - | Callback fired when state changes `(new-checked, event) => void` | No |
| `disabled` | `boolean` | `false` | Whether the switcher is disabled | No |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Size variant of the switcher | No |
| `aria-label` | `string` | - | Accessible label when no visible label is provided | No |
| `class` | `string` | - | Additional CSS classes | No |
| `data-testid` | `string` | - | Test identifier for automated testing | No |
## Usage Examples
### Controlled vs Uncontrolled
#### Uncontrolled (manages its own state)
```clj
[:> switcher* {:label "Enable notifications"
:default-checked false
:on-change (fn [checked event]
(js/console.log "New state:" checked))}]
```
#### Controlled (state managed externally)
```clj
(let [checked (r/atom false)]
[:> switcher* {:label "Enable notifications"
:checked @checked
:on-change (fn [new-checked event]
(reset! checked new-checked))}])
```
<Canvas of={Switcher.Controlled} />
## Variants
### Different Sizes
@ -71,16 +36,6 @@ The switcher supports three size variants: `"sm"`, `"md"` (default), and `"lg"`.
[:> switcher* {:size "lg" :label "Large"}]
```
### Disabled State
Disabled switchers are non-interactive and visually distinct. You can control the disabled state using the `disabled` prop in the controls panel.
```clj
[:> switcher* {:label "Disabled feature"
:disabled true
:default-checked false}]
```
### Without Visible Label
When no visible label is provided, use `aria-label` for accessibility.
@ -92,48 +47,15 @@ When no visible label is provided, use `aria-label` for accessibility.
<Canvas of={Switcher.WithoutVisibleLabel} />
## Design Tokens
The switcher component uses the following design system tokens:
### Colors
- **OFF state track**: `--color-background-quaternary` (muted gray)
- **ON state track**: `--color-accent-success` (green/teal)
- **Thumb**: `--color-background-primary` (white/light)
- **Label**: `--color-foreground-primary`
- **Focus ring**: `--color-accent-primary`
### Sizes
- **Small**: Track 32×16px, Thumb 12px
- **Medium**: Track 40×24px, Thumb 16px
- **Large**: Track 48×28px, Thumb 24px
### Spacing
- **Label gap**: `--sp-s` (8px)
## Usage Guidelines
### When to Use
- For binary settings that take effect immediately
- To enable/disable features or functionality
- In preference panels and configuration screens
- For toggling between two distinct states
### When Not to Use
- For actions that require confirmation (use buttons instead)
- For multiple choice selections (use radio buttons or select)
- For temporary states that need explicit "Apply" action
### Best Practices
- Always provide clear labels that describe what will happen when toggled
- Use consistent sizing within the same interface area
- Consider the immediate impact of the toggle action
- Group related switchers logically
- Provide feedback for state changes when appropriate
## Interactive Example
<Canvas of={Switcher.Interactive} />

View File

@ -23,54 +23,82 @@ $switcher-lg-thumb-size: $sz-24;
$switcher-transition-duration: 0.2s;
.switcher-wrapper {
--switcher-track-outline: none;
.switcher {
--switcher-track-outline-color: transparent;
--switcher-track-outline-offset: 0;
--switcher-track-width: #{$switcher-md-track-width};
--switcher-track-height: #{$switcher-md-track-height};
--switcher-track-bg: var(--color-background-quaternary);
--switcher-track-opacity: 1;
display: flex;
--switcher-thumb-transform: translateX(0);
--switcher-thumb-size: #{$switcher-md-thumb-size};
--switcher-thumb-bg: var(--color-foreground-secondary);
--switcher-thumb-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
--switcher-thumb-opacity: 1;
--switcher-label-foreground-color: var(--color-foreground-secondary);
--switcher-cursor: pointer;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--sp-s);
padding: 0;
inline-size: fit-content;
outline: none;
cursor: var(--switcher-cursor);
&:focus-visible {
--switcher-track-outline: $b-2 solid var(--color-accent-primary);
--switcher-track-outline-offset: #{$b-2};
--switcher-track-outline-color: var(--color-accent-primary);
}
&:disabled,
&[disabled] {
--switcher-label-foreground-color: var(--color-foreground-secondary);
--switcher-cursor: not-allowed;
--switcher-track-opacity: 0.6;
--switcher-thumb-opacity: 0.5;
--switcher-track-background: var(--color-background-tertiary);
}
}
.switcher-label {
--switcher-label-color: var(--color-foreground-secondary);
color: var(--switcher-label-color);
cursor: pointer;
color: var(--switcher-label-foreground-color);
user-select: none;
}
.switcher-label-disabled {
cursor: not-allowed;
color: var(--color-foreground-secondary);
.switcher-track {
position: relative;
inline-size: var(--switcher-track-width);
block-size: var(--switcher-track-height);
border-radius: $br-full;
background-color: var(--switcher-track-bg);
transition: background-color $switcher-transition-duration ease-in-out;
outline: $b-2 solid var(--switcher-track-outline-color);
outline-offset: var(--switcher-track-outline-offset);
opacity: var(--switcher-track-opacity);
&:hover {
--switcher-thumb-bg: var(--color-static-white);
}
}
.switcher {
--switcher-track-width: #{$switcher-md-track-width};
--switcher-track-height: #{$switcher-md-track-height};
--switcher-thumb-size: #{$switcher-md-thumb-size};
--switcher-thumb-transform: translateX(0);
--switcher-track-bg: var(--color-background-quaternary);
--switcher-thumb-bg: var(--color-foreground-secondary);
--switcher-thumb-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
position: relative;
display: inline-block;
cursor: pointer;
outline: none;
border: none;
background: transparent;
padding: 0;
&:hover:not(.switcher-disabled) {
--switcher-thumb-bg: var(--color-foreground-primary);
}
.switcher-thumb {
position: absolute;
inline-size: var(--switcher-thumb-size);
block-size: var(--switcher-thumb-size);
inset-block-start: calc((var(--switcher-track-height) - var(--switcher-thumb-size)) / 2);
inset-inline-start: calc((var(--switcher-track-height) - var(--switcher-thumb-size)) / 2);
border-radius: 50%;
background-color: var(--switcher-thumb-bg);
opacity: var(--switcher-thumb-opacity);
box-shadow: var(--switcher-thumb-shadow);
transform: var(--switcher-thumb-transform);
transition:
transform $switcher-transition-duration ease-in-out,
background-color $switcher-transition-duration ease-in-out;
}
// Size variants - Small
@ -97,14 +125,20 @@ $switcher-transition-duration: 0.2s;
// Checked state
.switcher-checked {
--switcher-track-bg: var(--color-accent-success);
--switcher-thumb-bg: var(--color-foreground-primary);
--switcher-thumb-bg: var(--color-static-white);
--switcher-thumb-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
&:hover:not(.switcher-disabled) {
&:hover {
--switcher-track-bg: var(--color-accent-tertiary);
}
}
.switcher-track-disabled-checked {
--switcher-track-bg: var(--color-background-tertiary);
&:hover {
--switcher-track-bg: var(--color-background-tertiary);
}
}
.switcher-checked.switcher-sm {
--switcher-thumb-transform: translateX(calc(#{$switcher-sm-track-width} - #{$switcher-sm-track-height}));
}
@ -117,55 +151,6 @@ $switcher-transition-duration: 0.2s;
--switcher-thumb-transform: translateX(calc(#{$switcher-lg-track-width} - #{$switcher-lg-track-height}));
}
// Disabled state
.switcher-disabled {
cursor: not-allowed;
&:not(.switcher-checked) {
--switcher-track-bg: var(--color-background-tertiary);
--switcher-thumb-bg: var(--color-foreground-secondary);
}
}
.switcher-disabled.switcher-checked {
--switcher-track-bg: var(--color-background-quaternary);
--switcher-thumb-bg: var(--color-foreground-secondary);
}
.switcher-track {
position: relative;
width: var(--switcher-track-width);
height: var(--switcher-track-height);
border-radius: $br-full;
background-color: var(--switcher-track-bg);
transition: background-color $switcher-transition-duration ease-in-out;
outline: var(--switcher-track-outline);
outline-offset: var(--switcher-track-outline-offset);
}
.switcher-track-disabled {
opacity: 0.6;
}
.switcher-thumb {
position: absolute;
width: var(--switcher-thumb-size);
height: var(--switcher-thumb-size);
top: calc((var(--switcher-track-height) - var(--switcher-thumb-size)) / 2);
left: calc((var(--switcher-track-height) - var(--switcher-thumb-size)) / 2);
border-radius: 50%;
background-color: var(--switcher-thumb-bg);
box-shadow: var(--switcher-thumb-shadow);
transform: var(--switcher-thumb-transform);
transition:
transform $switcher-transition-duration ease-in-out,
background-color $switcher-transition-duration ease-in-out;
}
.switcher-thumb-disabled {
opacity: 0.5;
}
@media (prefers-reduced-motion: reduce) {
.switcher-track,
.switcher-thumb {
@ -173,19 +158,18 @@ $switcher-transition-duration: 0.2s;
}
}
// Light mode color overrides
:global(.light) {
.switcher {
--switcher-track-bg: var(--color-background-secondary);
--switcher-thumb-bg: var(--color-foreground-secondary);
}
// TODO: Create new tier two tokens that represent this element
// // Light mode color overrides
// :global(.light) {
// .switcher {
// --switcher-track-bg: var(--color-background-secondary);
// }
.switcher-checked {
--switcher-track-bg: var(--color-accent-primary-muted);
--switcher-thumb-bg: var(--color-background-default);
// .switcher-checked {
// --switcher-track-bg: var(--color-accent-primary-muted);
&:hover {
--switcher-thumb-bg: var(--color-background-default);
}
}
}
// &:hover {
// --switcher-thumb-bg: var(--color-background-default);
// }
// }
// }

View File

@ -34,45 +34,16 @@ export default {
args: {
disabled: false,
size: "md",
defaultChecked: false,
defaultChecked: false
},
parameters: {
controls: { exclude: ["id", "class", "dataTestid", "on-change"] },
},
render: ({ onChange, ...args }) => (
<Switcher {...args} onChange={onChange} data-testid="switcher" label="Enable notifications" />
<Switcher {...args} onChange={onChange} label="Enable notifications"/>
),
};
export const DefaultUncontrolled = {
args: {
label: "Enable notifications",
defaultChecked: false,
},
};
export const Controlled = {
render: ({ onChange, ...args }) => {
const [checked, setChecked] = React.useState(false);
const handleChange = (newChecked, event) => {
setChecked(newChecked);
if (onChange) onChange(newChecked, event);
};
return (
<Switcher
{...args}
checked={checked}
onChange={handleChange}
data-testid="controlled-switcher"
/>
);
},
args: {
label: "Controlled switcher",
},
};
export const Default = {};
export const WithLongLabel = {
args: {
@ -81,7 +52,7 @@ export const WithLongLabel = {
},
render: ({ ...args }) => (
<div style={{ maxWidth: "300px" }}>
<Switcher {...args} data-testid="long-label-switcher" />
<Switcher {...args}/>
</div>
),
};
@ -91,54 +62,6 @@ export const WithoutVisibleLabel = {
defaultChecked: false,
},
render: ({ ...args }) => (
<Switcher {...args} data-testid="no-label-switcher" />
<Switcher {...args} aria-label="Enable notification"/>
),
};
export const Interactive = {
render: () => {
const [notifications, setNotifications] = React.useState(true);
const [darkMode, setDarkMode] = React.useState(false);
const [autoSave, setAutoSave] = React.useState(true);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<Switcher
label="Enable notifications"
checked={notifications}
onChange={(checked) => setNotifications(checked)}
data-testid="notifications-switcher"
/>
<Switcher
label="Dark mode"
checked={darkMode}
onChange={(checked) => setDarkMode(checked)}
data-testid="dark-mode-switcher"
/>
<Switcher
label="Auto-save documents"
checked={autoSave}
onChange={(checked) => setAutoSave(checked)}
disabled={!notifications}
data-testid="auto-save-switcher"
/>
<div style={{
marginTop: "16px",
padding: "12px",
backgroundColor: "#f5f5f5",
color: "#000",
borderRadius: "4px",
fontSize: "14px"
}}>
<strong>Current state:</strong>
<ul style={{ margin: "8px 0 0 0", paddingLeft: "20px" }}>
<li>Notifications: {notifications ? "On" : "Off"}</li>
<li>Dark mode: {darkMode ? "On" : "Off"}</li>
<li>Auto-save: {autoSave ? "On" : "Off"}</li>
</ul>
</div>
</div>
);
},
};
};