🎉 Add new component switcher structure

This commit is contained in:
elhombretecla 2025-09-26 19:50:02 +02:00
parent 979b4276ca
commit 6f362f9211
5 changed files with 685 additions and 0 deletions

View File

@ -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*

View File

@ -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*)

View File

@ -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";
<Meta title="DS/Controls/Switcher" />
# 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.
<Canvas of={SwitcherStories.DefaultUncontrolled} />
## 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))}])
```
<Canvas of={SwitcherStories.Controlled} />
### 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"}]
```
<Canvas of={SwitcherStories.Sizes} />
### Disabled State
Disabled switchers are non-interactive and visually distinct.
```clj
[:> switcher* {:label "Disabled feature"
:disabled true
:default-checked false}]
```
<Canvas of={SwitcherStories.Disabled} />
### Without Visible Label
When no visible label is provided, use `aria-label` for accessibility.
```clj
[:> switcher* {:aria-label "Toggle dark mode"
:default-checked false}]
```
<Canvas of={SwitcherStories.WithoutVisibleLabel} />
## 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
<Canvas of={SwitcherStories.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
<Canvas of={SwitcherStories.Interactive} />

View File

@ -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;
}
}

View File

@ -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 }) => (
<Switcher {...args} onChange={onChange} data-testid="switcher" />
),
};
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 Sizes = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<Switcher
size="sm"
label="Small switcher"
defaultChecked={false}
data-testid="small-switcher"
/>
<Switcher
size="md"
label="Medium switcher"
defaultChecked={true}
data-testid="medium-switcher"
/>
<Switcher
size="lg"
label="Large switcher"
defaultChecked={false}
data-testid="large-switcher"
/>
</div>
),
};
export const Disabled = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<Switcher
label="Disabled (off)"
disabled={true}
defaultChecked={false}
data-testid="disabled-off"
/>
<Switcher
label="Disabled (on)"
disabled={true}
defaultChecked={true}
data-testid="disabled-on"
/>
</div>
),
};
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 }) => (
<div style={{ maxWidth: "300px" }}>
<Switcher {...args} data-testid="long-label-switcher" />
</div>
),
};
export const WithoutVisibleLabel = {
args: {
"aria-label": "Toggle dark mode",
defaultChecked: false,
},
render: ({ ...args }) => (
<Switcher {...args} data-testid="no-label-switcher" />
),
};
export const Accessibility = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<div>
<p style={{ marginBottom: "8px", fontSize: "14px", color: "#666" }}>
Try using Tab to focus and Space/Enter to toggle:
</p>
<Switcher
label="Keyboard accessible switcher"
defaultChecked={false}
data-testid="keyboard-switcher"
/>
</div>
<div>
<p style={{ marginBottom: "8px", fontSize: "14px", color: "#666" }}>
Screen reader accessible (no visible label):
</p>
<Switcher
aria-label="Toggle feature X"
defaultChecked={true}
data-testid="screen-reader-switcher"
/>
</div>
</div>
),
};
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",
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>
);
},
};