diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index 0c17ed51b2..2645ced7e1 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -13,6 +13,7 @@ [app.main.ui.ds.controls.input :refer [input*]] [app.main.ui.ds.controls.numeric-input :refer [numeric-input*]] [app.main.ui.ds.controls.select :refer [select*]] + [app.main.ui.ds.controls.switcher.switcher :refer [switcher*]] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] [app.main.ui.ds.controls.utilities.label :refer [label*]] @@ -60,6 +61,7 @@ :Loader loader* :RawSvg raw-svg* :Select select* + :Switcher switcher* :Combobox combobox* :Text text* :TabSwitcher tab-switcher* diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs b/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs new file mode 100644 index 0000000000..df56138855 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs @@ -0,0 +1,99 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.ds.controls.switcher.switcher + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [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] + [:data-testid {:optional true} :string]]) + +(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] + (let [id (or id (mf/use-id)) + size (keyword (d/nilv size "md")) + disabled (d/nilv disabled false) + + ;; 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) + + ;; Handle toggle + handle-toggle (mf/use-fn + (mf/deps controlled? current-checked on-change internal-checked*) + (fn [event] + (when-not disabled + (let [new-checked (not current-checked)] + (when-not controlled? + (reset! internal-checked* new-checked)) + (when on-change + (on-change new-checked event)))))) + + ;; Handle keyboard events + handle-keydown (mf/use-fn + (mf/deps handle-toggle) + (fn [event] + (when (or (= (.-key event) " ") (= (.-key event) "Enter")) + (.preventDefault event) + (handle-toggle event)))) + + ;; Label click handler + handle-label-click (mf/use-fn + (mf/deps handle-toggle) + (fn [event] + (.preventDefault event) + (handle-toggle event))) + + has-label (not (str/blank? label)) + effective-aria-label (or aria-label (when-not has-label "Toggle switch"))] + + [:div {:class (dm/str class " " (stl/css-case :switcher-wrapper true))} + (when has-label + [:label {:for id + :class (stl/css :switcher-label) + :on-click handle-label-click} + label]) + [:div {:id id + :ref ref + :role "switch" + :tabIndex (if disabled -1 0) + :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 + :switcher--sm (= size :sm) + :switcher--md (= size :md) + :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)}]]]])) + +;; 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 new file mode 100644 index 0000000000..e95084bf7c --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx @@ -0,0 +1,159 @@ +{ /* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + + Copyright (c) KALEIDOS INC */ } + +import { Canvas, Meta } from '@storybook/blocks'; +import * as SwitcherStories 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. + + + +## Anatomy + +The switcher component consists of three main parts: + +- **Label** (optional): Text that describes what the switcher controls +- **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))}]) +``` + + + +### Different Sizes + +The switcher supports three size variants: `"sm"`, `"md"` (default), and `"lg"`. + +```clj +[:> switcher* {:size "sm" :label "Small"}] +[:> switcher* {:size "md" :label "Medium"}] +[:> switcher* {:size "lg" :label "Large"}] +``` + + + +### Disabled State + +Disabled switchers are non-interactive and visually distinct. + +```clj +[:> switcher* {:label "Disabled feature" + :disabled true + :default-checked false}] +``` + + + +### Without Visible Label + +When no visible label is provided, use `aria-label` for accessibility. + +```clj +[:> switcher* {:aria-label "Toggle dark mode" + :default-checked false}] +``` + + + +## 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: + +### 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 new file mode 100644 index 0000000000..8af4ff08a1 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss @@ -0,0 +1,197 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "ds/colors.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; + +$switcher-sm-track-width: $sz-32; +$switcher-sm-track-height: $sz-16; +$switcher-sm-thumb-size: $sz-12; + +$switcher-md-track-width: $sz-40; +$switcher-md-track-height: $sz-24; +$switcher-md-thumb-size: $sz-16; + +$switcher-lg-track-width: $sz-48; +$switcher-lg-track-height: $sz-28; +$switcher-lg-thumb-size: $sz-24; + +$switcher-transition-duration: 0.2s; + +.switcher-wrapper { + display: flex; + align-items: center; + gap: var(--sp-s); +} + +.switcher-label { + color: var(--color-foreground-primary); + cursor: pointer; + user-select: none; + + &:hover { + color: var(--color-foreground-primary); + } +} + +.switcher { + position: relative; + display: inline-block; + cursor: pointer; + outline: none; + border: none; + background: transparent; + padding: 0; + + // Focus ring using DS tokens - teal border around track + &: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(--color-foreground-secondary); + } + + .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 { + .switcher__track { + width: $switcher-md-track-width; + height: $switcher-md-track-height; + } + + .switcher__thumb { + width: $switcher-md-thumb-size; + height: $switcher-md-thumb-size; + top: calc((#{$switcher-md-track-height} - #{$switcher-md-thumb-size}) / 2); + left: calc((#{$switcher-md-track-height} - #{$switcher-md-thumb-size}) / 2); + } + + &.is-checked .switcher__thumb { + transform: translateX(calc(#{$switcher-md-track-width} - #{$switcher-md-track-height})); + } + } + + // Size variants - Small + &.switcher--sm { + .switcher__track { + width: $switcher-sm-track-width; + height: $switcher-sm-track-height; + } + + .switcher__thumb { + width: $switcher-sm-thumb-size; + height: $switcher-sm-thumb-size; + top: calc((#{$switcher-sm-track-height} - #{$switcher-sm-thumb-size}) / 2); + left: calc((#{$switcher-sm-track-height} - #{$switcher-sm-thumb-size}) / 2); + } + + &.is-checked .switcher__thumb { + transform: translateX(calc(#{$switcher-sm-track-width} - #{$switcher-sm-track-height})); + } + } + + // Size variants - Large + &.switcher--lg { + .switcher__track { + width: $switcher-lg-track-width; + height: $switcher-lg-track-height; + } + + .switcher__thumb { + width: $switcher-lg-thumb-size; + height: $switcher-lg-thumb-size; + top: calc((#{$switcher-lg-track-height} - #{$switcher-lg-thumb-size}) / 2); + left: calc((#{$switcher-lg-track-height} - #{$switcher-lg-thumb-size}) / 2); + } + + &.is-checked .switcher__thumb { + transform: translateX(calc(#{$switcher-lg-track-width} - #{$switcher-lg-track-height})); + } + } +} + +.switcher__track { + position: relative; + border-radius: 9999px; + background-color: var(--color-background-quaternary); + transition: background-color $switcher-transition-duration ease-in-out; + + // ON state - use success/teal color from DS + .switcher.is-checked & { + background-color: var(--color-accent-success); + } + + // Hover states (only when not disabled) + .switcher:not(.is-disabled):hover:not(.is-checked) & { + background-color: var(--color-background-quaternary); + } + + .switcher:not(.is-disabled):hover.is-checked & { + background-color: var(--color-accent-tertiary); + } +} + +.switcher__thumb { + position: absolute; + border-radius: 50%; + background-color: var(--color-foreground-secondary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); + transition: transform $switcher-transition-duration ease-in-out, background-color $switcher-transition-duration ease-in-out; + + // ON state - contrasting thumb (white in dark theme, dark in light theme) + .switcher.is-checked & { + background-color: var(--color-foreground-primary); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08); + } + + // Hover states - contrasting thumb for OFF state + .switcher:not(.is-disabled):hover:not(.is-checked) & { + background-color: var(--color-foreground-primary); + } + + // Hover states - keep contrasting thumb for ON state + .switcher:not(.is-disabled):hover.is-checked & { + background-color: var(--color-foreground-primary); + } +} + +@media (prefers-reduced-motion: reduce) { + .switcher__track, + .switcher__thumb { + transition: none; + } +} 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 new file mode 100644 index 0000000000..1afda9e9b7 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.stories.jsx @@ -0,0 +1,228 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import * as React from "react"; +import Components from "@target/components"; + +const { Switcher } = Components; + +export default { + title: "DS/Controls/Switcher", + component: Components.Switcher, + argTypes: { + checked: { + control: { type: "boolean" }, + description: "Controlled checked state", + }, + defaultChecked: { + control: { type: "boolean" }, + description: "Default checked state for uncontrolled mode", + }, + label: { + control: { type: "text" }, + description: "Label text displayed next to the switcher", + }, + disabled: { + control: { type: "boolean" }, + description: "Whether the switcher is disabled", + }, + size: { + options: ["sm", "md", "lg"], + control: { type: "select" }, + description: "Size variant of the switcher", + }, + "aria-label": { + control: { type: "text" }, + description: "Accessible label when no visible label is provided", + }, + onChange: { + action: "changed", + description: "Callback fired when the switcher state changes", + }, + }, + args: { + label: "Enable notifications", + disabled: false, + size: "md", + defaultChecked: false, + }, + parameters: { + controls: { exclude: ["id", "class", "dataTestid"] }, + }, + 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 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", + defaultChecked: true, + }, + render: ({ ...args }) => ( +
+ +
+ ), +}; + +export const WithoutVisibleLabel = { + args: { + "aria-label": "Toggle dark mode", + defaultChecked: false, + }, + render: ({ ...args }) => ( + + ), +}; + +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); + 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"}
  • +
+
+
+ ); + }, +};