torch-glare 2.2.0 → 2.3.0

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.
@@ -52,6 +52,7 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
52
52
  addLabel = "add",
53
53
  dir,
54
54
  children,
55
+ onValueChange,
55
56
  ...props
56
57
  },
57
58
  forwardedRef
@@ -85,11 +86,17 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
85
86
  searchTags
86
87
  } = useTagSelection({
87
88
  Tags: tags,
88
- onTagsChange: (e) => props.onChange?.({
89
- target: {
90
- value: e
91
- }
92
- } as any),
89
+ onTagsChange: (e) => {
90
+ // Native onChange keeps the event-shaped API (Tag[] in target.value)
91
+ // for react-hook-form / Controller; onValueChange is the typed, direct
92
+ // callback consumers and the docs use.
93
+ props.onChange?.({
94
+ target: {
95
+ value: e
96
+ }
97
+ } as any);
98
+ onValueChange?.(e);
99
+ },
93
100
  inputRef
94
101
  });
95
102
 
@@ -4,6 +4,7 @@ import { Search, Settings } from "lucide-react";
4
4
  import { useEffect, useRef, useState, type ReactNode } from "react";
5
5
  import type { ViewType } from "./types";
6
6
  import { Button } from "../Button";
7
+ import { TabSwitch } from "../TabSwitch";
7
8
  import { cn } from "../../utils/cn";
8
9
 
