@fragments-sdk/ui 0.1.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.
- package/package.json +44 -0
- package/src/brand.ts +15 -0
- package/src/components/Alert/Alert.fragment.tsx +163 -0
- package/src/components/Alert/Alert.module.scss +116 -0
- package/src/components/Alert/index.tsx +95 -0
- package/src/components/Avatar/Avatar.fragment.tsx +147 -0
- package/src/components/Avatar/Avatar.module.scss +136 -0
- package/src/components/Avatar/index.tsx +177 -0
- package/src/components/Badge/Badge.fragment.tsx +151 -0
- package/src/components/Badge/Badge.module.scss +87 -0
- package/src/components/Badge/index.tsx +55 -0
- package/src/components/Button/Button.fragment.tsx +159 -0
- package/src/components/Button/Button.module.scss +97 -0
- package/src/components/Button/index.tsx +51 -0
- package/src/components/Card/Card.fragment.tsx +156 -0
- package/src/components/Card/Card.module.scss +86 -0
- package/src/components/Card/index.tsx +79 -0
- package/src/components/Checkbox/Checkbox.fragment.tsx +166 -0
- package/src/components/Checkbox/Checkbox.module.scss +144 -0
- package/src/components/Checkbox/index.tsx +166 -0
- package/src/components/Dialog/Dialog.fragment.tsx +179 -0
- package/src/components/Dialog/Dialog.module.scss +158 -0
- package/src/components/Dialog/index.tsx +230 -0
- package/src/components/EmptyState/EmptyState.fragment.tsx +222 -0
- package/src/components/EmptyState/EmptyState.module.scss +120 -0
- package/src/components/EmptyState/index.tsx +80 -0
- package/src/components/Input/Input.fragment.tsx +174 -0
- package/src/components/Input/Input.module.scss +64 -0
- package/src/components/Input/index.tsx +76 -0
- package/src/components/Menu/Menu.fragment.tsx +168 -0
- package/src/components/Menu/Menu.module.scss +190 -0
- package/src/components/Menu/index.tsx +318 -0
- package/src/components/Popover/Popover.fragment.tsx +178 -0
- package/src/components/Popover/Popover.module.scss +165 -0
- package/src/components/Popover/index.tsx +229 -0
- package/src/components/Progress/Progress.fragment.tsx +142 -0
- package/src/components/Progress/Progress.module.scss +185 -0
- package/src/components/Progress/index.tsx +196 -0
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +188 -0
- package/src/components/RadioGroup/RadioGroup.module.scss +155 -0
- package/src/components/RadioGroup/index.tsx +166 -0
- package/src/components/Select/Select.fragment.tsx +173 -0
- package/src/components/Select/Select.module.scss +187 -0
- package/src/components/Select/index.tsx +233 -0
- package/src/components/Separator/Separator.fragment.tsx +148 -0
- package/src/components/Separator/Separator.module.scss +92 -0
- package/src/components/Separator/index.tsx +89 -0
- package/src/components/Skeleton/Skeleton.fragment.tsx +147 -0
- package/src/components/Skeleton/Skeleton.module.scss +166 -0
- package/src/components/Skeleton/index.tsx +185 -0
- package/src/components/Table/Table.fragment.tsx +193 -0
- package/src/components/Table/Table.module.scss +152 -0
- package/src/components/Table/index.tsx +266 -0
- package/src/components/Tabs/Tabs.fragment.tsx +155 -0
- package/src/components/Tabs/Tabs.module.scss +142 -0
- package/src/components/Tabs/index.tsx +142 -0
- package/src/components/Textarea/Textarea.fragment.tsx +171 -0
- package/src/components/Textarea/Textarea.module.scss +89 -0
- package/src/components/Textarea/index.tsx +128 -0
- package/src/components/Toast/Toast.fragment.tsx +210 -0
- package/src/components/Toast/Toast.module.scss +227 -0
- package/src/components/Toast/index.tsx +315 -0
- package/src/components/Toggle/Toggle.fragment.tsx +174 -0
- package/src/components/Toggle/Toggle.module.scss +103 -0
- package/src/components/Toggle/index.tsx +80 -0
- package/src/components/Tooltip/Tooltip.fragment.tsx +158 -0
- package/src/components/Tooltip/Tooltip.module.scss +82 -0
- package/src/components/Tooltip/index.tsx +135 -0
- package/src/index.ts +151 -0
- package/src/scss.d.ts +4 -0
- package/src/styles/globals.scss +17 -0
- package/src/tokens/_mixins.scss +93 -0
- package/src/tokens/_variables.scss +276 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
useReactTable,
|
|
4
|
+
getCoreRowModel,
|
|
5
|
+
getSortedRowModel,
|
|
6
|
+
flexRender,
|
|
7
|
+
type ColumnDef,
|
|
8
|
+
type SortingState,
|
|
9
|
+
type RowSelectionState,
|
|
10
|
+
type OnChangeFn,
|
|
11
|
+
} from '@tanstack/react-table';
|
|
12
|
+
// Import globals to ensure CSS variables are defined
|
|
13
|
+
import '../../styles/globals.scss';
|
|
14
|
+
import styles from './Table.module.scss';
|
|
15
|
+
|
|
16
|
+
// Column definition helper type
|
|
17
|
+
export type TableColumn<T> = ColumnDef<T, unknown>;
|
|
18
|
+
|
|
19
|
+
export interface TableProps<T> {
|
|
20
|
+
/** Column definitions */
|
|
21
|
+
columns: TableColumn<T>[];
|
|
22
|
+
/** Data array */
|
|
23
|
+
data: T[];
|
|
24
|
+
/** Unique key extractor for each row */
|
|
25
|
+
getRowId?: (row: T) => string;
|
|
26
|
+
/** Enable sorting */
|
|
27
|
+
sortable?: boolean;
|
|
28
|
+
/** Controlled sorting state */
|
|
29
|
+
sorting?: SortingState;
|
|
30
|
+
/** Sorting change handler */
|
|
31
|
+
onSortingChange?: OnChangeFn<SortingState>;
|
|
32
|
+
/** Enable row selection */
|
|
33
|
+
selectable?: boolean;
|
|
34
|
+
/** Controlled selection state */
|
|
35
|
+
rowSelection?: RowSelectionState;
|
|
36
|
+
/** Selection change handler */
|
|
37
|
+
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
|
38
|
+
/** Row click handler */
|
|
39
|
+
onRowClick?: (row: T) => void;
|
|
40
|
+
/** Empty state message */
|
|
41
|
+
emptyMessage?: string;
|
|
42
|
+
/** Size variant */
|
|
43
|
+
size?: 'sm' | 'md';
|
|
44
|
+
/** Additional class name */
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Table<T>({
|
|
49
|
+
columns,
|
|
50
|
+
data,
|
|
51
|
+
getRowId,
|
|
52
|
+
sortable = false,
|
|
53
|
+
sorting: controlledSorting,
|
|
54
|
+
onSortingChange,
|
|
55
|
+
selectable = false,
|
|
56
|
+
rowSelection: controlledRowSelection,
|
|
57
|
+
onRowSelectionChange,
|
|
58
|
+
onRowClick,
|
|
59
|
+
emptyMessage = 'No data available',
|
|
60
|
+
size = 'md',
|
|
61
|
+
className,
|
|
62
|
+
}: TableProps<T>) {
|
|
63
|
+
// Internal sorting state when uncontrolled
|
|
64
|
+
const [internalSorting, setInternalSorting] = React.useState<SortingState>([]);
|
|
65
|
+
const sorting = controlledSorting ?? internalSorting;
|
|
66
|
+
const handleSortingChange = onSortingChange ?? setInternalSorting;
|
|
67
|
+
|
|
68
|
+
// Internal selection state when uncontrolled
|
|
69
|
+
const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({});
|
|
70
|
+
const rowSelection = controlledRowSelection ?? internalRowSelection;
|
|
71
|
+
const handleRowSelectionChange = onRowSelectionChange ?? setInternalRowSelection;
|
|
72
|
+
|
|
73
|
+
const table = useReactTable({
|
|
74
|
+
data,
|
|
75
|
+
columns,
|
|
76
|
+
getRowId,
|
|
77
|
+
getCoreRowModel: getCoreRowModel(),
|
|
78
|
+
getSortedRowModel: sortable ? getSortedRowModel() : undefined,
|
|
79
|
+
state: {
|
|
80
|
+
sorting: sortable ? sorting : undefined,
|
|
81
|
+
rowSelection: selectable ? rowSelection : undefined,
|
|
82
|
+
},
|
|
83
|
+
onSortingChange: sortable ? handleSortingChange : undefined,
|
|
84
|
+
onRowSelectionChange: selectable ? handleRowSelectionChange : undefined,
|
|
85
|
+
enableRowSelection: selectable,
|
|
86
|
+
enableSorting: sortable,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const isEmpty = data.length === 0;
|
|
90
|
+
|
|
91
|
+
const rootClasses = [styles.table, styles[size], className]
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join(' ');
|
|
94
|
+
|
|
95
|
+
if (isEmpty) {
|
|
96
|
+
return (
|
|
97
|
+
<div className={styles.emptyState}>
|
|
98
|
+
<span className={styles.emptyMessage}>{emptyMessage}</span>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className={styles.wrapper}>
|
|
105
|
+
<table className={rootClasses}>
|
|
106
|
+
<thead className={styles.thead}>
|
|
107
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
108
|
+
<tr key={headerGroup.id} className={styles.headerRow}>
|
|
109
|
+
{headerGroup.headers.map((header) => {
|
|
110
|
+
const canSort = sortable && header.column.getCanSort();
|
|
111
|
+
const sortDirection = header.column.getIsSorted();
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<th
|
|
115
|
+
key={header.id}
|
|
116
|
+
className={styles.th}
|
|
117
|
+
style={{
|
|
118
|
+
width: header.getSize() !== 150 ? header.getSize() : undefined,
|
|
119
|
+
}}
|
|
120
|
+
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
|
121
|
+
aria-sort={
|
|
122
|
+
sortDirection
|
|
123
|
+
? sortDirection === 'asc'
|
|
124
|
+
? 'ascending'
|
|
125
|
+
: 'descending'
|
|
126
|
+
: undefined
|
|
127
|
+
}
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
className={[
|
|
131
|
+
styles.headerContent,
|
|
132
|
+
canSort && styles.sortable,
|
|
133
|
+
]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(' ')}
|
|
136
|
+
>
|
|
137
|
+
{header.isPlaceholder
|
|
138
|
+
? null
|
|
139
|
+
: flexRender(
|
|
140
|
+
header.column.columnDef.header,
|
|
141
|
+
header.getContext()
|
|
142
|
+
)}
|
|
143
|
+
{canSort && (
|
|
144
|
+
<span className={styles.sortIndicator} aria-hidden="true">
|
|
145
|
+
{sortDirection === 'asc' ? (
|
|
146
|
+
<SortAscIcon />
|
|
147
|
+
) : sortDirection === 'desc' ? (
|
|
148
|
+
<SortDescIcon />
|
|
149
|
+
) : (
|
|
150
|
+
<SortIcon />
|
|
151
|
+
)}
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
</th>
|
|
156
|
+
);
|
|
157
|
+
})}
|
|
158
|
+
</tr>
|
|
159
|
+
))}
|
|
160
|
+
</thead>
|
|
161
|
+
<tbody className={styles.tbody}>
|
|
162
|
+
{table.getRowModel().rows.map((row) => {
|
|
163
|
+
const isClickable = !!onRowClick;
|
|
164
|
+
const isSelected = selectable ? row.getIsSelected() : false;
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<tr
|
|
168
|
+
key={row.id}
|
|
169
|
+
className={[
|
|
170
|
+
styles.row,
|
|
171
|
+
isClickable && styles.clickable,
|
|
172
|
+
isSelected && styles.selected,
|
|
173
|
+
]
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join(' ')}
|
|
176
|
+
onClick={isClickable ? () => onRowClick(row.original) : undefined}
|
|
177
|
+
data-selected={isSelected || undefined}
|
|
178
|
+
>
|
|
179
|
+
{row.getVisibleCells().map((cell) => (
|
|
180
|
+
<td key={cell.id} className={styles.td}>
|
|
181
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
182
|
+
</td>
|
|
183
|
+
))}
|
|
184
|
+
</tr>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
</tbody>
|
|
188
|
+
</table>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Sort icons - minimal and functional
|
|
194
|
+
function SortIcon() {
|
|
195
|
+
return (
|
|
196
|
+
<svg
|
|
197
|
+
width="12"
|
|
198
|
+
height="12"
|
|
199
|
+
viewBox="0 0 12 12"
|
|
200
|
+
fill="none"
|
|
201
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
202
|
+
>
|
|
203
|
+
<path
|
|
204
|
+
d="M6 2L8.5 5H3.5L6 2Z"
|
|
205
|
+
fill="currentColor"
|
|
206
|
+
opacity="0.3"
|
|
207
|
+
/>
|
|
208
|
+
<path
|
|
209
|
+
d="M6 10L3.5 7H8.5L6 10Z"
|
|
210
|
+
fill="currentColor"
|
|
211
|
+
opacity="0.3"
|
|
212
|
+
/>
|
|
213
|
+
</svg>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function SortAscIcon() {
|
|
218
|
+
return (
|
|
219
|
+
<svg
|
|
220
|
+
width="12"
|
|
221
|
+
height="12"
|
|
222
|
+
viewBox="0 0 12 12"
|
|
223
|
+
fill="none"
|
|
224
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
225
|
+
>
|
|
226
|
+
<path d="M6 2L8.5 5H3.5L6 2Z" fill="currentColor" />
|
|
227
|
+
</svg>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function SortDescIcon() {
|
|
232
|
+
return (
|
|
233
|
+
<svg
|
|
234
|
+
width="12"
|
|
235
|
+
height="12"
|
|
236
|
+
viewBox="0 0 12 12"
|
|
237
|
+
fill="none"
|
|
238
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
239
|
+
>
|
|
240
|
+
<path d="M6 10L3.5 7H8.5L6 10Z" fill="currentColor" />
|
|
241
|
+
</svg>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Helper to create simple columns without TanStack's createColumnHelper
|
|
246
|
+
export function createColumns<T>(
|
|
247
|
+
columns: Array<{
|
|
248
|
+
key: string;
|
|
249
|
+
header: string;
|
|
250
|
+
width?: number;
|
|
251
|
+
cell?: (row: T) => React.ReactNode;
|
|
252
|
+
}>
|
|
253
|
+
): TableColumn<T>[] {
|
|
254
|
+
return columns.map((col) => ({
|
|
255
|
+
id: col.key,
|
|
256
|
+
accessorKey: col.key,
|
|
257
|
+
header: col.header,
|
|
258
|
+
size: col.width,
|
|
259
|
+
cell: col.cell
|
|
260
|
+
? ({ row }) => col.cell!(row.original)
|
|
261
|
+
: ({ getValue }) => getValue() ?? '--',
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Re-export useful types
|
|
266
|
+
export type { ColumnDef, SortingState, RowSelectionState };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineSegment } from '@fragments/core';
|
|
3
|
+
import { Tabs } from './index.js';
|
|
4
|
+
|
|
5
|
+
export default defineSegment({
|
|
6
|
+
component: Tabs,
|
|
7
|
+
|
|
8
|
+
meta: {
|
|
9
|
+
name: 'Tabs',
|
|
10
|
+
description: 'Organize content into switchable panels. Use for related content that benefits from a compact, navigable layout.',
|
|
11
|
+
category: 'navigation',
|
|
12
|
+
status: 'stable',
|
|
13
|
+
tags: ['tabs', 'navigation', 'panels', 'content-switcher'],
|
|
14
|
+
since: '0.1.0',
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
usage: {
|
|
18
|
+
when: [
|
|
19
|
+
'Organizing related content into sections',
|
|
20
|
+
'Reducing page scrolling by grouping content',
|
|
21
|
+
'Settings pages with multiple categories',
|
|
22
|
+
'Dashboard views with different data perspectives',
|
|
23
|
+
],
|
|
24
|
+
whenNot: [
|
|
25
|
+
'Primary navigation (use sidebar or header nav)',
|
|
26
|
+
'Sequential steps (use Stepper or wizard)',
|
|
27
|
+
'Comparing content side-by-side',
|
|
28
|
+
'Very long lists of options (use Select or Menu)',
|
|
29
|
+
],
|
|
30
|
+
guidelines: [
|
|
31
|
+
'Keep tab labels short (1-2 words)',
|
|
32
|
+
'Order tabs by usage frequency or logical sequence',
|
|
33
|
+
'Avoid more than 5-6 tabs; consider sub-navigation for more',
|
|
34
|
+
'Tab content should be roughly equivalent in scope',
|
|
35
|
+
'Use pills variant for contained sections, underline for page-level tabs',
|
|
36
|
+
],
|
|
37
|
+
accessibility: [
|
|
38
|
+
'Keyboard navigation with arrow keys',
|
|
39
|
+
'Tab panels are properly labeled',
|
|
40
|
+
'Focus management follows WAI-ARIA tabs pattern',
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
props: {
|
|
45
|
+
children: {
|
|
46
|
+
type: 'node',
|
|
47
|
+
description: 'Tab list and panels (use Tabs.List, Tabs.Tab, Tabs.Panel)',
|
|
48
|
+
required: true,
|
|
49
|
+
},
|
|
50
|
+
defaultValue: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Initially active tab (uncontrolled)',
|
|
53
|
+
},
|
|
54
|
+
value: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'Controlled active tab value',
|
|
57
|
+
},
|
|
58
|
+
onValueChange: {
|
|
59
|
+
type: 'function',
|
|
60
|
+
description: 'Called when active tab changes',
|
|
61
|
+
},
|
|
62
|
+
orientation: {
|
|
63
|
+
type: 'enum',
|
|
64
|
+
description: 'Tab list orientation',
|
|
65
|
+
values: ['horizontal', 'vertical'],
|
|
66
|
+
default: 'horizontal',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
relations: [
|
|
71
|
+
{ component: 'Select', relationship: 'alternative', note: 'Use Select for many options in compact space' },
|
|
72
|
+
{ component: 'Menu', relationship: 'alternative', note: 'Use Menu for action-based navigation' },
|
|
73
|
+
],
|
|
74
|
+
|
|
75
|
+
contract: {
|
|
76
|
+
propsSummary: [
|
|
77
|
+
'value: string - controlled active tab',
|
|
78
|
+
'defaultValue: string - initial active tab',
|
|
79
|
+
'onValueChange: (value) => void - tab change handler',
|
|
80
|
+
'Tabs.List variant: underline|pills - visual style',
|
|
81
|
+
],
|
|
82
|
+
scenarioTags: [
|
|
83
|
+
'navigation.tabs',
|
|
84
|
+
'layout.panels',
|
|
85
|
+
'content.sections',
|
|
86
|
+
],
|
|
87
|
+
a11yRules: ['A11Y_TABS_KEYBOARD', 'A11Y_TABS_LABELS'],
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
variants: [
|
|
91
|
+
{
|
|
92
|
+
name: 'Underline',
|
|
93
|
+
description: 'Default underline style tabs',
|
|
94
|
+
render: () => (
|
|
95
|
+
<Tabs defaultValue="overview">
|
|
96
|
+
<Tabs.List variant="underline">
|
|
97
|
+
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
|
98
|
+
<Tabs.Tab value="analytics">Analytics</Tabs.Tab>
|
|
99
|
+
<Tabs.Tab value="settings">Settings</Tabs.Tab>
|
|
100
|
+
</Tabs.List>
|
|
101
|
+
<Tabs.Panel value="overview">
|
|
102
|
+
<p>Overview content goes here.</p>
|
|
103
|
+
</Tabs.Panel>
|
|
104
|
+
<Tabs.Panel value="analytics">
|
|
105
|
+
<p>Analytics content goes here.</p>
|
|
106
|
+
</Tabs.Panel>
|
|
107
|
+
<Tabs.Panel value="settings">
|
|
108
|
+
<p>Settings content goes here.</p>
|
|
109
|
+
</Tabs.Panel>
|
|
110
|
+
</Tabs>
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'Pills',
|
|
115
|
+
description: 'Pill-style tabs for contained sections',
|
|
116
|
+
render: () => (
|
|
117
|
+
<Tabs defaultValue="all">
|
|
118
|
+
<Tabs.List variant="pills">
|
|
119
|
+
<Tabs.Tab value="all">All</Tabs.Tab>
|
|
120
|
+
<Tabs.Tab value="active">Active</Tabs.Tab>
|
|
121
|
+
<Tabs.Tab value="archived">Archived</Tabs.Tab>
|
|
122
|
+
</Tabs.List>
|
|
123
|
+
<Tabs.Panel value="all">
|
|
124
|
+
<p>Showing all items.</p>
|
|
125
|
+
</Tabs.Panel>
|
|
126
|
+
<Tabs.Panel value="active">
|
|
127
|
+
<p>Showing active items only.</p>
|
|
128
|
+
</Tabs.Panel>
|
|
129
|
+
<Tabs.Panel value="archived">
|
|
130
|
+
<p>Showing archived items.</p>
|
|
131
|
+
</Tabs.Panel>
|
|
132
|
+
</Tabs>
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'With Disabled',
|
|
137
|
+
description: 'Tabs with a disabled option',
|
|
138
|
+
render: () => (
|
|
139
|
+
<Tabs defaultValue="general">
|
|
140
|
+
<Tabs.List variant="underline">
|
|
141
|
+
<Tabs.Tab value="general">General</Tabs.Tab>
|
|
142
|
+
<Tabs.Tab value="security">Security</Tabs.Tab>
|
|
143
|
+
<Tabs.Tab value="billing" disabled>Billing</Tabs.Tab>
|
|
144
|
+
</Tabs.List>
|
|
145
|
+
<Tabs.Panel value="general">
|
|
146
|
+
<p>General settings panel.</p>
|
|
147
|
+
</Tabs.Panel>
|
|
148
|
+
<Tabs.Panel value="security">
|
|
149
|
+
<p>Security settings panel.</p>
|
|
150
|
+
</Tabs.Panel>
|
|
151
|
+
</Tabs>
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
@use '../../tokens/variables' as *;
|
|
2
|
+
@use '../../tokens/mixins' as *;
|
|
3
|
+
|
|
4
|
+
// Root container
|
|
5
|
+
.root {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
font-family: var(--fui-font-sans, $fui-font-sans);
|
|
9
|
+
|
|
10
|
+
&[data-orientation='vertical'] {
|
|
11
|
+
flex-direction: row;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Tab list container
|
|
16
|
+
.list {
|
|
17
|
+
position: relative;
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: var(--fui-space-1, $fui-space-1);
|
|
21
|
+
border-bottom: 1px solid var(--fui-border, $fui-border);
|
|
22
|
+
padding: 0 var(--fui-space-1, $fui-space-1);
|
|
23
|
+
|
|
24
|
+
&[data-orientation='vertical'] {
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
align-items: stretch;
|
|
27
|
+
border-bottom: none;
|
|
28
|
+
border-right: 1px solid var(--fui-border, $fui-border);
|
|
29
|
+
padding: var(--fui-space-1, $fui-space-1) 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Variant: pills (no border, background tabs)
|
|
34
|
+
.listPills {
|
|
35
|
+
border-bottom: none;
|
|
36
|
+
background-color: var(--fui-bg-tertiary, $fui-bg-tertiary);
|
|
37
|
+
border-radius: var(--fui-radius-md, $fui-radius-md);
|
|
38
|
+
padding: var(--fui-space-1, $fui-space-1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Variant: underline (default, with indicator line)
|
|
42
|
+
.listUnderline {
|
|
43
|
+
// Default styles already applied to .list
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Individual tab button
|
|
47
|
+
.tab {
|
|
48
|
+
@include button-reset;
|
|
49
|
+
@include interactive-base;
|
|
50
|
+
|
|
51
|
+
position: relative;
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
gap: var(--fui-space-2, $fui-space-2);
|
|
56
|
+
padding: var(--fui-space-2, $fui-space-2) var(--fui-space-3, $fui-space-3);
|
|
57
|
+
font-size: var(--fui-font-size-sm, $fui-font-size-sm);
|
|
58
|
+
font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
|
|
59
|
+
color: var(--fui-text-secondary, $fui-text-secondary);
|
|
60
|
+
white-space: nowrap;
|
|
61
|
+
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
62
|
+
|
|
63
|
+
&:hover:not([data-disabled]) {
|
|
64
|
+
color: var(--fui-text-primary, $fui-text-primary);
|
|
65
|
+
background-color: var(--fui-bg-hover, $fui-bg-hover);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&[data-active] {
|
|
69
|
+
color: var(--fui-text-primary, $fui-text-primary);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
&[data-disabled] {
|
|
73
|
+
color: var(--fui-text-tertiary, $fui-text-tertiary);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Tab in underline variant
|
|
78
|
+
.tabUnderline {
|
|
79
|
+
border-radius: 0;
|
|
80
|
+
margin-bottom: -1px;
|
|
81
|
+
padding-bottom: calc(var(--fui-space-2, $fui-space-2) + 1px);
|
|
82
|
+
|
|
83
|
+
&:hover:not([data-disabled]) {
|
|
84
|
+
background-color: transparent;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Tab in pills variant
|
|
89
|
+
.tabPills {
|
|
90
|
+
&[data-active] {
|
|
91
|
+
background-color: var(--fui-bg-elevated, $fui-bg-elevated);
|
|
92
|
+
box-shadow: var(--fui-shadow-sm, $fui-shadow-sm);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Animated indicator (underline variant)
|
|
97
|
+
.indicator {
|
|
98
|
+
position: absolute;
|
|
99
|
+
bottom: 0;
|
|
100
|
+
left: 0;
|
|
101
|
+
height: 2px;
|
|
102
|
+
background-color: var(--fui-color-accent, $fui-color-accent);
|
|
103
|
+
border-radius: var(--fui-radius-full, $fui-radius-full);
|
|
104
|
+
|
|
105
|
+
// Use CSS variables from Base UI for position
|
|
106
|
+
width: var(--active-tab-width);
|
|
107
|
+
transform: translateX(var(--active-tab-left));
|
|
108
|
+
transition:
|
|
109
|
+
width var(--fui-transition-fast, $fui-transition-fast),
|
|
110
|
+
transform var(--fui-transition-fast, $fui-transition-fast);
|
|
111
|
+
|
|
112
|
+
[data-orientation='vertical'] & {
|
|
113
|
+
bottom: auto;
|
|
114
|
+
left: auto;
|
|
115
|
+
right: 0;
|
|
116
|
+
top: 0;
|
|
117
|
+
width: 2px;
|
|
118
|
+
height: var(--active-tab-height);
|
|
119
|
+
transform: translateY(var(--active-tab-top));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Tab panel content
|
|
124
|
+
.panel {
|
|
125
|
+
padding: var(--fui-space-4, $fui-space-4) 0;
|
|
126
|
+
outline: none;
|
|
127
|
+
|
|
128
|
+
&[data-hidden] {
|
|
129
|
+
display: none;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Focus visible for keyboard navigation
|
|
133
|
+
&:focus-visible {
|
|
134
|
+
@include focus-ring;
|
|
135
|
+
border-radius: var(--fui-radius-md, $fui-radius-md);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Panel with no padding (for custom layouts)
|
|
140
|
+
.panelFlush {
|
|
141
|
+
padding: 0;
|
|
142
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Tabs as BaseTabs } from '@base-ui/react/tabs';
|
|
3
|
+
import styles from './Tabs.module.scss';
|
|
4
|
+
// Import globals to ensure CSS variables are defined
|
|
5
|
+
import '../../styles/globals.scss';
|
|
6
|
+
|
|
7
|
+
// ============================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================
|
|
10
|
+
|
|
11
|
+
export type TabValue = string | number;
|
|
12
|
+
|
|
13
|
+
export interface TabsProps {
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
defaultValue?: TabValue;
|
|
16
|
+
value?: TabValue;
|
|
17
|
+
onValueChange?: (value: TabValue) => void;
|
|
18
|
+
orientation?: 'horizontal' | 'vertical';
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TabsListProps {
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
variant?: 'underline' | 'pills';
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TabProps {
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
value: TabValue;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TabsPanelProps {
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
value: TabValue;
|
|
38
|
+
keepMounted?: boolean;
|
|
39
|
+
flush?: boolean;
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================
|
|
44
|
+
// Context for variant
|
|
45
|
+
// ============================================
|
|
46
|
+
|
|
47
|
+
const TabsVariantContext = React.createContext<'underline' | 'pills'>('underline');
|
|
48
|
+
|
|
49
|
+
// ============================================
|
|
50
|
+
// Components
|
|
51
|
+
// ============================================
|
|
52
|
+
|
|
53
|
+
function TabsRoot({
|
|
54
|
+
children,
|
|
55
|
+
defaultValue,
|
|
56
|
+
value,
|
|
57
|
+
onValueChange,
|
|
58
|
+
orientation = 'horizontal',
|
|
59
|
+
className,
|
|
60
|
+
}: TabsProps) {
|
|
61
|
+
const classes = [styles.root, className].filter(Boolean).join(' ');
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<BaseTabs.Root
|
|
65
|
+
defaultValue={defaultValue}
|
|
66
|
+
value={value}
|
|
67
|
+
onValueChange={onValueChange}
|
|
68
|
+
orientation={orientation}
|
|
69
|
+
className={classes}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</BaseTabs.Root>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function TabsList({
|
|
77
|
+
children,
|
|
78
|
+
variant = 'underline',
|
|
79
|
+
className,
|
|
80
|
+
}: TabsListProps) {
|
|
81
|
+
const variantClass = variant === 'pills' ? styles.listPills : styles.listUnderline;
|
|
82
|
+
const classes = [styles.list, variantClass, className].filter(Boolean).join(' ');
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<TabsVariantContext.Provider value={variant}>
|
|
86
|
+
<BaseTabs.List className={classes}>
|
|
87
|
+
{children}
|
|
88
|
+
{variant === 'underline' && <BaseTabs.Indicator className={styles.indicator} />}
|
|
89
|
+
</BaseTabs.List>
|
|
90
|
+
</TabsVariantContext.Provider>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function Tab({
|
|
95
|
+
children,
|
|
96
|
+
value,
|
|
97
|
+
disabled,
|
|
98
|
+
className,
|
|
99
|
+
}: TabProps) {
|
|
100
|
+
const variant = React.useContext(TabsVariantContext);
|
|
101
|
+
const variantClass = variant === 'pills' ? styles.tabPills : styles.tabUnderline;
|
|
102
|
+
const classes = [styles.tab, variantClass, className].filter(Boolean).join(' ');
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<BaseTabs.Tab value={value} disabled={disabled} className={classes}>
|
|
106
|
+
{children}
|
|
107
|
+
</BaseTabs.Tab>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function TabsPanel({
|
|
112
|
+
children,
|
|
113
|
+
value,
|
|
114
|
+
keepMounted = false,
|
|
115
|
+
flush = false,
|
|
116
|
+
className,
|
|
117
|
+
}: TabsPanelProps) {
|
|
118
|
+
const classes = [
|
|
119
|
+
styles.panel,
|
|
120
|
+
flush && styles.panelFlush,
|
|
121
|
+
className,
|
|
122
|
+
].filter(Boolean).join(' ');
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<BaseTabs.Panel value={value} keepMounted={keepMounted} className={classes}>
|
|
126
|
+
{children}
|
|
127
|
+
</BaseTabs.Panel>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================
|
|
132
|
+
// Export compound component
|
|
133
|
+
// ============================================
|
|
134
|
+
|
|
135
|
+
export const Tabs = Object.assign(TabsRoot, {
|
|
136
|
+
List: TabsList,
|
|
137
|
+
Tab: Tab,
|
|
138
|
+
Panel: TabsPanel,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Re-export individual components
|
|
142
|
+
export { TabsRoot, TabsList, Tab, TabsPanel };
|