torch-glare 2.2.1 → 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.
|
@@ -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
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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";
|
|
@@ -268,7 +268,7 @@ function CustomScreen({ data, fields }: Props) {
|
|
|
268
268
|
|
|
269
269
|
## Accessibility
|
|
270
270
|
|
|
271
|
-
- The view-switcher uses `
|
|
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.
|
|
@@ -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
|
+
```
|