9
10
  export type DataViewsHeaderView = {
@@ -66,38 +67,18 @@ export function DataViewsHeader({
66
67
 
67
68
  {/* Segmented view switcher */}
68
69
  <div className="flex flex-1 items-center gap-2">
69
- <div className="flex items-center gap-[2px] rounded-[10px] bg-[#252729] p-[2px] shadow-[inset_0_0_4px_0_rgba(0,0,0,0.08)]">
70
- {views.map((view, idx) => {
71
- const active = view.id === currentView;
72
- const prevActive = idx > 0 && views[idx - 1].id === currentView;
73
- // Separator sits between two inactive tabs only; the active white
74
- // pill never has a flanking divider (matches Figma).
75
- const showDivider = idx > 0 && !active && !prevActive;
76
- return (
77
- <div key={view.id} className="flex items-center">
78
- {showDivider && (
79
- <div className="mx-[3px] h-3 w-px bg-[#434446]" />
80
- )}
81
- <button
82
- type="button"
83
- aria-pressed={active}
84
- onClick={() => onViewChange(view.id)}
85
- className={cn(
86
- "flex h-6 items-center gap-[6px] rounded-[8px] px-3 text-[14px] font-[510] leading-none transition-all duration-200 ease-in-out",
87
- active
88
- ? "bg-white text-black shadow-[0_0_10px_2px_rgba(0,0,0,0.25)]"
89
- : "bg-transparent text-white hover:bg-white/5",
90
- )}
91
- >
92
- <span className="flex h-[14px] w-[14px] items-center justify-center [&_svg]:h-[14px] [&_svg]:w-[14px]">
93
- {view.icon}
94
- </span>
95
- {view.label}
96
- </button>
97
- </div>
98
- );
99
- })}
100
- </div>
70
+ <TabSwitch
71
+ // The header bar is always dark, so the switcher resolves dark-theme
72
+ // tokens regardless of the host app's theme.
73
+ theme="dark"
74
+ value={currentView}
75
+ onValueChange={onViewChange}
76
+ options={views.map((view) => ({
77
+ value: view.id,
78
+ label: view.label,
79
+ icon: view.icon,
80
+ }))}
81
+ />
101
82
  </div>
102
83
 
103
84
  {/* Action bar */}
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type ReactNode } from "react";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { cn } from "../utils/cn";
6
+ import { Themes } from "../utils/types";
7
+
8
+ /**
9
+ * TabSwitch — a segmented control for picking one option from a small set.
10
+ *
11
+ * The classic "List / Cards"-style pill switcher: a rounded track holding one
12
+ * button per option, with the active option rendered as a raised pill. Thin
13
+ * dividers sit between adjacent inactive options (never flanking the active
14
+ * pill). Generic over the option value, controlled via `value`/`onValueChange`,
15
+ * theme-aware, and built on semantic presentation tokens so it adapts to
16
+ * light/dark/default themes.
17
+ */
18
+
19
+ export interface TabSwitchOption<T extends string = string> {
20
+ value: T;
21
+ label?: ReactNode;
22
+ /** Optional leading icon (Remix `<i>`, lucide svg, etc.). */
23
+ icon?: ReactNode;
24
+ disabled?: boolean;
25
+ }
26
+
27
+ // The track (outer container) holds the segmented buttons.
28
+ const trackStyles = cva(
29
+ [
30
+ "inline-flex items-center w-fit",
31
+ "rounded-[10px]",
32
+ "bg-background-presentation-body-primary",
33
+ "shadow-[inset_0_0_4px_0_rgba(0,0,0,0.08)]",
34
+ ],
35
+ {
36
+ variants: {
37
+ size: {
38
+ S: ["gap-[2px] p-[2px]"],
39
+ M: ["gap-[2px] p-[2px]"],
40
+ L: ["gap-[3px] p-[3px]"],
41
+ },
42
+ },
43
+ defaultVariants: { size: "M" },
44
+ },
45
+ );
46
+
47
+ // Each option button. The active pill is raised; inactive options are
48
+ // transparent and lighten on hover.
49
+ const optionStyles = cva(
50
+ [
51
+ "flex items-center justify-center gap-[6px]",
52
+ "rounded-[8px]",
53
+ "font-[510] leading-none",
54
+ "transition-all duration-200 ease-in-out",
55
+ "outline-none",
56
+ "focus-visible:ring-2 focus-visible:ring-border-presentation-state-focus",
57
+ "disabled:cursor-not-allowed disabled:opacity-50",
58
+ ],
59
+ {
60
+ variants: {
61
+ size: {
62
+ S: [
63
+ "h-5 px-2 text-[12px]",
64
+ "[&_svg]:h-3 [&_svg]:w-3",
65
+ "[&_i]:text-[12px]",
66
+ ],
67
+ M: [
68
+ "h-6 px-3 text-[14px]",
69
+ "[&_svg]:h-[14px] [&_svg]:w-[14px]",
70
+ "[&_i]:text-[14px]",
71
+ ],
72
+ L: [
73
+ "h-8 px-4 text-[16px]",
74
+ "[&_svg]:h-4 [&_svg]:w-4",
75
+ "[&_i]:text-[16px]",
76
+ ],
77
+ },
78
+ active: {
79
+ // Active option = a solid raised WHITE pill with dark text, in every
80
+ // theme. The selected-tab design tokens encode a different look per
81
+ // theme (black pill on light, translucent-white on dark), but the
82
+ // intended switcher is always a white pill — and it must stay visible
83
+ // on the always-dark DataViews header bar — so the active pill is
84
+ // theme-independent here. The track + inactive options remain
85
+ // theme-aware.
86
+ true: [
87
+ "bg-white",
88
+ "text-[#1C1D1F]",
89
+ "border border-black/5",
90
+ "shadow-[0_1px_3px_0_rgba(0,0,0,0.18)]",
91
+ ],
92
+ false: [
93
+ "border border-transparent",
94
+ "bg-transparent",
95
+ "text-content-presentation-global-primary",
96
+ "hover:bg-background-presentation-tab-hover",
97
+ ],
98
+ },
99
+ },
100
+ defaultVariants: { size: "M", active: false },
101
+ },
102
+ );
103
+
104
+ interface Props<T extends string = string>
105
+ extends
106
+ Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">,
107
+ VariantProps<typeof trackStyles> {
108
+ options: TabSwitchOption<T>[];
109
+ /** Controlled selected value. */
110
+ value: T;
111
+ onValueChange: (value: T) => void;
112
+ theme?: Themes;
113
+ disabled?: boolean;
114
+ }
115
+
116
+ function TabSwitchInner<T extends string = string>(
117
+ {
118
+ options,
119
+ value,
120
+ onValueChange,
121
+ size,
122
+ theme,
123
+ disabled,
124
+ className,
125
+ ...props
126
+ }: Props<T>,
127
+ ref: React.ForwardedRef<HTMLDivElement>,
128
+ ) {
129
+ return (
130
+ <div
131
+ ref={ref}
132
+ role="tablist"
133
+ data-theme={theme}
134
+ className={cn(trackStyles({ size }), className)}
135
+ {...props}
136
+ >
137
+ {options.map((option, idx) => {
138
+ const active = option.value === value;
139
+ const prevActive = idx > 0 && options[idx - 1].value === value;
140
+ // A divider sits between two inactive options only — the active pill is
141
+ // never flanked by one.
142
+ const showDivider = idx > 0 && !active && !prevActive;
143
+ const isDisabled = disabled || option.disabled;
144
+
145
+ return (
146
+ <div key={option.value} className="flex items-center">
147
+ {showDivider && (
148
+ <div className="mx-[3px] h-3 w-px bg-border-presentation-action-disabled" />
149
+ )}
150
+ <button
151
+ type="button"
152
+ role="tab"
153
+ aria-selected={active}
154
+ aria-pressed={active}
155
+ disabled={isDisabled}
156
+ onClick={() => onValueChange(option.value)}
157
+ className={cn(optionStyles({ size, active }))}
158
+ >
159
+ {option.icon && (
160
+ <span className="flex items-center justify-center">
161
+ {option.icon}
162
+ </span>
163
+ )}
164
+ {option.label}
165
+ </button>
166
+ </div>
167
+ );
168
+ })}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ // forwardRef loses the generic, so we cast to preserve `<TabSwitch<T> />` typing.
174
+ export const TabSwitch = forwardRef(TabSwitchInner) as <
175
+ T extends string = string,
176
+ >(
177
+ props: Props<T> & { ref?: React.ForwardedRef<HTMLDivElement> },
178
+ ) => ReturnType<typeof TabSwitchInner>;
179
+
180
+ // @ts-expect-error — attach displayName to the cast function for devtools.
181
+ TabSwitch.displayName = "TabSwitch";
@@ -33,9 +33,9 @@ import { useState } from 'react'
33
33
 
34
34
  export function BasicBadgeField() {
35
35
  const [tags] = useState([
36
- { id: '1', name: 'React', variant: 'blue' },
37
- { id: '2', name: 'TypeScript', variant: 'purple' },
38
- { id: '3', name: 'Next.js', variant: 'slate' },
36
+ { id: '1', name: 'React', isSelected: false, variant: 'blue' },
37
+ { id: '2', name: 'TypeScript', isSelected: false, variant: 'purple' },
38
+ { id: '3', name: 'Next.js', isSelected: false, variant: 'slate' },
39
39
  ])
40
40
 
41
41
  return (
@@ -59,16 +59,16 @@ export function TechnologySelector() {
59
59
  const [selectedTags, setSelectedTags] = useState<Tag[]>([])
60
60
 
61
61
  const allTags: Tag[] = [
62
- { id: '1', name: 'React', variant: 'blue' },
63
- { id: '2', name: 'TypeScript', variant: 'purple' },
64
- { id: '3', name: 'Next.js', variant: 'slate' },
65
- { id: '4', name: 'Tailwind CSS', variant: 'green' },
66
- { id: '5', name: 'Node.js', variant: 'green' },
67
- { id: '6', name: 'PostgreSQL', variant: 'blue' },
68
- { id: '7', name: 'MongoDB', variant: 'green' },
69
- { id: '8', name: 'GraphQL', variant: 'purple' },
70
- { id: '9', name: 'REST API', variant: 'gray' },
71
- { id: '10', name: 'Docker', variant: 'blue' },
62
+ { id: '1', name: 'React', isSelected: false, variant: 'blue' },
63
+ { id: '2', name: 'TypeScript', isSelected: false, variant: 'purple' },
64
+ { id: '3', name: 'Next.js', isSelected: false, variant: 'slate' },
65
+ { id: '4', name: 'Tailwind CSS', isSelected: false, variant: 'green' },
66
+ { id: '5', name: 'Node.js', isSelected: false, variant: 'green' },
67
+ { id: '6', name: 'PostgreSQL', isSelected: false, variant: 'blue' },
68
+ { id: '7', name: 'MongoDB', isSelected: false, variant: 'green' },
69
+ { id: '8', name: 'GraphQL', isSelected: false, variant: 'purple' },
70
+ { id: '9', name: 'REST API', isSelected: false, variant: 'gray' },
71
+ { id: '10', name: 'Docker', isSelected: false, variant: 'blue' },
72
72
  ]
73
73
 
74
74
  return (
@@ -101,13 +101,13 @@ Filter content by multiple categories.
101
101
  ```tsx
102
102
  export function CategoryFilter() {
103
103
  const categories: Tag[] = [
104
- { id: 'design', name: 'Design', variant: 'purple' },
105
- { id: 'development', name: 'Development', variant: 'blue' },
106
- { id: 'marketing', name: 'Marketing', variant: 'yellow' },
107
- { id: 'sales', name: 'Sales', variant: 'green' },
108
- { id: 'support', name: 'Support', variant: 'orange' },
109
- { id: 'hr', name: 'Human Resources', variant: 'rose' },
110
- { id: 'finance', name: 'Finance', variant: 'slate' },
104
+ { id: 'design', name: 'Design', isSelected: false, variant: 'purple' },
105
+ { id: 'development', name: 'Development', isSelected: false, variant: 'blue' },
106
+ { id: 'marketing', name: 'Marketing', isSelected: false, variant: 'yellow' },
107
+ { id: 'sales', name: 'Sales', isSelected: false, variant: 'green' },
108
+ { id: 'support', name: 'Support', isSelected: false, variant: 'orange' },
109
+ { id: 'hr', name: 'Human Resources', isSelected: false, variant: 'rose' },
110
+ { id: 'finance', name: 'Finance', isSelected: false, variant: 'slate' },
111
111
  ]
112
112
 
113
113
  const [selectedCategories, setSelectedCategories] = useState<Tag[]>([])
@@ -148,21 +148,21 @@ Multi-select skills for job applications or profiles.
148
148
  ```tsx
149
149
  export function SkillsSelector() {
150
150
  const skills: Tag[] = [
151
- { id: 'js', name: 'JavaScript', variant: 'yellow' },
152
- { id: 'ts', name: 'TypeScript', variant: 'blue' },
153
- { id: 'react', name: 'React', variant: 'blue' },
154
- { id: 'vue', name: 'Vue.js', variant: 'green' },
155
- { id: 'angular', name: 'Angular', variant: 'red' },
156
- { id: 'node', name: 'Node.js', variant: 'green' },
157
- { id: 'python', name: 'Python', variant: 'blue' },
158
- { id: 'java', name: 'Java', variant: 'orange' },
159
- { id: 'go', name: 'Go', variant: 'green' },
160
- { id: 'rust', name: 'Rust', variant: 'orange' },
161
- { id: 'sql', name: 'SQL', variant: 'slate' },
162
- { id: 'nosql', name: 'NoSQL', variant: 'green' },
163
- { id: 'aws', name: 'AWS', variant: 'yellow' },
164
- { id: 'azure', name: 'Azure', variant: 'blue' },
165
- { id: 'gcp', name: 'Google Cloud', variant: 'blue' },
151
+ { id: 'js', name: 'JavaScript', isSelected: false, variant: 'yellow' },
152
+ { id: 'ts', name: 'TypeScript', isSelected: false, variant: 'blue' },
153
+ { id: 'react', name: 'React', isSelected: false, variant: 'blue' },
154
+ { id: 'vue', name: 'Vue.js', isSelected: false, variant: 'green' },
155
+ { id: 'angular', name: 'Angular', isSelected: false, variant: 'red' },
156
+ { id: 'node', name: 'Node.js', isSelected: false, variant: 'green' },
157
+ { id: 'python', name: 'Python', isSelected: false, variant: 'blue' },
158
+ { id: 'java', name: 'Java', isSelected: false, variant: 'orange' },
159
+ { id: 'go', name: 'Go', isSelected: false, variant: 'green' },
160
+ { id: 'rust', name: 'Rust', isSelected: false, variant: 'orange' },
161
+ { id: 'sql', name: 'SQL', isSelected: false, variant: 'slate' },
162
+ { id: 'nosql', name: 'NoSQL', isSelected: false, variant: 'green' },
163
+ { id: 'aws', name: 'AWS', isSelected: false, variant: 'yellow' },
164
+ { id: 'azure', name: 'Azure', isSelected: false, variant: 'blue' },
165
+ { id: 'gcp', name: 'Google Cloud', isSelected: false, variant: 'blue' },
166
166
  ]
167
167
 
168
168
  const [selectedSkills, setSelectedSkills] = useState<Tag[]>([])
@@ -213,16 +213,16 @@ Tag management for projects with custom variants.
213
213
  ```tsx
214
214
  export function ProjectTags() {
215
215
  const projectTags: Tag[] = [
216
- { id: '1', name: 'High Priority', variant: 'red' },
217
- { id: '2', name: 'In Progress', variant: 'blue' },
218
- { id: '3', name: 'Completed', variant: 'green' },
219
- { id: '4', name: 'On Hold', variant: 'yellow' },
220
- { id: '5', name: 'Needs Review', variant: 'purple' },
221
- { id: '6', name: 'Client Approval', variant: 'purple' },
222
- { id: '7', name: 'Internal', variant: 'gray' },
223
- { id: '8', name: 'External', variant: 'slate' },
224
- { id: '9', name: 'Urgent', variant: 'orange' },
225
- { id: '10', name: 'Can Wait', variant: 'green' },
216
+ { id: '1', name: 'High Priority', isSelected: false, variant: 'red' },
217
+ { id: '2', name: 'In Progress', isSelected: false, variant: 'blue' },
218
+ { id: '3', name: 'Completed', isSelected: false, variant: 'green' },
219
+ { id: '4', name: 'On Hold', isSelected: false, variant: 'yellow' },
220
+ { id: '5', name: 'Needs Review', isSelected: false, variant: 'purple' },
221
+ { id: '6', name: 'Client Approval', isSelected: false, variant: 'purple' },
222
+ { id: '7', name: 'Internal', isSelected: false, variant: 'gray' },
223
+ { id: '8', name: 'External', isSelected: false, variant: 'slate' },
224
+ { id: '9', name: 'Urgent', isSelected: false, variant: 'orange' },
225
+ { id: '10', name: 'Can Wait', isSelected: false, variant: 'green' },
226
226
  ]
227
227
 
228
228
  const [projectData, setProjectData] = useState({
@@ -275,9 +275,9 @@ BadgeField in various sizes.
275
275
  ```tsx
276
276
  export function BadgeFieldSizes() {
277
277
  const tags: Tag[] = [
278
- { id: '1', name: 'Small', variant: 'blue' },
279
- { id: '2', name: 'Medium', variant: 'green' },
280
- { id: '3', name: 'Large', variant: 'purple' },
278
+ { id: '1', name: 'Small', isSelected: false, variant: 'blue' },
279
+ { id: '2', name: 'Medium', isSelected: false, variant: 'green' },
280
+ { id: '3', name: 'Large', isSelected: false, variant: 'purple' },
281
281
  ]
282
282
 
283
283
  return (
@@ -308,9 +308,9 @@ Badge field with custom icons and action buttons.
308
308
  ```tsx
309
309
  export function BadgeFieldWithIcon() {
310
310
  const tags: Tag[] = [
311
- { id: '1', name: 'JavaScript', variant: 'yellow' },
312
- { id: '2', name: 'Python', variant: 'blue' },
313
- { id: '3', name: 'Ruby', variant: 'red' },
311
+ { id: '1', name: 'JavaScript', isSelected: false, variant: 'yellow' },
312
+ { id: '2', name: 'Python', isSelected: false, variant: 'blue' },
313
+ { id: '3', name: 'Ruby', isSelected: false, variant: 'red' },
314
314
  ]
315
315
 
316
316
  return (
@@ -339,9 +339,9 @@ export function BadgeFieldWithError() {
339
339
  const [error, setError] = useState('')
340
340
 
341
341
  const availableTags: Tag[] = [
342
- { id: '1', name: 'Option 1', variant: 'blue' },
343
- { id: '2', name: 'Option 2', variant: 'green' },
344
- { id: '3', name: 'Option 3', variant: 'purple' },
342
+ { id: '1', name: 'Option 1', isSelected: false, variant: 'blue' },
343
+ { id: '2', name: 'Option 2', isSelected: false, variant: 'green' },
344
+ { id: '3', name: 'Option 3', isSelected: false, variant: 'purple' },
345
345
  ]
346
346
 
347
347
  const handleChange = (newTags: Tag[]) => {
@@ -382,10 +382,10 @@ Tag-style email recipient selector.
382
382
  ```tsx
383
383
  export function EmailRecipients() {
384
384
  const contacts: Tag[] = [
385
- { id: '1', name: 'john@example.com', variant: 'blue' },
386
- { id: '2', name: 'jane@example.com', variant: 'green' },
387
- { id: '3', name: 'team@example.com', variant: 'purple' },
388
- { id: '4', name: 'support@example.com', variant: 'slate' },
385
+ { id: '1', name: 'john@example.com', isSelected: false, variant: 'blue' },
386
+ { id: '2', name: 'jane@example.com', isSelected: false, variant: 'green' },
387
+ { id: '3', name: 'team@example.com', isSelected: false, variant: 'purple' },
388
+ { id: '4', name: 'support@example.com', isSelected: false, variant: 'slate' },
389
389
  ]
390
390
 
391
391
  const [recipients, setRecipients] = useState<Tag[]>([])
@@ -422,37 +422,29 @@ export function EmailRecipients() {
422
422
  }
423
423
  ```
424
424
 
425
- ### Variants
425
+ ### RTL Support
426
426
 
427
- System and Presentation style variants.
427
+ Pass `dir="rtl"` (and a translated `addLabel`) to mirror the field for right-to-left languages.
428
428
 
429
429
  ```tsx
430
- export function BadgeFieldVariants() {
430
+ export function RtlBadgeField() {
431
431
  const tags: Tag[] = [
432
- { id: '1', name: 'Tag 1', variant: 'blue' },
433
- { id: '2', name: 'Tag 2', variant: 'green' },
434
- { id: '3', name: 'Tag 3', variant: 'purple' },
432
+ { id: '1', name: 'إلكترونيات', isSelected: false, variant: 'blue' },
433
+ { id: '2', name: 'كتب', isSelected: false, variant: 'green' },
434
+ { id: '3', name: 'ملابس', isSelected: false, variant: 'purple' },
435
435
  ]
436
436
 
437
- return (
438
- <div className="space-y-6">
439
- <div>
440
- <h3 className="font-semibold mb-2">System Style</h3>
441
- <BadgeField
442
- tags={tags}
443
- variant="SystemStyle"
444
- placeholder="System style variant..."
445
- />
446
- </div>
437
+ const [selected, setSelected] = useState<Tag[]>([])
447
438
 
448
- <div>
449
- <h3 className="font-semibold mb-2">Presentation Style</h3>
450
- <BadgeField
451
- tags={tags}
452
- variant="PresentationStyle"
453
- placeholder="Presentation style variant..."
454
- />
455
- </div>
439
+ return (
440
+ <div dir="rtl">
441
+ <BadgeField
442
+ dir="rtl"
443
+ tags={tags}
444
+ onValueChange={setSelected}
445
+ addLabel="إضافة"
446
+ placeholder="اختر وسماً"
447
+ />
456
448
  </div>
457
449
  )
458
450
  }
@@ -469,7 +461,7 @@ Extends all Input element props (except size and variant).
469
461
  | tags | `Tag[]` | **Required** | Available tags to select from |
470
462
  | onValueChange | `(tags: Tag[]) => void` | - | Callback when selection changes |
471
463
  | size | `'XS' \| 'S' \| 'M'` | `'M'` | Field size |
472
- | variant | `'SystemStyle' \| 'PresentationStyle'` | `'PresentationStyle'` | Visual variant |
464
+ | variant | `'PresentationStyle'` | `'PresentationStyle'` | Visual variant |
473
465
  | icon | `ReactNode` | - | Leading icon |
474
466
  | errorMessage | `string` | - | Error message (shows tooltip) |
475
467
  | onTable | `boolean` | `false` | Table-specific styling |
@@ -478,6 +470,8 @@ Extends all Input element props (except size and variant).
478
470
  | required | `boolean` | `false` | Required indicator |
479
471
  | theme | `Themes` | - | Theme override |
480
472
  | actionButton | `ReactNode` | - | Trailing action button |
473
+ | addLabel | `string` | `'add'` | Label for the add action shown in the field |
474
+ | dir | `string` | `'ltr'` | Reading direction (`'rtl'` for right-to-left) |
481
475
  | placeholder | `string` | - | Input placeholder text |
482
476
 
483
477
  ### Tag Type
@@ -486,7 +480,10 @@ Extends all Input element props (except size and variant).
486
480
  interface Tag {
487
481
  id: string
488
482
  name: string
489
- variant?: BadgeVariant
483
+ isSelected: boolean
484
+ variant?: string
485
+ value?: string
486
+ [key: string]: any
490
487
  }
491
488
  ```
492
489
 
@@ -500,8 +497,8 @@ BadgeField includes comprehensive keyboard support:
500
497
  | `ArrowUp` | Move focus to previous tag in dropdown |
501
498
  | `ArrowLeft` | Move focus to previous selected tag |
502
499
  | `ArrowRight` | Move focus to next selected tag |
503
- | `Enter` | Select focused tag / Remove focused selected tag |
504
- | `Backspace` | Remove last selected tag when input is empty |
500
+ | `Enter` | Select the focused tag from the dropdown |
501
+ | `Delete` / `Backspace` | Remove the focused selected tag |
505
502
  | `Escape` | Close dropdown |
506
503
  | `Tab` | Close dropdown and move to next field |
507
504
 
@@ -531,7 +528,7 @@ BadgeField includes comprehensive keyboard support:
531
528
  interface BadgeFieldProps
532
529
  extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'variant'> {
533
530
  size?: 'XS' | 'S' | 'M'
534
- variant?: 'SystemStyle' | 'PresentationStyle'
531
+ variant?: 'PresentationStyle'
535
532
  icon?: ReactNode
536
533
  errorMessage?: string
537
534
  onTable?: boolean
@@ -542,12 +539,16 @@ interface BadgeFieldProps
542
539
  actionButton?: ReactNode
543
540
  tags: Tag[]
544
541
  onValueChange?: (tags: Tag[]) => void
542
+ addLabel?: string
545
543
  }
546
544
 
547
545
  interface Tag {
548
546
  id: string
549
547
  name: string
550
- variant?: BadgeVariant
548
+ isSelected: boolean
549
+ variant?: string
550
+ value?: string
551
+ [key: string]: any
551
552
  }
552
553
  ```
553
554
 
@@ -596,8 +597,8 @@ import { BadgeField } from '@/components/BadgeField'
596
597
 
597
598
  describe('BadgeField', () => {
598
599
  const mockTags = [
599
- { id: '1', name: 'Tag 1', variant: 'blue' },
600
- { id: '2', name: 'Tag 2', variant: 'green' },
600
+ { id: '1', name: 'Tag 1', isSelected: false, variant: 'blue' },
601
+ { id: '2', name: 'Tag 2', isSelected: false, variant: 'green' },
601
602
  ]
602
603
 
603
604
  it('renders placeholder', () => {
@@ -638,7 +639,10 @@ describe('BadgeField', () => {
638
639
  fireEvent.focus(input)
639
640
  fireEvent.click(screen.getByText('Tag 1'))
640
641
 
641
- expect(handleChange).toHaveBeenCalledWith([mockTags[0]])
642
+ // Selecting a tag emits it with isSelected: true
643
+ expect(handleChange).toHaveBeenCalledWith([
644
+ expect.objectContaining({ id: '1', isSelected: true }),
645
+ ])
642
646
  })
643
647
 
644
648
  it('removes tag on unselect', () => {
@@ -272,6 +272,7 @@ The right-click zone. Wrap it around the element the menu should open from.
272
272
  |------|------|---------|-------------|
273
273
  | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Item style variant |
274
274
  | `size` | `'S' \| 'M'` | `'M'` | Item size |
275
+ | `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
275
276
  | `active` | `boolean` | `false` | Active (selected) state |
276
277
  | `disabled` | `boolean` | `false` | Disabled state (still shows but is not selectable) |
277
278
  | `onSelect` | `(event: Event) => void` | - | Select handler; closes the menu by default |
@@ -311,6 +312,7 @@ The right-click zone. Wrap it around the element the menu should open from.
311
312
  |------|------|---------|-------------|
312
313
  | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
313
314
  | `size` | `'S' \| 'M'` | `'M'` | Item size |
315
+ | `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
314
316
  | `className` | `string` | - | Additional CSS classes |
315
317
 
316
318
  Renders a trailing chevron (`ri-arrow-right-s-line`) that mirrors in RTL.
@@ -319,6 +321,7 @@ Renders a trailing chevron (`ri-arrow-right-s-line`) that mirrors in RTL.
319
321
 
320
322
  | Prop | Type | Default | Description |
321
323
  |------|------|---------|-------------|
324
+ | `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
322
325
  | `className` | `string` | - | Additional CSS classes |
323
326
 
324
327
  A non-interactive section heading. Acts as a boundary for auto-grouping.
@@ -268,7 +268,7 @@ function CustomScreen({ data, fields }: Props) {
268
268
 
269
269
  ## Accessibility
270
270
 
271
- - The view-switcher uses `TabFormItem` (button-based, full keyboard support via Tab/Enter/Space).
271
+ - The view-switcher uses [`TabSwitch`](./tab-switch.md) — a segmented `role="tablist"` control (each view a `role="tab"` button, full keyboard support via Tab/Enter/Space). Installing DataViews pulls in `TabSwitch` automatically.
272
272
  - Tree rows expose `role="treeitem"` with `aria-expanded` and `aria-selected`.
273
273
  - Filter checkboxes carry labels and `htmlFor` linkage.
274
274
  - Settings panel buttons have `aria-pressed` for sort direction.
@@ -284,4 +284,5 @@ The component uses only `*-presentation-*` design tokens. Wrap with `ThemeProvid
284
284
  - [`KanbanView`](./kanban-view.md) — standalone kanban
285
285
  - [`InboxView`](./inbox-view.md) — standalone inbox
286
286
  - [`TreeView`](./tree-view.md) — standalone tree
287
+ - [`TabSwitch`](./tab-switch.md) — the segmented view-switcher in the header (reusable on its own)
287
288
  - [How-to: Render a backend response with DataViews](../how-to/data-views-from-backend-response.md) — recipes by data shape.
@@ -32,6 +32,7 @@ import {
32
32
  DropdownMenuSubTrigger,
33
33
  DropdownMenuSubContent,
34
34
  DropdownMenuGroup,
35
+ DropdownMenuPortal,
35
36
  } from '@torch-ui/components'
36
37
  ```
37
38
 
@@ -275,27 +276,6 @@ function DisabledMenu() {
275
276
  }
276
277
  ```
277
278
 
278
- ### SystemStyle Variant
279
-
280
- ```typescript
281
- import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@torch-ui/components'
282
-
283
- function SystemMenu() {
284
- return (
285
- <DropdownMenu>
286
- <DropdownMenuTrigger asChild>
287
- <button>System</button>
288
- </DropdownMenuTrigger>
289
- <DropdownMenuContent variant="SystemStyle">
290
- <DropdownMenuItem variant="SystemStyle">Settings</DropdownMenuItem>
291
- <DropdownMenuItem variant="SystemStyle">About</DropdownMenuItem>
292
- <DropdownMenuItem variant="SystemStyle">Help</DropdownMenuItem>
293
- </DropdownMenuContent>
294
- </DropdownMenu>
295
- )
296
- }
297
- ```
298
-
299
279
  ### Right-click Menu
300
280
 
301
281
  For a true right-click (context) menu, use the dedicated [ContextMenu](./context-menu.md) component instead of DropdownMenu — it opens at the pointer on right-click / long-press.
@@ -340,12 +320,12 @@ function Example() {
340
320
 
341
321
  | Prop | Type | Default | Description |
342
322
  |------|------|---------|-------------|
343
- | `variant` | `'SystemStyle' \| 'PresentationStyle'` | `'PresentationStyle'` | Visual style variant |
323
+ | `variant` | `'PresentationStyle'` | `'PresentationStyle'` | Visual style variant |
344
324
  | `theme` | `'dark' \| 'light' \| 'default'` | - | Theme variant |
345
325
  | `className` | `string` | - | Additional CSS classes |
346
326
  | `sideOffset` | `number` | `4` | Distance from trigger |
347
327
  | `collisionPadding` | `number` | `8` | Gap kept from viewport edges when flipping/shifting |
348
- | `align` | `'start' \| 'center' \| 'end'` | `'start'` | Alignment |
328
+ | `align` | `'start' \| 'center' \| 'end'` | `'center'` | Alignment (inherited from Radix) |
349
329
  | `autoGroup` | `boolean` | `true` | Auto-wrap loose items in boxed groups |
350
330
 
351
331
  ### DropdownMenuItem
@@ -354,6 +334,7 @@ function Example() {
354
334
  |------|------|---------|-------------|
355
335
  | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Item style variant |
356
336
  | `size` | `'S' \| 'M'` | `'M'` | Item size |
337
+ | `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
357
338
  | `disabled` | `boolean` | `false` | Disabled state |
358
339
  | `active` | `boolean` | `false` | Active state |
359
340
  | `onSelect` | `(event) => void` | - | Select handler |
@@ -365,6 +346,7 @@ function Example() {
365
346
  | `checked` | `boolean \| 'indeterminate'` | `false` | Checked state |
366
347
  | `onCheckedChange` | `(checked: boolean) => void` | - | Change handler |
367
348
  | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
349
+ | `size` | `'S' \| 'M'` | `'M'` | Item size |
368
350
 
369
351
  ### DropdownMenuRadioGroup
370
352
 
@@ -379,11 +361,30 @@ function Example() {
379
361
  |------|------|---------|-------------|
380
362
  | `value` | `string` | Required | Radio option value |
381
363
  | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
364
+ | `size` | `'S' \| 'M'` | `'M'` | Item size |
365
+
366
+ ### DropdownMenuSubTrigger
367
+
368
+ | Prop | Type | Default | Description |
369
+ |------|------|---------|-------------|
370
+ | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
371
+ | `size` | `'S' \| 'M'` | `'M'` | Item size |
372
+ | `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
373
+ | `className` | `string` | - | Additional CSS classes |
374
+
375
+ ### DropdownMenuSubContent
376
+
377
+ | Prop | Type | Default | Description |
378
+ |------|------|---------|-------------|
379
+ | `variant` | `'PresentationStyle'` | `'PresentationStyle'` | Visual style variant |
380
+ | `autoGroup` | `boolean` | `true` | Auto-wrap loose items in boxed groups |
381
+ | `className` | `string` | - | Additional CSS classes |
382
382
 
383
383
  ### DropdownMenuLabel
384
384
 
385
385
  | Prop | Type | Default | Description |
386
386
  |------|------|---------|-------------|
387
+ | `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
387
388
  | `className` | `string` | - | Additional CSS classes |
388
389
 
389
390
  ### DropdownMenuGroup
@@ -419,11 +420,13 @@ export const DropdownMenu: React.FC<DropdownMenuProps>
419
420
 
420
421
  // Content
421
422
  interface DropdownMenuContentProps {
422
- variant?: 'SystemStyle' | 'PresentationStyle'
423
+ variant?: 'PresentationStyle'
423
424
  theme?: 'dark' | 'light' | 'default'
424
425
  className?: string
425
426
  sideOffset?: number
427
+ collisionPadding?: number
426
428
  align?: 'start' | 'center' | 'end'
429
+ autoGroup?: boolean
427
430
  }
428
431
 
429
432
  export const DropdownMenuContent: React.ForwardRefExoticComponent<DropdownMenuContentProps>
@@ -432,6 +435,7 @@ export const DropdownMenuContent: React.ForwardRefExoticComponent<DropdownMenuCo
432
435
  interface DropdownMenuItemProps {
433
436
  variant?: 'Default' | 'info' | 'Negative'
434
437
  size?: 'S' | 'M'
438
+ inset?: boolean
435
439
  disabled?: boolean
436
440
  active?: boolean
437
441
  onSelect?: (event: Event) => void
@@ -444,6 +448,7 @@ interface DropdownMenuCheckboxItemProps {
444
448
  checked?: boolean | 'indeterminate'
445
449
  onCheckedChange?: (checked: boolean) => void
446
450
  variant?: 'Default' | 'info' | 'Negative'
451
+ size?: 'S' | 'M'
447
452
  }
448
453
 
449
454
  export const DropdownMenuCheckboxItem: React.ForwardRefExoticComponent<DropdownMenuCheckboxItemProps>
@@ -452,6 +457,8 @@ export const DropdownMenuCheckboxItem: React.ForwardRefExoticComponent<DropdownM
452
457
  interface DropdownMenuRadioItemProps {
453
458
  value: string
454
459
  variant?: 'Default' | 'info' | 'Negative'
460
+ size?: 'S' | 'M'
461
+ onSelect?: (event: Event) => void
455
462
  }
456
463
 
457
464
  export const DropdownMenuRadioItem: React.ForwardRefExoticComponent<DropdownMenuRadioItemProps>
@@ -598,7 +605,7 @@ describe('DropdownMenu', () => {
598
605
  | Bundle size (minified) | ~8kb |
599
606
  | Bundle size (gzipped) | ~3kb |
600
607
  | Dependencies | @radix-ui/react-dropdown-menu (~15kb) |
601
- | Max height | 200px (scrollable) |
608
+ | Max height | Radix available height (scrollable) |
602
609
  | Tree-shakeable | ✅ |
603
610
 
604
611
  ## Best Practices
@@ -170,7 +170,7 @@ function ArabicPicker({ options, value, onValueChange }) {
170
170
  | `onValueChange` | `(value: string, option: SearchableSelectOption) => void` | - | Called when an option is selected. Receives the value and the full option object. |
171
171
  | `placeholder` | `string` | `'Search…'` | Placeholder text for the search input. |
172
172
  | `size` | `'XS' \| 'S' \| 'M'` | `'M'` | Field size. |
173
- | `variant` | `'SystemStyle' \| 'PresentationStyle'` | `'PresentationStyle'` | Visual style variant for the field and dropdown surface. |
173
+ | `variant` | `'PresentationStyle'` | `'PresentationStyle'` | Visual style variant for the field and dropdown surface. |
174
174
  | `icon` | `ReactNode` | - | Optional leading icon rendered inside the field. |
175
175
  | `theme` | `'dark' \| 'light' \| 'default'` | - | Theme variant, applied via `data-theme`. |
176
176
  | `dir` | `string` | - | Text direction (e.g. `'rtl'`) for the field and dropdown. |
@@ -210,7 +210,7 @@ interface SearchableSelectProps {
210
210
  onValueChange?: (value: string, option: SearchableSelectOption) => void
211
211
  placeholder?: string
212
212
  size?: 'XS' | 'S' | 'M'
213
- variant?: 'SystemStyle' | 'PresentationStyle'
213
+ variant?: 'PresentationStyle'
214
214
  icon?: ReactNode
215
215
  theme?: 'dark' | 'light' | 'default'
216
216
  dir?: string
@@ -194,7 +194,7 @@ function AsyncExample() {
194
194
  | `searchKeys` | `(keyof T & string)[]` | Every column `key` | Which fields client-side search matches against. |
195
195
  | `placeholder` | `string` | `'Search…'` | Input placeholder text. |
196
196
  | `size` | `'XS' \| 'S' \| 'M'` | `'M'` | Input size. (`XS` maps the underlying Group to `S` with a tighter input height.) |
197
- | `variant` | `'SystemStyle' \| 'PresentationStyle'` | `'PresentationStyle'` | Visual style of the input and dropdown surface. |
197
+ | `variant` | `'PresentationStyle'` | `'PresentationStyle'` | Visual style of the input and dropdown surface. |
198
198
  | `icon` | `ReactNode` | - | Optional leading icon rendered inside the input. |
199
199
  | `theme` | `'dark' \| 'light' \| 'default'` | - | Theme variant, applied via `data-theme`. |
200
200
  | `dir` | `string` | - | Text direction (e.g. `'rtl'`), applied to the input group and dropdown. |
@@ -244,7 +244,7 @@ interface SearchableTableProps<T> {
244
244
  searchKeys?: (keyof T & string)[]
245
245
  placeholder?: string
246
246
  size?: 'XS' | 'S' | 'M'
247
- variant?: 'SystemStyle' | 'PresentationStyle'
247
+ variant?: 'PresentationStyle'
248
248
  icon?: React.ReactNode
249
249
  theme?: Themes
250
250
  dir?: string
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: TabSwitch
3
+ version: 1.0.0
4
+ status: stable
5
+ category: components/navigation
6
+ tags: [tab-switch, segmented-control, view-switcher, toggle, list-cards, pills]
7
+ last-reviewed: 2026-06-15
8
+ bundle-size: 2.0kb
9
+ dependencies:
10
+ - "class-variance-authority": "^0.7.0"
11
+ ---
12
+
13
+ # TabSwitch
14
+
15
+ > A segmented control for picking one option from a small set — the classic List / Cards style pill switcher. The active option renders as a solid raised white pill; thin dividers sit between adjacent inactive options (never flanking the active pill). Controlled, generic over the option value, supports optional per-option icons, three sizes, and theme-aware track/labels. This is the switcher used in the DataViews header to flip between Table / Kanban / Inbox / Tree.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install torch-glare
21
+ ```
22
+
23
+ ## Import
24
+
25
+ ```typescript
26
+ import { TabSwitch } from 'torch-glare/lib/components/TabSwitch'
27
+ import type { TabSwitchOption } from 'torch-glare/lib/components/TabSwitch'
28
+ ```
29
+
30
+ ## Quick Examples
31
+
32
+ ### Basic Usage (List / Cards)
33
+
34
+ ```typescript
35
+ import { TabSwitch } from 'torch-glare/lib/components/TabSwitch'
36
+ import { useState } from 'react'
37
+
38
+ function Example() {
39
+ const [view, setView] = useState('list')
40
+
41
+ return (
42
+ <TabSwitch
43
+ value={view}
44
+ onValueChange={setView}
45
+ options={[
46
+ { value: 'list', label: 'List', icon: <i className="ri-layout-grid-line" /> },
47
+ { value: 'cards', label: 'Cards', icon: <i className="ri-grid-fill" /> },
48
+ ]}
49
+ />
50
+ )
51
+ }
52
+ ```
53
+
54
+ ### Sizes
55
+
56
+ ```typescript
57
+ <TabSwitch size="S" value={view} onValueChange={setView} options={options} />
58
+ <TabSwitch size="M" value={view} onValueChange={setView} options={options} /> {/* default */}
59
+ <TabSwitch size="L" value={view} onValueChange={setView} options={options} />
60
+ ```
61
+
62
+ ### Icons Only
63
+
64
+ Omit `label` to render an icon-only switcher.
65
+
66
+ ```typescript
67
+ <TabSwitch
68
+ value={view}
69
+ onValueChange={setView}
70
+ options={[
71
+ { value: 'list', icon: <i className="ri-layout-grid-line" /> },
72
+ { value: 'cards', icon: <i className="ri-grid-fill" /> },
73
+ { value: 'board', icon: <i className="ri-layout-column-line" /> },
74
+ ]}
75
+ />
76
+ ```
77
+
78
+ ### On a dark surface
79
+
80
+ The active pill is always a solid white pill with dark text, so it stays visible on dark bars. The track and inactive labels follow the theme — pass `theme="dark"` (or render inside a `data-theme="dark"` scope) so they resolve dark-theme tokens. This is how the DataViews header uses it.
81
+
82
+ ```typescript
83
+ <div data-theme="dark" className="bg-black p-2">
84
+ <TabSwitch theme="dark" value={view} onValueChange={setView} options={options} />
85
+ </div>
86
+ ```
87
+
88
+ ### Disabled
89
+
90
+ ```typescript
91
+ {/* whole control */}
92
+ <TabSwitch disabled value={view} onValueChange={setView} options={options} />
93
+
94
+ {/* a single option */}
95
+ <TabSwitch
96
+ value={view}
97
+ onValueChange={setView}
98
+ options={[
99
+ { value: 'list', label: 'List' },
100
+ { value: 'cards', label: 'Cards', disabled: true },
101
+ ]}
102
+ />
103
+ ```
104
+
105
+ ## API Reference
106
+
107
+ ### TabSwitch
108
+
109
+ | Prop | Type | Default | Description |
110
+ |------|------|---------|-------------|
111
+ | `options` | `TabSwitchOption[]` | — (required) | The selectable options rendered as segments. |
112
+ | `value` | `string` | — | The currently selected option value (controlled). |
113
+ | `onValueChange` | `(value: string) => void` | — | Called with the option value when a segment is selected. |
114
+ | `size` | `'S' \| 'M' \| 'L'` | `'M'` | Size of the control. |
115
+ | `disabled` | `boolean` | `false` | Disables the whole control. |
116
+ | `theme` | `'dark' \| 'light' \| 'default'` | — | Applies a fixed theme to the track and inactive labels (the active pill stays white). |
117
+ | `className` | `string` | — | Additional classes merged onto the track. |
118
+
119
+ `TabSwitch` is generic over the option value: `TabSwitch<T extends string>` infers `T` from `options`, so `value` and `onValueChange` are typed to your union (e.g. `'list' | 'cards'`).
120
+
121
+ ### TabSwitchOption
122
+
123
+ | Prop | Type | Default | Description |
124
+ |------|------|---------|-------------|
125
+ | `value` | `string` | — | Unique value for the option. |
126
+ | `label` | `ReactNode` | `undefined` | Text or node shown for the option. Omit for an icon-only segment. |
127
+ | `icon` | `ReactNode` | `undefined` | Optional leading icon rendered before the label. |
128
+ | `disabled` | `boolean` | `false` | Disables this individual option. |
129
+
130
+ ## Accessibility
131
+
132
+ - The track is a `role="tablist"`; each option is a `role="tab"` with `aria-selected` reflecting the active state.
133
+ - Options are real `<button>` elements, so they are keyboard-focusable and activate on Enter/Space.
134
+
135
+ ## Notes
136
+
137
+ - The active pill is intentionally a solid white pill with dark text in every theme (not derived from the per-theme selected-tab tokens), so it reads correctly on the always-dark DataViews header as well as on light surfaces.
138
+ - TabSwitch is a controlled component — always pass both `value` and `onValueChange`.
139
+
140
+ ## TypeScript
141
+
142
+ ```typescript
143
+ interface TabSwitchOption<T extends string = string> {
144
+ value: T
145
+ label?: React.ReactNode
146
+ icon?: React.ReactNode
147
+ disabled?: boolean
148
+ }
149
+
150
+ interface TabSwitchProps<T extends string = string>
151
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
152
+ options: TabSwitchOption<T>[]
153
+ value: T
154
+ onValueChange: (value: T) => void
155
+ size?: 'S' | 'M' | 'L'
156
+ theme?: 'dark' | 'light' | 'default'
157
+ disabled?: boolean
158
+ }
159
+
160
+ declare function TabSwitch<T extends string = string>(
161
+ props: TabSwitchProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
162
+ ): React.ReactElement
163
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torch-glare",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "files": [