@fragments-sdk/ui 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +14 -2
  3. package/fragments.json +1 -1
  4. package/package.json +23 -2
  5. package/src/assets/fragments-logo.tsx +37 -0
  6. package/src/assets/fragments_logo.svg +1 -0
  7. package/src/assets/fragments_logo_text.svg +1 -0
  8. package/src/blocks/AccountSettings.block.ts +1 -1
  9. package/src/blocks/ActivityFeed.block.ts +7 -7
  10. package/src/blocks/ChatInterface.block.ts +36 -80
  11. package/src/blocks/DashboardLayout.block.ts +85 -66
  12. package/src/blocks/DashboardPage.block.ts +298 -0
  13. package/src/blocks/EmptyState.block.ts +6 -4
  14. package/src/blocks/FeatureGrid.block.ts +1 -1
  15. package/src/blocks/LoginForm.block.ts +21 -26
  16. package/src/blocks/PricingComparison.block.ts +1 -1
  17. package/src/blocks/SettingsPanel.block.ts +4 -4
  18. package/src/blocks/ShoppingCart.block.ts +2 -2
  19. package/src/components/Accordion/Accordion.fragment.tsx +67 -1
  20. package/src/components/Alert/Alert.fragment.tsx +69 -1
  21. package/src/components/Alert/Alert.module.scss +7 -3
  22. package/src/components/AppShell/AppShell.fragment.tsx +326 -87
  23. package/src/components/Avatar/Avatar.fragment.tsx +35 -2
  24. package/src/components/Badge/Badge.fragment.tsx +47 -9
  25. package/src/components/Badge/Badge.module.scss +1 -0
  26. package/src/components/Breadcrumbs/Breadcrumbs.fragment.tsx +1 -1
  27. package/src/components/Breadcrumbs/Breadcrumbs.module.scss +3 -0
  28. package/src/components/Button/Button.fragment.tsx +1 -1
  29. package/src/components/Checkbox/Checkbox.fragment.tsx +4 -4
  30. package/src/components/Checkbox/Checkbox.module.scss +7 -7
  31. package/src/components/Checkbox/index.tsx +6 -1
  32. package/src/components/Chip/Chip.fragment.tsx +1 -1
  33. package/src/components/CodeBlock/CodeBlock.fragment.tsx +10 -2
  34. package/src/components/CodeBlock/CodeBlock.module.scss +48 -11
  35. package/src/components/CodeBlock/CodeBlock.test.tsx +51 -1
  36. package/src/components/CodeBlock/index.tsx +221 -3
  37. package/src/components/ColorPicker/ColorPicker.fragment.tsx +1 -1
  38. package/src/components/ColorPicker/ColorPicker.module.scss +8 -7
  39. package/src/components/Combobox/Combobox.fragment.tsx +1 -1
  40. package/src/components/Combobox/Combobox.module.scss +1 -3
  41. package/src/components/Field/index.tsx +1 -1
  42. package/src/components/Form/Form.fragment.tsx +4 -4
  43. package/src/components/Input/Input.fragment.tsx +1 -1
  44. package/src/components/Input/Input.test.tsx +35 -0
  45. package/src/components/Input/index.tsx +47 -2
  46. package/src/components/Menu/Menu.module.scss +2 -0
  47. package/src/components/Message/Message.module.scss +3 -3
  48. package/src/components/Popover/Popover.fragment.tsx +1 -1
  49. package/src/components/Popover/Popover.module.scss +1 -3
  50. package/src/components/Prompt/Prompt.module.scss +6 -19
  51. package/src/components/Prompt/Prompt.test.tsx +8 -0
  52. package/src/components/Prompt/index.tsx +12 -1
  53. package/src/components/RadioGroup/RadioGroup.fragment.tsx +4 -3
  54. package/src/components/RadioGroup/RadioGroup.module.scss +9 -9
  55. package/src/components/RadioGroup/index.tsx +5 -1
  56. package/src/components/Sidebar/Sidebar.module.scss +9 -2
  57. package/src/components/Sidebar/Sidebar.test.tsx +6 -0
  58. package/src/components/Sidebar/index.tsx +4 -1
  59. package/src/components/Slider/Slider.fragment.tsx +2 -2
  60. package/src/components/Slider/Slider.module.scss +2 -0
  61. package/src/components/Switch/index.ts +1 -0
  62. package/src/components/Table/Table.fragment.tsx +1 -1
  63. package/src/components/Theme/Theme.fragment.tsx +16 -0
  64. package/src/components/Theme/ThemeToggle.module.scss +4 -3
  65. package/src/components/Toast/Toast.fragment.tsx +1 -0
  66. package/src/components/Toast/Toast.module.scss +9 -4
  67. package/src/components/Toggle/Toggle.fragment.tsx +32 -32
  68. package/src/components/Toggle/Toggle.module.scss +33 -26
  69. package/src/components/Toggle/Toggle.test.tsx +10 -10
  70. package/src/components/Toggle/index.tsx +23 -15
  71. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +34 -2
  72. package/src/index.ts +9 -1
  73. package/src/tokens/_derive.scss +32 -8
  74. package/src/tokens/_mixins.scss +14 -0
  75. package/src/tokens/_variables.scss +12 -6
  76. package/src/blocks/AIChat.block.ts +0 -266
  77. package/src/blocks/AppShell.block.ts +0 -175
  78. package/src/blocks/CTABanner.block.ts +0 -24
  79. package/src/blocks/CardGrid.block.ts +0 -22
  80. package/src/blocks/CodeExamples.block.ts +0 -66
  81. package/src/blocks/ConfirmDialog.block.ts +0 -19
  82. package/src/blocks/ConversationWithHistory.block.ts +0 -45
  83. package/src/blocks/DashboardNav.block.ts +0 -183
  84. package/src/blocks/ForgotPassword.block.ts +0 -26
  85. package/src/blocks/FormLayout.block.ts +0 -31
  86. package/src/blocks/InsetDashboardLayout.block.ts +0 -79
  87. package/src/blocks/MetricDashboard.block.ts +0 -38
  88. package/src/blocks/NewsletterSignup.block.ts +0 -26
  89. package/src/blocks/NotificationList.block.ts +0 -39
  90. package/src/blocks/NotificationPreferences.block.ts +0 -40
  91. package/src/blocks/OrderSummary.block.ts +0 -52
  92. package/src/blocks/ProfileEditForm.block.ts +0 -51
  93. package/src/blocks/SearchResults.block.ts +0 -39
  94. package/src/blocks/SettingsPage.block.ts +0 -58
  95. package/src/blocks/StreamingMessage.block.ts +0 -24
  96. package/src/blocks/TestimonialCard.block.ts +0 -27
  97. package/src/blocks/UserProfileCard.block.ts +0 -29
  98. package/src/recipes/AIChat.recipe.ts +0 -266
  99. package/src/recipes/AppShell.recipe.ts +0 -175
  100. package/src/recipes/CardGrid.recipe.ts +0 -22
  101. package/src/recipes/ChatInterface.recipe.ts +0 -87
  102. package/src/recipes/CodeExamples.recipe.ts +0 -66
  103. package/src/recipes/ConfirmDialog.recipe.ts +0 -19
  104. package/src/recipes/DashboardLayout.recipe.ts +0 -73
  105. package/src/recipes/DashboardNav.recipe.ts +0 -183
  106. package/src/recipes/FormLayout.recipe.ts +0 -31
  107. package/src/recipes/LoginForm.recipe.ts +0 -33
  108. package/src/recipes/SettingsPage.recipe.ts +0 -58
