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 }) => (
-