From 2de6b6460e4c7b403fad58330a6c1743bf685ba3 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 3 Oct 2025 15:02:31 +0200 Subject: [PATCH] :recycle: Review on switcher component --- .../ui/ds/controls/switcher/switcher.cljs | 102 ++++------ .../main/ui/ds/controls/switcher/switcher.mdx | 84 +-------- .../ui/ds/controls/switcher/switcher.scss | 176 ++++++++---------- .../ds/controls/switcher/switcher.stories.jsx | 89 +-------- 4 files changed, 130 insertions(+), 321 deletions(-) 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 0901eac9ba..6b594f749b 100644 --- a/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs @@ -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)}]]])) 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 2008ab495f..7282f0f6e5 100644 --- a/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx @@ -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). - + ## 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))}]) -``` - - +## 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. -## 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 - - 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 4bc7ddad88..aceed7594a 100644 --- a/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss @@ -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); +// } +// } +// } 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 678fac3ce7..bd8242821b 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 @@ -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 }) => ( - + ), }; - -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 ( - - ); - }, - args: { - label: "Controlled switcher", - }, -}; +export const Default = {}; export const WithLongLabel = { args: { @@ -81,7 +52,7 @@ export const WithLongLabel = { }, render: ({ ...args }) => (
- +
), }; @@ -91,54 +62,6 @@ export const WithoutVisibleLabel = { defaultChecked: false, }, render: ({ ...args }) => ( - + ), -}; - -export const Interactive = { - render: () => { - const [notifications, setNotifications] = React.useState(true); - const [darkMode, setDarkMode] = React.useState(false); - const [autoSave, setAutoSave] = React.useState(true); - - return ( -
- setNotifications(checked)} - data-testid="notifications-switcher" - /> - setDarkMode(checked)} - data-testid="dark-mode-switcher" - /> - setAutoSave(checked)} - disabled={!notifications} - data-testid="auto-save-switcher" - /> - -
- Current state: -
    -
  • Notifications: {notifications ? "On" : "Off"}
  • -
  • Dark mode: {darkMode ? "On" : "Off"}
  • -
  • Auto-save: {autoSave ? "On" : "Off"}
  • -
-
-
- ); - }, -}; +}; \ No newline at end of file