diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs b/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs index 650a49fdd9..d009b0d422 100644 --- a/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs @@ -28,7 +28,7 @@ (mf/defc switcher* {::mf/forward-ref true ::mf/schema schema:switcher} - [{:keys [id label checked default-checked on-change disabled size aria-label class data-testid] :rest props} ref] + [{:keys [id label checked default-checked on-change disabled size aria-label class] :rest props} ref] (let [id (or id (mf/use-id)) size (keyword (d/nilv size "md")) disabled (d/nilv disabled false) @@ -68,12 +68,16 @@ (handle-toggle event))) has-label (not (str/blank? label)) - effective-aria-label (or aria-label (when-not has-label "Toggle switch"))] + effective-aria-label (if has-label + (or aria-label label) + "Toggle switch")] - [:div {:class (dm/str class " " (stl/css-case :switcher-wrapper true))} + [:div {:class (dm/str class " " (stl/css-case :switcher-wrapper true)) + :data-testid (.-data-testid props)} (when has-label [:label {:for id - :class (stl/css :switcher-label) + :class (stl/css-case :switcher-label true + :is-disabled disabled) :on-click handle-label-click} label]) [:div {:id id @@ -83,7 +87,6 @@ :aria-checked current-checked :aria-disabled disabled :aria-label effective-aria-label - :data-testid data-testid :class (stl/css-case :switcher true :is-checked current-checked :is-disabled disabled @@ -92,8 +95,12 @@ :switcher--lg (= size :lg)) :on-click handle-toggle :on-key-down handle-keydown} - [:div {:class (stl/css :switcher-track)} - [:div {:class (stl/css :switcher-thumb)}]]]])) + [:div {:class (stl/css-case :switcher-track true + :is-checked current-checked + :is-disabled disabled)} + [:div {:class (stl/css-case :switcher-thumb true + :is-checked current-checked + :is-disabled disabled)}]]]])) ;; Export as default (def switcher switcher*) diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx b/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx index 4c56a36424..eb86bcd177 100644 --- a/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx @@ -7,7 +7,7 @@ import { Canvas, Meta } from '@storybook/blocks'; import * as SwitcherStories from "./switcher.stories"; - + # Switcher @@ -26,7 +26,7 @@ The switcher component consists of three main parts: ## 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 | @@ -75,7 +75,7 @@ The switcher supports three size variants: `"sm"`, `"md"` (default), and `"lg"`. ### Disabled State -Disabled switchers are non-interactive and visually distinct. +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" @@ -83,8 +83,6 @@ Disabled switchers are non-interactive and visually distinct. :default-checked false}] ``` - - ### Without Visible Label When no visible label is provided, use `aria-label` for accessibility. @@ -96,22 +94,6 @@ When no visible label is provided, use `aria-label` for accessibility. -## Accessibility - -The switcher component follows accessibility best practices: - -- **Role**: Uses `role="switch"` for proper semantic meaning -- **Keyboard Support**: - - `Tab` to focus the switcher - - `Space` or `Enter` to toggle the state -- **ARIA Attributes**: - - `aria-checked` reflects the current state - - `aria-disabled` when disabled - - `aria-label` for screen readers when no visible label -- **Focus Management**: Visible focus ring for keyboard navigation - - - ## Design Tokens The switcher component uses the following design system tokens: @@ -119,11 +101,9 @@ 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) -- **OFF state thumb**: `--color-foreground-secondary` (gray) -- **ON state thumb**: `--color-foreground-primary` (white in dark theme, black in light theme) -- **Hover state thumb**: `--color-foreground-primary` (contrasting color) +- **Thumb**: `--color-background-primary` (white/light) - **Label**: `--color-foreground-primary` -- **Focus ring**: `--color-accent-tertiary` (teal in dark theme, purple in light theme) +- **Focus ring**: `--color-accent-primary` ### Sizes - **Small**: Track 32×16px, Thumb 12px diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss b/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss index 5e11dfb315..47da45aad7 100644 --- a/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss @@ -27,12 +27,30 @@ $switcher-transition-duration: 0.2s; display: flex; align-items: center; gap: var(--sp-s); + padding: 0; + + // Focus ring using DS tokens - on wrapper cascading to track + &:focus-visible { + .switcher-track { + outline: $b-2 solid var(--color-accent-primary); + outline-offset: $b-2; + } + } } .switcher-label { - color: var(--title-foreground-color); + color: var(--color-foreground-primary); cursor: pointer; user-select: none; + + &:hover { + color: var(--color-foreground-primary); + } +} + +.switcher-label.is-disabled { + cursor: not-allowed; + color: var(--color-foreground-secondary); } .switcher { @@ -44,42 +62,7 @@ $switcher-transition-duration: 0.2s; background: transparent; padding: 0; - &:focus-visible { - .switcher-track { - outline: $b-2 solid var(--color-accent-tertiary); - outline-offset: $b-2; - } - } - // Disabled state - &.is-disabled { - cursor: not-allowed; - - .switcher-label { - cursor: not-allowed; - color: var(--text-foreground-color); - } - - .switcher-track { - background-color: var(--color-background-tertiary); - opacity: 0.6; - } - - &.is-checked .switcher-track { - background-color: var(--color-background-quaternary); - opacity: 0.6; - } - - .switcher-thumb { - background-color: var(--color-foreground-secondary); - opacity: 0.5; - } - - &.is-checked .switcher-thumb { - background-color: var(--color-foreground-secondary); - opacity: 0.6; - } - } // Size variants - Medium (default) &.switcher--md { @@ -179,6 +162,31 @@ $switcher-transition-duration: 0.2s; } } +// Flat modifier-based selectors for states +.switcher.is-disabled { + cursor: not-allowed; +} + +.switcher-track.is-disabled { + background-color: var(--color-background-tertiary); + opacity: 0.6; +} + +.switcher-track.is-disabled.is-checked { + background-color: var(--color-background-quaternary); + opacity: 0.6; +} + +.switcher-thumb.is-disabled { + background-color: var(--color-foreground-secondary); + opacity: 0.5; +} + +.switcher-thumb.is-disabled.is-checked { + background-color: var(--color-foreground-secondary); + opacity: 0.6; +} + @media (prefers-reduced-motion: reduce) { .switcher-track, .switcher-thumb { diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.stories.jsx b/frontend/src/app/main/ui/ds/controls/switcher/switcher.stories.jsx index 1afda9e9b7..6a7a727cd7 100644 --- a/frontend/src/app/main/ui/ds/controls/switcher/switcher.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.stories.jsx @@ -10,7 +10,7 @@ import Components from "@target/components"; const { Switcher } = Components; export default { - title: "DS/Controls/Switcher", + title: "Controls/Switcher", component: Components.Switcher, argTypes: { checked: { @@ -87,50 +87,6 @@ export const Controlled = { }, }; -export const Sizes = { - render: () => ( -
- - - -
- ), -}; - -export const Disabled = { - render: () => ( -
- - -
- ), -}; - export const WithLongLabel = { args: { label: "This is a very long label that demonstrates how the switcher component handles text wrapping and layout when the label content is extensive", @@ -153,33 +109,6 @@ export const WithoutVisibleLabel = { ), }; -export const Accessibility = { - render: () => ( -
-
-

- Try using Tab to focus and Space/Enter to toggle: -

- -
-
-

- Screen reader accessible (no visible label): -

- -
-
- ), -}; - export const Interactive = { render: () => { const [notifications, setNotifications] = React.useState(true); @@ -211,7 +140,8 @@ export const Interactive = {