@@ -7,6 +7,7 @@ function renderPrompt(props: {
7
7
  placeholder?: string;
8
8
  disabled?: boolean;
9
9
  defaultValue?: string;
10
+ loading?: boolean;
10
11
  } = {}) {
11
12
  return render(
12
13
  <Prompt
@@ -14,6 +15,7 @@ function renderPrompt(props: {
14
15
  onSubmit={props.onSubmit}
15
16
  disabled={props.disabled}
16
17
  defaultValue={props.defaultValue}
18
+ loading={props.loading}
17
19
  >
18
20
  <Prompt.Textarea />
19
21
  <Prompt.Toolbar>
@@ -82,6 +84,12 @@ describe('Prompt', () => {
82
84
  expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
83
85
  });
84
86
 
87
+ it('shows loading spinner icon in submit button when loading', () => {
88
+ renderPrompt({ loading: true, defaultValue: 'Submitting...' });
89
+ expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
90
+ expect(screen.getByRole('status', { name: /submitting/i })).toBeInTheDocument();
91
+ });
92
+
85
93
  it('has no accessibility violations', async () => {
86
94
  const { container } = renderPrompt();
87
95
  await expectNoA11yViolations(container);
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react';
4
4
  import styles from './Prompt.module.scss';
5
5
  import '../../styles/globals.scss';
6
+ import { Loading } from '../Loading';
6
7
 
7
8
  // ============================================
8
9
  // Types
@@ -404,7 +405,17 @@ function PromptSubmit({
404
405
  disabled={isDisabled}
405
406
  aria-label={ariaLabel}
406
407
  >
407
- {children ?? <ArrowUpIcon />}
408
+ {loading ? (
409
+ <Loading
410
+ size="sm"
411
+ variant="spinner"
412
+ color="current"
413
+ label="Submitting"
414
+ className={styles.submitSpinner}
415
+ />
416
+ ) : (
417
+ children ?? <ArrowUpIcon />
418
+ )}
408
419
  </button>
409
420
  );
410
421
  }
@@ -23,7 +23,7 @@ export default defineFragment({
23
23
  whenNot: [
24
24
  'Multiple selections allowed (use Checkbox group)',
25
25
  'Many options (use Select)',
26
- 'Binary on/off choice (use Toggle/Switch)',
26
+ 'Binary on/off choice (use Switch)',
27
27
  'Options need to be searchable (use Combobox)',
28
28
  ],
29
29
  guidelines: [
@@ -96,9 +96,9 @@ export default defineFragment({
96
96
  note: 'Use Select for many options or limited space',
97
97
  },
98
98
  {
99
- component: 'Toggle',
99
+ component: 'Switch',
100
100
  relationship: 'alternative',
101
- note: 'Use Toggle for binary on/off choices',
101
+ note: 'Use Switch for binary on/off choices',
102
102
  },
103
103
  ],
104
104
 
@@ -118,6 +118,7 @@ export default defineFragment({
118
118
  a11yRules: [
119
119
  'A11Y_RADIO_GROUP',
120
120
  'A11Y_LABEL_REQUIRED',
121
+ 'A11Y_TARGET_SIZE_MIN',
121
122
  ],
122
123
  bans: [],
123
124
  },
@@ -30,11 +30,15 @@
30
30
  // Individual item wrapper
31
31
  .itemWrapper {
32
32
  display: inline-flex;
33
- align-items: flex-start;
33
+ align-items: center;
34
34
  gap: var(--fui-space-2, $fui-space-2);
35
35
  cursor: pointer;
36
36
  font-family: var(--fui-font-sans, $fui-font-sans);
37
37
 
38
+ &[data-has-description] {
39
+ align-items: flex-start;
40
+ }
41
+
38
42
  &[data-disabled] {
39
43
  cursor: not-allowed;
40
44
  opacity: 0.5;
@@ -44,15 +48,13 @@
44
48
  // The radio circle
45
49
  .radio {
46
50
  @include interactive-base;
51
+ @include touch-target;
47
52
 
48
53
  position: relative;
49
- display: inline-flex;
50
- align-items: center;
51
- justify-content: center;
52
54
  flex-shrink: 0;
53
55
  width: 1rem;
54
56
  height: 1rem;
55
- margin-top: var(--fui-space-0-5, $fui-space-0-5);
57
+ margin-top: 0;
56
58
  background-color: var(--fui-bg-elevated, $fui-bg-elevated);
57
59
  border: 1px solid var(--fui-border-strong, $fui-border-strong);
58
60
  border-radius: var(--fui-radius-full, $fui-radius-full);
@@ -80,13 +82,11 @@
80
82
  .radioSm {
81
83
  width: 0.875rem;
82
84
  height: 0.875rem;
83
- margin-top: var(--fui-space-0-75, $fui-space-0-75);
84
85
  }
85
86
 
86
87
  .radioLg {
87
88
  width: 1.25rem;
88
89
  height: 1.25rem;
89
- margin-top: 0;
90
90
  }
91
91
 
92
92
  // The indicator dot - use absolute positioning for perfect centering
@@ -94,8 +94,8 @@
94
94
  position: absolute;
95
95
  top: 50%;
96
96
  left: 50%;
97
- width: 0.5rem;
98
- height: 0.5rem;
97
+ width: 0.75rem;
98
+ height: 0.75rem;
99
99
  background-color: var(--fui-color-accent, $fui-color-accent);
100
100
  border-radius: var(--fui-radius-full, $fui-radius-full);
101
101
 
@@ -112,7 +112,11 @@ function RadioItem({
112
112
  }
113
113
 
114
114
  return (
115
- <label className={wrapperClasses} data-disabled={disabled || undefined}>
115
+ <label
116
+ className={wrapperClasses}
117
+ data-disabled={disabled || undefined}
118
+ data-has-description={description ? true : undefined}
119
+ >
116
120
  <BaseRadio.Root
117
121
  value={value}
118
122
  disabled={disabled}
@@ -124,11 +124,18 @@
124
124
 
125
125
  .nav {
126
126
  flex: 1;
127
- overflow-y: auto;
128
- overflow-x: hidden;
127
+ min-height: 0;
128
+ display: flex;
129
+ flex-direction: column;
130
+ overflow: hidden;
129
131
  padding: var(--fui-padding-item-sm, $fui-padding-item-sm);
130
132
  }
131
133
 
134
+ .navScrollArea {
135
+ flex: 1;
136
+ min-height: 0;
137
+ }
138
+
132
139
  // ============================================
133
140
  // Section
134
141
  // ============================================
@@ -55,6 +55,12 @@ describe('Sidebar', () => {
55
55
  expect(screen.getByRole('navigation', { name: /main/i })).toBeInTheDocument();
56
56
  });
57
57
 
58
+ it('uses ScrollArea with fade indicators in nav content', () => {
59
+ renderSidebar();
60
+ const scrollAreaRoot = screen.getByRole('navigation', { name: /main/i }).querySelector('[data-orientation="vertical"]');
61
+ expect(scrollAreaRoot).toBeInTheDocument();
62
+ });
63
+
58
64
  it('renders section with label', () => {
59
65
  renderSidebar();
60
66
  expect(screen.getByText('Section One')).toBeInTheDocument();
@@ -3,6 +3,7 @@ import styles from './Sidebar.module.scss';
3
3
  import { Tooltip } from '../Tooltip';
4
4
  import { Skeleton } from '../Skeleton';
5
5
  import { Collapsible } from '../Collapsible';
6
+ import { ScrollArea } from '../ScrollArea';
6
7
  import { useFocusTrap } from '../../utils/a11y';
7
8
  // Import globals to ensure CSS variables are defined
8
9
  import '../../styles/globals.scss';
@@ -655,7 +656,9 @@ function SidebarNav({ children, 'aria-label': ariaLabel = 'Main navigation', cla
655
656
  const classes = [styles.nav, className].filter(Boolean).join(' ');
656
657
  return (
657
658
  <nav className={classes} aria-label={ariaLabel}>
658
- {children}
659
+ <ScrollArea orientation="vertical" showFades className={styles.navScrollArea}>
660
+ {children}
661
+ </ScrollArea>
659
662
  </nav>
660
663
  );
661
664
  }
@@ -24,7 +24,7 @@ export default defineFragment({
24
24
  whenNot: [
25
25
  'Precise numeric input (use Input type="number")',
26
26
  'Discrete options (use RadioGroup or Select)',
27
- 'Yes/no choices (use Toggle)',
27
+ 'Yes/no choices (use Switch)',
28
28
  ],
29
29
  guidelines: [
30
30
  'Always provide a label describing what the slider controls',
@@ -109,7 +109,7 @@ export default defineFragment({
109
109
  'input.numeric',
110
110
  'control.slider',
111
111
  ],
112
- a11yRules: ['A11Y_LABEL_REQUIRED', 'A11Y_KEYBOARD_ACCESSIBLE'],
112
+ a11yRules: ['A11Y_LABEL_REQUIRED', 'A11Y_KEYBOARD_ACCESSIBLE', 'A11Y_TARGET_SIZE_MIN'],
113
113
  },
114
114
 
115
115
  variants: [
@@ -57,6 +57,8 @@
57
57
  }
58
58
 
59
59
  .thumb {
60
+ @include min-target-size;
61
+
60
62
  width: $fui-slider-thumb-size;
61
63
  height: $fui-slider-thumb-size;
62
64
  background-color: var(--fui-bg-primary, $fui-bg-primary);
@@ -0,0 +1 @@
1
+ export { Switch, type SwitchProps, Toggle, type ToggleProps } from '../Toggle';
@@ -58,7 +58,7 @@ export default defineFragment({
58
58
  ],
59
59
  whenNot: [
60
60
  'Simple lists (use List component)',
61
- 'Card-based layouts (use CardGrid)',
61
+ 'Card-based layouts (use Grid with Cards)',
62
62
  'Heavily interactive data (consider DataGrid)',
63
63
  'Small screens (consider card or list view)',
64
64
  ],
@@ -89,6 +89,22 @@ export default defineFragment({
89
89
  { component: 'AppShell', relationship: 'sibling', note: 'ThemeProvider typically wraps AppShell' },
90
90
  ],
91
91
 
92
+ contract: {
93
+ propsSummary: [
94
+ 'defaultMode: light|dark|system - initial theme mode',
95
+ 'mode: light|dark|system - controlled mode',
96
+ 'onModeChange: (mode) => void - change handler',
97
+ 'attribute: data-theme|class - DOM theme attribute',
98
+ 'ThemeToggle size: sm|md|lg - toggle button size',
99
+ ],
100
+ scenarioTags: [
101
+ 'navigation.theme',
102
+ 'settings.appearance',
103
+ 'ui.mode-toggle',
104
+ ],
105
+ a11yRules: ['A11Y_TARGET_SIZE_MIN'],
106
+ },
107
+
92
108
  variants: [
93
109
  {
94
110
  name: 'Default',
@@ -16,6 +16,7 @@
16
16
  .toggleButton {
17
17
  @include button-reset;
18
18
  @include interactive-base;
19
+ @include min-target-size;
19
20
 
20
21
  display: flex;
21
22
  align-items: center;
@@ -29,8 +30,8 @@
29
30
  }
30
31
 
31
32
  &:focus-visible {
32
- outline: 2px solid var(--fui-color-accent, $fui-color-accent);
33
- outline-offset: 1px;
33
+ outline: var(--fui-focus-ring-width, $fui-focus-ring-width) solid var(--fui-focus-ring-color, $fui-focus-ring-color);
34
+ outline-offset: var(--fui-focus-ring-offset, $fui-focus-ring-offset);
34
35
  }
35
36
 
36
37
  svg {
@@ -41,7 +42,7 @@
41
42
  .toggleButtonActive {
42
43
  background-color: var(--fui-bg-elevated, $fui-bg-elevated);
43
44
  color: var(--fui-text-primary, $fui-text-primary);
44
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
45
+ box-shadow: var(--fui-shadow-sm, $fui-shadow-sm);
45
46
  }
46
47
 
47
48
  // Size variants
@@ -150,6 +150,7 @@ export default defineFragment({
150
150
  ],
151
151
  a11yRules: [
152
152
  'A11Y_ALERT_ROLE',
153
+ 'A11Y_TARGET_SIZE_MIN',
153
154
  ],
154
155
  bans: [],
155
156
  },
@@ -1,4 +1,5 @@
1
1
  @use '../../tokens/variables' as *;
2
+ @use '../../tokens/mixins' as *;
2
3
 
3
4
  // ============================================
4
5
  // Toast Container
@@ -77,7 +78,7 @@
77
78
  @keyframes toastEnter {
78
79
  from {
79
80
  opacity: 0;
80
- transform: translateY(8px) scale(0.96);
81
+ transform: translateY($fui-anim-offset-md) scale(0.96);
81
82
  }
82
83
  to {
83
84
  opacity: 1;
@@ -168,6 +169,11 @@
168
169
 
169
170
  .action {
170
171
  flex-shrink: 0;
172
+ @include min-target-size;
173
+
174
+ display: inline-flex;
175
+ align-items: center;
176
+ justify-content: center;
171
177
  padding: var(--fui-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
172
178
  font-size: var(--fui-font-size-sm, $fui-font-size-sm);
173
179
  font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
@@ -194,9 +200,8 @@
194
200
 
195
201
  .close {
196
202
  flex-shrink: 0;
197
- display: flex;
198
- align-items: center;
199
- justify-content: center;
203
+ @include touch-target;
204
+
200
205
  width: 1.5rem;
201
206
  height: 1.5rem;
202
207
  margin: -2px -4px -2px 0;
@@ -1,18 +1,18 @@
1
1
  import React, { useState } from 'react';
2
2
  import { defineFragment } from '@fragments/core';
3
- import { Toggle } from '.';
3
+ import { Switch } from '.';
4
4
 
5
5
  // Stateful wrapper for interactive demos
6
- function StatefulToggle(props: React.ComponentProps<typeof Toggle>) {
6
+ function StatefulSwitch(props: React.ComponentProps<typeof Switch>) {
7
7
  const [checked, setChecked] = useState(props.checked ?? false);
8
- return <Toggle {...props} checked={checked} onChange={setChecked} />;
8
+ return <Switch {...props} checked={checked} onChange={setChecked} />;
9
9
  }
10
10
 
11
11
  export default defineFragment({
12
- component: Toggle,
12
+ component: Switch,
13
13
 
14
14
  meta: {
15
- name: 'Toggle',
15
+ name: 'Switch',
16
16
  description: 'Binary on/off switch for settings and preferences. Provides immediate visual feedback and is ideal for options that take effect instantly.',
17
17
  category: 'forms',
18
18
  status: 'stable',
@@ -34,11 +34,11 @@ export default defineFragment({
34
34
  'Complex multi-state options (use select or radio)',
35
35
  ],
36
36
  guidelines: [
37
- 'Toggle should always have a visible label explaining what it controls',
37
+ 'Switch should always have a visible label explaining what it controls',
38
38
  'The "on" state should be the positive/enabling action',
39
39
  'Changes should take effect immediately - no save button needed',
40
- 'Include a description for toggles whose effect isn\'t obvious from the label',
41
- 'Group related toggles visually in settings panels',
40
+ 'Include a description for switches whose effect isn\'t obvious from the label',
41
+ 'Group related switches visually in settings panels',
42
42
  ],
43
43
  accessibility: [
44
44
  'Uses role="switch" with aria-checked for proper semantics',
@@ -51,7 +51,7 @@ export default defineFragment({
51
51
  props: {
52
52
  checked: {
53
53
  type: 'boolean',
54
- description: 'Whether the toggle is in the on state',
54
+ description: 'Whether the switch is in the on state',
55
55
  default: 'false',
56
56
  },
57
57
  onChange: {
@@ -68,19 +68,19 @@ export default defineFragment({
68
68
  },
69
69
  disabled: {
70
70
  type: 'boolean',
71
- description: 'Whether the toggle is non-interactive',
71
+ description: 'Whether the switch is non-interactive',
72
72
  default: 'false',
73
73
  },
74
74
  size: {
75
75
  type: 'enum',
76
- description: 'Toggle track size',
76
+ description: 'Switch track size',
77
77
  values: ['sm', 'md'],
78
78
  default: 'md',
79
79
  },
80
80
  },
81
81
 
82
82
  relations: [
83
- { component: 'Input', relationship: 'sibling', note: 'Input handles text/number entry; Toggle handles boolean state' },
83
+ { component: 'Input', relationship: 'sibling', note: 'Input handles text/number entry; Switch handles boolean state' },
84
84
  { component: 'Checkbox', relationship: 'alternative', note: 'Use Checkbox when change requires form submission' },
85
85
  ],
86
86
 
@@ -91,35 +91,35 @@ export default defineFragment({
91
91
  'label: string - visible label text',
92
92
  'description: string - helper text below label',
93
93
  'disabled: boolean - non-interactive state',
94
- 'size: sm|md - toggle size',
94
+ 'size: sm|md - switch size',
95
95
  ],
96
96
  scenarioTags: [
97
97
  'form.boolean',
98
- 'settings.toggle',
98
+ 'settings.switch',
99
99
  'settings.preference',
100
100
  'form.switch',
101
101
  ],
102
- a11yRules: ['A11Y_SWITCH_ROLE', 'A11Y_SWITCH_LABEL', 'A11Y_SWITCH_FOCUS'],
102
+ a11yRules: ['A11Y_SWITCH_ROLE', 'A11Y_SWITCH_LABEL', 'A11Y_SWITCH_FOCUS', 'A11Y_TARGET_SIZE_MIN'],
103
103
  },
104
104
 
105
105
  variants: [
106
106
  {
107
107
  name: 'Default Off',
108
- description: 'Toggle in the off state',
109
- render: () => <StatefulToggle label="Email notifications" />,
108
+ description: 'Switch in the off state',
109
+ render: () => <StatefulSwitch label="Email notifications" />,
110
110
  args: { label: 'Email notifications' },
111
111
  },
112
112
  {
113
113
  name: 'Checked',
114
- description: 'Toggle in the on state',
115
- render: () => <StatefulToggle checked label="Dark mode" />,
114
+ description: 'Switch in the on state',
115
+ render: () => <StatefulSwitch checked label="Dark mode" />,
116
116
  args: { checked: true, label: 'Dark mode' },
117
117
  },
118
118
  {
119
119
  name: 'With Description',
120
- description: 'Toggle with explanatory helper text',
120
+ description: 'Switch with explanatory helper text',
121
121
  render: () => (
122
- <StatefulToggle
122
+ <StatefulSwitch
123
123
  checked
124
124
  label="Auto-save"
125
125
  description="Automatically save changes as you type"
@@ -129,41 +129,41 @@ export default defineFragment({
129
129
  },
130
130
  {
131
131
  name: 'Small Size',
132
- description: 'Compact toggle for dense settings panels',
132
+ description: 'Compact switch for dense settings panels',
133
133
  render: () => (
134
134
  <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
135
- <StatefulToggle size="sm" checked label="Show line numbers" />
136
- <StatefulToggle size="sm" label="Word wrap" />
137
- <StatefulToggle size="sm" checked label="Minimap" />
135
+ <StatefulSwitch size="sm" checked label="Show line numbers" />
136
+ <StatefulSwitch size="sm" label="Word wrap" />
137
+ <StatefulSwitch size="sm" checked label="Minimap" />
138
138
  </div>
139
139
  ),
140
140
  },
141
141
  {
142
142
  name: 'Disabled States',
143
- description: 'Non-interactive toggles showing both states',
143
+ description: 'Non-interactive switches showing both states',
144
144
  render: () => (
145
145
  <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
146
- <Toggle disabled label="Premium feature (upgrade required)" />
147
- <Toggle disabled checked label="System managed (read-only)" />
146
+ <Switch disabled label="Premium feature (upgrade required)" />
147
+ <Switch disabled checked label="System managed (read-only)" />
148
148
  </div>
149
149
  ),
150
150
  },
151
151
  {
152
152
  name: 'Settings Panel',
153
- description: 'Multiple toggles in a realistic settings layout',
153
+ description: 'Multiple switches in a realistic settings layout',
154
154
  render: () => (
155
155
  <div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '320px' }}>
156
- <StatefulToggle
156
+ <StatefulSwitch
157
157
  checked
158
158
  label="Push notifications"
159
159
  description="Receive push notifications on your device"
160
160
  />
161
- <StatefulToggle
161
+ <StatefulSwitch
162
162
  checked
163
163
  label="Email digest"
164
164
  description="Weekly summary of your activity"
165
165
  />
166
- <StatefulToggle
166
+ <StatefulSwitch
167
167
  label="Marketing emails"
168
168
  description="Product updates and promotional offers"
169
169
  />
@@ -3,7 +3,7 @@
3
3
 
4
4
  .root {
5
5
  display: flex;
6
- align-items: flex-start;
6
+ align-items: center;
7
7
  gap: var(--fui-space-3, $fui-space-3);
8
8
  cursor: pointer;
9
9
  font-family: var(--fui-font-sans, $fui-font-sans);
@@ -14,21 +14,30 @@
14
14
  }
15
15
  }
16
16
 
17
+ .rootWithDescription {
18
+ align-items: flex-start;
19
+
20
+ .track {
21
+ margin-top: var(--fui-space-0-5, $fui-space-0-5);
22
+ }
23
+ }
24
+
17
25
  .track {
18
26
  @include button-reset;
19
27
  @include interactive-base;
28
+ @include min-target-size;
20
29
 
21
30
  position: relative;
22
31
  flex-shrink: 0;
23
32
  border-radius: var(--fui-radius-full, $fui-radius-full);
24
- margin-top: var(--fui-space-0-5, $fui-space-0-5);
25
33
  background-color: var(--fui-border-strong, $fui-border-strong);
34
+ transition: background-color var(--fui-transition-normal, $fui-transition-normal);
26
35
 
27
- &[data-checked] {
36
+ .root[data-checked] & {
28
37
  background-color: var(--fui-color-accent, $fui-color-accent);
29
38
  }
30
39
 
31
- &:hover:not(:disabled) {
40
+ .root:not([data-disabled]) &:hover {
32
41
  opacity: 0.9;
33
42
  }
34
43
  }
@@ -36,43 +45,41 @@
36
45
  .trackSm {
37
46
  width: var(--fui-toggle-width-sm, $fui-toggle-width-sm);
38
47
  height: var(--fui-toggle-height-sm, $fui-toggle-height-sm);
48
+ --_toggle-thumb-size: calc(var(--fui-toggle-height-sm, #{$fui-toggle-height-sm}) - 0.5rem);
49
+ --_toggle-inset: calc((var(--fui-toggle-height-sm, #{$fui-toggle-height-sm}) - var(--_toggle-thumb-size)) / 2);
50
+ --_toggle-translate: calc(
51
+ var(--fui-toggle-width-sm, #{$fui-toggle-width-sm}) -
52
+ var(--_toggle-thumb-size) -
53
+ (var(--_toggle-inset) * 2)
54
+ );
39
55
  }
40
56
 
41
57
  .trackMd {
42
58
  width: var(--fui-toggle-width-md, $fui-toggle-width-md);
43
59
  height: var(--fui-toggle-height-md, $fui-toggle-height-md);
60
+ --_toggle-thumb-size: calc(var(--fui-toggle-height-md, #{$fui-toggle-height-md}) - 0.5rem);
61
+ --_toggle-inset: calc((var(--fui-toggle-height-md, #{$fui-toggle-height-md}) - var(--_toggle-thumb-size)) / 2);
62
+ --_toggle-translate: calc(
63
+ var(--fui-toggle-width-md, #{$fui-toggle-width-md}) -
64
+ var(--_toggle-thumb-size) -
65
+ (var(--_toggle-inset) * 2)
66
+ );
44
67
  }
45
68
 
46
69
  .thumb {
47
70
  position: absolute;
48
- top: $fui-toggle-thumb-offset;
49
- left: $fui-toggle-thumb-offset;
71
+ top: var(--_toggle-inset, $fui-toggle-thumb-offset);
72
+ left: var(--_toggle-inset, $fui-toggle-thumb-offset);
73
+ width: var(--_toggle-thumb-size, $fui-toggle-thumb-md);
74
+ height: var(--_toggle-thumb-size, $fui-toggle-thumb-md);
50
75
  border-radius: 50%;
51
76
  background-color: var(--fui-bg-primary, $fui-bg-primary);
52
77
  box-shadow: var(--fui-shadow-sm, $fui-shadow-sm);
53
78
  transition: transform var(--fui-transition-normal, $fui-transition-normal);
54
79
 
55
- [data-checked] > & {
80
+ .root[data-checked] & {
56
81
  // Move thumb to the right when checked
57
- transform: translateX(calc(100%));
58
- }
59
- }
60
-
61
- .thumbSm {
62
- width: $fui-toggle-thumb-sm;
63
- height: $fui-toggle-thumb-sm;
64
-
65
- [data-checked] > & {
66
- transform: translateX($fui-toggle-thumb-sm);
67
- }
68
- }
69
-
70
- .thumbMd {
71
- width: $fui-toggle-thumb-md;
72
- height: $fui-toggle-thumb-md;
73
-
74
- [data-checked] > & {
75
- transform: translateX($fui-toggle-thumb-md);
82
+ transform: translateX(var(--_toggle-translate, 0));
76
83
  }
77
84
  }
78
85