@dashforge/tw 0.9.1-beta → 0.11.0-beta
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/CHANGELOG.md +53 -0
- package/dist/index.esm.js +1330 -405
- package/dist/src/components/DateRangePicker/DateRangePicker.d.ts +13 -0
- package/dist/src/components/DateRangePicker/DateRangePicker.d.ts.map +1 -0
- package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts +85 -0
- package/dist/src/components/DateRangePicker/dateRangePicker.types.d.ts.map +1 -0
- package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts +183 -0
- package/dist/src/components/DateRangePicker/dateRangePicker.variants.d.ts.map +1 -0
- package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +7 -43
- package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
- package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +50 -55
- package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -1
- package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +36 -94
- package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -1
- package/dist/src/components/Tabs/Tabs.d.ts +8 -4
- package/dist/src/components/Tabs/Tabs.d.ts.map +1 -1
- package/dist/src/components/Tabs/tabs.types.d.ts +8 -2
- package/dist/src/components/Tabs/tabs.types.d.ts.map +1 -1
- package/dist/src/components/Tabs/useTabs.d.ts +55 -0
- package/dist/src/components/Tabs/useTabs.d.ts.map +1 -0
- package/dist/src/components/TimePicker/TimePicker.d.ts +12 -0
- package/dist/src/components/TimePicker/TimePicker.d.ts.map +1 -0
- package/dist/src/components/TimePicker/timePicker.types.d.ts +80 -0
- package/dist/src/components/TimePicker/timePicker.types.d.ts.map +1 -0
- package/dist/src/components/TimePicker/timePicker.variants.d.ts +103 -0
- package/dist/src/components/TimePicker/timePicker.variants.d.ts.map +1 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +3 -4
- package/src/components/DateRangePicker/DateRangePicker.test.tsx +92 -0
- package/src/components/DateRangePicker/DateRangePicker.tsx +510 -0
- package/src/components/DateRangePicker/dateRangePicker.types.ts +74 -0
- package/src/components/DateRangePicker/dateRangePicker.variants.ts +101 -0
- package/src/components/DateTimePicker/DateTimePicker.test.tsx +60 -186
- package/src/components/DateTimePicker/DateTimePicker.tsx +209 -181
- package/src/components/DateTimePicker/dateTimePicker.types.ts +50 -54
- package/src/components/DateTimePicker/dateTimePicker.variants.ts +22 -51
- package/src/components/Tabs/Tabs.test.tsx +85 -14
- package/src/components/Tabs/Tabs.tsx +35 -24
- package/src/components/Tabs/tabs.types.ts +8 -2
- package/src/components/Tabs/useTabs.ts +167 -0
- package/src/components/TimePicker/TimePicker.test.tsx +65 -0
- package/src/components/TimePicker/TimePicker.tsx +274 -0
- package/src/components/TimePicker/timePicker.types.ts +69 -0
- package/src/components/TimePicker/timePicker.variants.ts +53 -0
- package/src/index.ts +28 -16
- package/src/components/DateTimePicker/DateTimePicker.perf.test.tsx +0 -153
|
@@ -1,84 +1,55 @@
|
|
|
1
|
-
import { tv
|
|
1
|
+
import { tv } from 'tailwind-variants';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Slots for `<DateTimePicker>` — a bridge-integrated date+time field whose
|
|
5
|
+
* trigger is a `<button>` styled as an input, opening a popover that
|
|
6
|
+
* combines a `<Calendar>` with a time list.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Native `<input type="date|time|datetime-local">` quirks handled here:
|
|
10
|
-
*
|
|
11
|
-
* - The browser-rendered placeholder mask (e.g., `mm/dd/yyyy`) is
|
|
12
|
-
* invisible on `bg-neutral-50` by default in some themes; we keep
|
|
13
|
-
* the same neutral palette and rely on the input's own contrast
|
|
14
|
-
* rules (no extra `::placeholder` styling — browsers don't honor it
|
|
15
|
-
* on these input types).
|
|
16
|
-
* - The native picker indicator (calendar / clock icon) is
|
|
17
|
-
* browser-supplied; we ensure it stays visible in dark mode via
|
|
18
|
-
* `color-scheme: light dark` on the input (set inline below).
|
|
8
|
+
* Theme identity: bare neutral / brand token-backed classes only — no
|
|
9
|
+
* `dark:` variants on the neutral palette (the preset CSS-var swap
|
|
10
|
+
* auto-inverts). See `_shared/themeIdentity.test.ts`.
|
|
19
11
|
*/
|
|
20
12
|
export const dateTimePickerVariants = tv({
|
|
21
13
|
slots: {
|
|
22
|
-
root: 'flex',
|
|
14
|
+
root: 'flex flex-col',
|
|
23
15
|
label: 'block text-sm font-medium text-neutral-900 mb-1',
|
|
24
16
|
requiredMark: 'text-danger-500 ml-0.5',
|
|
25
|
-
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
],
|
|
32
|
-
|
|
33
|
-
'
|
|
34
|
-
'text-neutral-900 placeholder:text-neutral-400',
|
|
35
|
-
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
36
|
-
'read-only:cursor-default',
|
|
37
|
-
// `color-scheme` lets the native calendar / clock icon respect
|
|
38
|
-
// the dark mode (otherwise it stays light-themed against dark bg)
|
|
39
|
-
'[color-scheme:light_dark]',
|
|
40
|
-
],
|
|
17
|
+
trigger:
|
|
18
|
+
'flex items-center gap-2 h-10 min-w-[13rem] rounded-md border border-neutral-300 bg-neutral-50 px-3 text-base text-neutral-900 cursor-pointer transition-colors hover:border-neutral-400 focus-visible:outline-none focus-visible:border-primary-500 focus-visible:ring-2 focus-visible:ring-primary-500/30 disabled:cursor-not-allowed disabled:opacity-60 disabled:bg-neutral-200',
|
|
19
|
+
value: 'flex-1 truncate text-left',
|
|
20
|
+
placeholder: 'flex-1 truncate text-left text-neutral-400',
|
|
21
|
+
icon: 'shrink-0 text-[1rem] text-neutral-500',
|
|
22
|
+
panel: 'flex items-stretch',
|
|
23
|
+
list: 'flex max-h-[19rem] min-w-[6.5rem] flex-col gap-0.5 overflow-y-auto border-l border-neutral-200 p-1',
|
|
24
|
+
option:
|
|
25
|
+
'flex w-full cursor-pointer items-center rounded px-2.5 py-1.5 text-sm text-neutral-900 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500 aria-selected:bg-primary-600 aria-selected:text-white aria-selected:hover:bg-primary-600',
|
|
41
26
|
helperText: 'mt-1 text-sm text-neutral-600',
|
|
42
27
|
errorText: 'mt-1 text-sm text-danger-600',
|
|
43
28
|
},
|
|
44
29
|
variants: {
|
|
45
|
-
size: {
|
|
46
|
-
sm: { inputWrapper: 'h-8 px-2', input: 'text-sm' },
|
|
47
|
-
md: { inputWrapper: 'h-10 px-3', input: 'text-base' },
|
|
48
|
-
lg: { inputWrapper: 'h-12 px-4', input: 'text-lg' },
|
|
49
|
-
},
|
|
50
30
|
layout: {
|
|
51
|
-
stacked: {
|
|
31
|
+
stacked: {},
|
|
52
32
|
inline: {
|
|
53
33
|
root: 'flex-row items-start gap-3',
|
|
54
|
-
label: 'mb-0 pt-2 whitespace-nowrap shrink-0',
|
|
34
|
+
label: 'mb-0 pt-2.5 whitespace-nowrap shrink-0',
|
|
55
35
|
},
|
|
56
36
|
},
|
|
57
37
|
error: {
|
|
58
38
|
true: {
|
|
59
|
-
|
|
60
|
-
'border-danger-500 focus-
|
|
39
|
+
trigger:
|
|
40
|
+
'border-danger-500 hover:border-danger-500 focus-visible:border-danger-500 focus-visible:ring-danger-500/30',
|
|
61
41
|
},
|
|
62
42
|
},
|
|
63
43
|
fullWidth: {
|
|
64
44
|
true: {
|
|
65
45
|
root: 'w-full',
|
|
66
|
-
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
disabled: {
|
|
70
|
-
true: {
|
|
71
|
-
inputWrapper: 'opacity-60 cursor-not-allowed bg-neutral-200',
|
|
46
|
+
trigger: 'w-full min-w-0',
|
|
72
47
|
},
|
|
73
48
|
},
|
|
74
49
|
},
|
|
75
50
|
defaultVariants: {
|
|
76
|
-
size: 'md',
|
|
77
51
|
layout: 'stacked',
|
|
78
52
|
error: false,
|
|
79
53
|
fullWidth: false,
|
|
80
|
-
disabled: false,
|
|
81
54
|
},
|
|
82
55
|
});
|
|
83
|
-
|
|
84
|
-
export type DateTimePickerVariants = VariantProps<typeof dateTimePickerVariants>;
|
|
@@ -19,7 +19,6 @@ describe('Tabs — rendering', () => {
|
|
|
19
19
|
expect(screen.queryByRole('tab', { name: 'Overview' })).not.toBeNull();
|
|
20
20
|
expect(screen.queryByRole('tab', { name: 'Details' })).not.toBeNull();
|
|
21
21
|
expect(screen.queryByRole('tab', { name: 'History' })).not.toBeNull();
|
|
22
|
-
// Only the active panel renders content (others get data-state="inactive").
|
|
23
22
|
expect(screen.queryByText('Overview body')).not.toBeNull();
|
|
24
23
|
});
|
|
25
24
|
|
|
@@ -30,7 +29,7 @@ describe('Tabs — rendering', () => {
|
|
|
30
29
|
|
|
31
30
|
it('switches active panel via controlled `value` rerender', () => {
|
|
32
31
|
const { rerender } = render(
|
|
33
|
-
<Tabs items={items} value="overview" onValueChange={() => {}}
|
|
32
|
+
<Tabs items={items} value="overview" onValueChange={() => {}} />,
|
|
34
33
|
);
|
|
35
34
|
expect(screen.queryByText('Overview body')).not.toBeNull();
|
|
36
35
|
rerender(<Tabs items={items} value="details" onValueChange={() => {}} />);
|
|
@@ -39,14 +38,20 @@ describe('Tabs — rendering', () => {
|
|
|
39
38
|
|
|
40
39
|
it('marks the active tab via aria-selected', () => {
|
|
41
40
|
render(<Tabs items={items} defaultValue="details" />);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
expect(
|
|
41
|
+
expect(
|
|
42
|
+
screen.getByRole('tab', { name: 'Details' }).getAttribute('aria-selected'),
|
|
43
|
+
).toBe('true');
|
|
44
|
+
expect(
|
|
45
|
+
screen.getByRole('tab', { name: 'Overview' }).getAttribute('aria-selected'),
|
|
46
|
+
).toBe('false');
|
|
46
47
|
});
|
|
47
|
-
});
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
it('selects a tab on click', () => {
|
|
50
|
+
render(<Tabs items={items} />);
|
|
51
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Details' }));
|
|
52
|
+
expect(screen.queryByText('Details body')).not.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
50
55
|
|
|
51
56
|
describe('Tabs — variants', () => {
|
|
52
57
|
it.each(['underline', 'pill'] as const)('renders variant=%s', (variant) => {
|
|
@@ -56,19 +61,85 @@ describe('Tabs — variants', () => {
|
|
|
56
61
|
|
|
57
62
|
it.each(['horizontal', 'vertical'] as const)('renders orientation=%s', (o) => {
|
|
58
63
|
render(<Tabs items={items} orientation={o} />);
|
|
59
|
-
|
|
60
|
-
expect(list.getAttribute('aria-orientation')).toBe(o);
|
|
64
|
+
expect(screen.getByRole('tablist').getAttribute('aria-orientation')).toBe(o);
|
|
61
65
|
});
|
|
62
66
|
});
|
|
63
67
|
|
|
64
68
|
describe('Tabs — disabled items', () => {
|
|
65
69
|
it('marks disabled triggers as disabled', () => {
|
|
70
|
+
const disabledItems = [...items.slice(0, 2), { ...items[2]!, disabled: true }];
|
|
71
|
+
render(<Tabs items={disabledItems} />);
|
|
72
|
+
const trigger = screen.getByRole('tab', {
|
|
73
|
+
name: 'History',
|
|
74
|
+
}) as HTMLButtonElement;
|
|
75
|
+
expect(trigger.disabled).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('skips disabled tabs during keyboard navigation', () => {
|
|
66
79
|
const disabledItems = [
|
|
67
|
-
|
|
68
|
-
{ ...items[
|
|
80
|
+
items[0]!,
|
|
81
|
+
{ ...items[1]!, disabled: true },
|
|
82
|
+
items[2]!,
|
|
69
83
|
];
|
|
70
84
|
render(<Tabs items={disabledItems} />);
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' });
|
|
86
|
+
// Overview → (skip disabled Details) → History
|
|
87
|
+
expect(
|
|
88
|
+
screen.getByRole('tab', { name: 'History' }).getAttribute('aria-selected'),
|
|
89
|
+
).toBe('true');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('Tabs — keyboard', () => {
|
|
94
|
+
it('ArrowRight activates the next tab', () => {
|
|
95
|
+
render(<Tabs items={items} />);
|
|
96
|
+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' });
|
|
97
|
+
expect(
|
|
98
|
+
screen.getByRole('tab', { name: 'Details' }).getAttribute('aria-selected'),
|
|
99
|
+
).toBe('true');
|
|
100
|
+
expect(screen.queryByText('Details body')).not.toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('ArrowRight wraps at the end', () => {
|
|
104
|
+
render(<Tabs items={items} defaultValue="history" />);
|
|
105
|
+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' });
|
|
106
|
+
expect(
|
|
107
|
+
screen.getByRole('tab', { name: 'Overview' }).getAttribute('aria-selected'),
|
|
108
|
+
).toBe('true');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('Home / End jump to the first / last tab', () => {
|
|
112
|
+
render(<Tabs items={items} defaultValue="details" />);
|
|
113
|
+
const list = screen.getByRole('tablist');
|
|
114
|
+
fireEvent.keyDown(list, { key: 'End' });
|
|
115
|
+
expect(
|
|
116
|
+
screen.getByRole('tab', { name: 'History' }).getAttribute('aria-selected'),
|
|
117
|
+
).toBe('true');
|
|
118
|
+
fireEvent.keyDown(list, { key: 'Home' });
|
|
119
|
+
expect(
|
|
120
|
+
screen.getByRole('tab', { name: 'Overview' }).getAttribute('aria-selected'),
|
|
121
|
+
).toBe('true');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('ArrowDown drives a vertical tablist', () => {
|
|
125
|
+
render(<Tabs items={items} orientation="vertical" />);
|
|
126
|
+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowDown' });
|
|
127
|
+
expect(
|
|
128
|
+
screen.getByRole('tab', { name: 'Details' }).getAttribute('aria-selected'),
|
|
129
|
+
).toBe('true');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('Tabs — keepMounted', () => {
|
|
134
|
+
it('mounts only the active panel by default', () => {
|
|
135
|
+
render(<Tabs items={items} defaultValue="overview" />);
|
|
136
|
+
expect(screen.queryByText('Overview body')).not.toBeNull();
|
|
137
|
+
expect(screen.queryByText('History body')).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('keeps every panel mounted when keepMounted is set', () => {
|
|
141
|
+
render(<Tabs items={items} defaultValue="overview" keepMounted />);
|
|
142
|
+
expect(screen.queryByText('Overview body')).not.toBeNull();
|
|
143
|
+
expect(screen.queryByText('History body')).not.toBeNull();
|
|
73
144
|
});
|
|
74
145
|
});
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
import * as RadixTabs from '@radix-ui/react-tabs';
|
|
2
1
|
import { cn } from '../../utils/cn.js';
|
|
3
2
|
import { tabsVariants } from './tabs.variants.js';
|
|
3
|
+
import { useTabs } from './useTabs.js';
|
|
4
4
|
import type { TabsProps } from './tabs.types.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Dashforge TW `<Tabs>` — declarative tab navigation.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Custom clean-room implementation on the headless `useTabs` engine — no
|
|
10
|
+
* runtime UI dependency. Implements the WAI-ARIA APG tabs pattern: arrow-key
|
|
11
|
+
* navigation, automatic activation, roving tabindex, `role="tablist"` +
|
|
12
|
+
* `role="tab"` + `role="tabpanel"`.
|
|
13
13
|
*
|
|
14
14
|
* Two variant axes:
|
|
15
15
|
* - `variant` — `underline` (default) | `pill`
|
|
16
16
|
* - `orientation` — `horizontal` (default) | `vertical`
|
|
17
|
+
*
|
|
18
|
+
* Controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
|
|
19
|
+
* `keepMounted` keeps inactive panels in the DOM (default: only the active
|
|
20
|
+
* panel is mounted).
|
|
17
21
|
*/
|
|
18
22
|
export function Tabs(props: TabsProps) {
|
|
19
23
|
const {
|
|
@@ -23,44 +27,51 @@ export function Tabs(props: TabsProps) {
|
|
|
23
27
|
onValueChange,
|
|
24
28
|
variant = 'underline',
|
|
25
29
|
orientation = 'horizontal',
|
|
30
|
+
keepMounted = false,
|
|
26
31
|
sx,
|
|
27
32
|
slotProps,
|
|
28
33
|
} = props;
|
|
29
34
|
|
|
30
35
|
const v = tabsVariants({ variant, orientation });
|
|
31
|
-
const
|
|
36
|
+
const tabs = useTabs({
|
|
37
|
+
items,
|
|
38
|
+
value,
|
|
39
|
+
defaultValue,
|
|
40
|
+
onValueChange,
|
|
41
|
+
orientation,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Either all panels (hidden when inactive) or just the active one.
|
|
45
|
+
const panels = keepMounted
|
|
46
|
+
? items
|
|
47
|
+
: items.filter((item) => item.value === tabs.activeValue);
|
|
32
48
|
|
|
33
49
|
return (
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
onValueChange={onValueChange}
|
|
38
|
-
orientation={orientation}
|
|
39
|
-
className={cn(v.root(), sx, slotProps?.root?.className)}
|
|
40
|
-
>
|
|
41
|
-
<RadixTabs.List
|
|
50
|
+
<div className={cn(v.root(), sx, slotProps?.root?.className)}>
|
|
51
|
+
<div
|
|
52
|
+
{...tabs.getTabListProps()}
|
|
42
53
|
className={cn(v.list(), slotProps?.list?.className)}
|
|
43
54
|
>
|
|
44
55
|
{items.map((item) => (
|
|
45
|
-
<
|
|
56
|
+
<button
|
|
46
57
|
key={item.value}
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
type="button"
|
|
59
|
+
{...tabs.getTabProps(item)}
|
|
49
60
|
className={cn(v.trigger(), slotProps?.trigger?.className)}
|
|
50
61
|
>
|
|
51
62
|
{item.label}
|
|
52
|
-
</
|
|
63
|
+
</button>
|
|
53
64
|
))}
|
|
54
|
-
</
|
|
55
|
-
{
|
|
56
|
-
<
|
|
65
|
+
</div>
|
|
66
|
+
{panels.map((item) => (
|
|
67
|
+
<div
|
|
57
68
|
key={item.value}
|
|
58
|
-
|
|
69
|
+
{...tabs.getPanelProps(item)}
|
|
59
70
|
className={cn(v.content(), slotProps?.content?.className)}
|
|
60
71
|
>
|
|
61
72
|
{item.content}
|
|
62
|
-
</
|
|
73
|
+
</div>
|
|
63
74
|
))}
|
|
64
|
-
</
|
|
75
|
+
</div>
|
|
65
76
|
);
|
|
66
77
|
}
|
|
@@ -26,7 +26,8 @@ export interface TabItem {
|
|
|
26
26
|
/**
|
|
27
27
|
* Props for `<Tabs>`.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
29
|
+
* Custom clean-room implementation — the headless `useTabs` engine, no Radix.
|
|
30
|
+
* Two variant axes:
|
|
30
31
|
* - `variant` — `underline` (default) | `pill`
|
|
31
32
|
* - `orientation` — `horizontal` (default) | `vertical`
|
|
32
33
|
*
|
|
@@ -34,7 +35,7 @@ export interface TabItem {
|
|
|
34
35
|
* (`defaultValue`) modes — the `value` prop wins when both are set.
|
|
35
36
|
*/
|
|
36
37
|
export interface TabsProps extends TabsVariants {
|
|
37
|
-
/** Tab items array
|
|
38
|
+
/** Tab items array. */
|
|
38
39
|
items: TabItem[];
|
|
39
40
|
/** Controlled active value. */
|
|
40
41
|
value?: string;
|
|
@@ -42,6 +43,11 @@ export interface TabsProps extends TabsVariants {
|
|
|
42
43
|
defaultValue?: string;
|
|
43
44
|
/** Fires when the active tab changes. */
|
|
44
45
|
onValueChange?: (value: string) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Keep inactive panels mounted (hidden via the `hidden` attribute).
|
|
48
|
+
* Default `false` — only the active panel is in the DOM.
|
|
49
|
+
*/
|
|
50
|
+
keepMounted?: boolean;
|
|
45
51
|
/** Root className shortcut. */
|
|
46
52
|
sx?: string;
|
|
47
53
|
/** Per-slot overrides. */
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useCallback, useId, useRef, useState } from 'react';
|
|
2
|
+
import type { KeyboardEvent } from 'react';
|
|
3
|
+
import type { TabItem } from './tabs.types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `useTabs` — the headless tab engine for the Tailwind `<Tabs>`.
|
|
7
|
+
*
|
|
8
|
+
* Pure logic, zero UI: active-value state (controlled / uncontrolled) plus
|
|
9
|
+
* the WAI-ARIA APG keyboard model (arrow keys, Home/End, roving tabindex,
|
|
10
|
+
* automatic activation, disabled-skip, edge wrap). Returns attribute bundles
|
|
11
|
+
* for the `tablist` / `tab` / `tabpanel` roles.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT a shared package: the tab logic is ~90 lines — too thin
|
|
14
|
+
* to justify a published `tabs-core`. The MUI skin keeps its own near-identical
|
|
15
|
+
* copy. Only the public API contract is shared.
|
|
16
|
+
*/
|
|
17
|
+
interface UseTabsOptions {
|
|
18
|
+
items: TabItem[];
|
|
19
|
+
value: string | undefined;
|
|
20
|
+
defaultValue: string | undefined;
|
|
21
|
+
onValueChange: ((value: string) => void) | undefined;
|
|
22
|
+
orientation: 'horizontal' | 'vertical';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TabListAttributes {
|
|
26
|
+
role: 'tablist';
|
|
27
|
+
'aria-orientation': 'horizontal' | 'vertical';
|
|
28
|
+
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TabAttributes {
|
|
32
|
+
role: 'tab';
|
|
33
|
+
id: string;
|
|
34
|
+
'aria-selected': boolean;
|
|
35
|
+
'aria-controls': string;
|
|
36
|
+
tabIndex: number;
|
|
37
|
+
disabled: boolean | undefined;
|
|
38
|
+
'data-state': 'active' | 'inactive';
|
|
39
|
+
onClick: () => void;
|
|
40
|
+
ref: (el: HTMLButtonElement | null) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PanelAttributes {
|
|
44
|
+
role: 'tabpanel';
|
|
45
|
+
id: string;
|
|
46
|
+
'aria-labelledby': string;
|
|
47
|
+
tabIndex: number;
|
|
48
|
+
hidden: boolean;
|
|
49
|
+
'data-state': 'active' | 'inactive';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UseTabsResult {
|
|
53
|
+
/** The currently active tab value. */
|
|
54
|
+
activeValue: string;
|
|
55
|
+
getTabListProps: () => TabListAttributes;
|
|
56
|
+
getTabProps: (item: TabItem) => TabAttributes;
|
|
57
|
+
getPanelProps: (item: TabItem) => PanelAttributes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useTabs(options: UseTabsOptions): UseTabsResult {
|
|
61
|
+
const { items, value, defaultValue, onValueChange, orientation } = options;
|
|
62
|
+
|
|
63
|
+
const baseId = useId();
|
|
64
|
+
const firstValue = items[0]?.value ?? '';
|
|
65
|
+
const [internalValue, setInternalValue] = useState<string>(
|
|
66
|
+
defaultValue ?? firstValue,
|
|
67
|
+
);
|
|
68
|
+
const isControlled = value !== undefined;
|
|
69
|
+
const activeValue = isControlled ? value : internalValue;
|
|
70
|
+
|
|
71
|
+
// Tab buttons, keyed by value — used to move DOM focus on keyboard nav.
|
|
72
|
+
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
73
|
+
|
|
74
|
+
const select = useCallback(
|
|
75
|
+
(next: string) => {
|
|
76
|
+
if (!isControlled) {
|
|
77
|
+
setInternalValue(next);
|
|
78
|
+
}
|
|
79
|
+
onValueChange?.(next);
|
|
80
|
+
},
|
|
81
|
+
[isControlled, onValueChange],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const handleKeyDown = useCallback(
|
|
85
|
+
(event: KeyboardEvent<HTMLDivElement>) => {
|
|
86
|
+
const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
|
|
87
|
+
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
|
|
88
|
+
const enabled = items.filter((item) => item.disabled !== true);
|
|
89
|
+
if (enabled.length === 0) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const currentIndex = enabled.findIndex(
|
|
93
|
+
(item) => item.value === activeValue,
|
|
94
|
+
);
|
|
95
|
+
let targetIndex: number | null = null;
|
|
96
|
+
if (event.key === nextKey) {
|
|
97
|
+
targetIndex =
|
|
98
|
+
currentIndex < 0 ? 0 : (currentIndex + 1) % enabled.length;
|
|
99
|
+
} else if (event.key === prevKey) {
|
|
100
|
+
targetIndex =
|
|
101
|
+
currentIndex < 0
|
|
102
|
+
? enabled.length - 1
|
|
103
|
+
: (currentIndex - 1 + enabled.length) % enabled.length;
|
|
104
|
+
} else if (event.key === 'Home') {
|
|
105
|
+
targetIndex = 0;
|
|
106
|
+
} else if (event.key === 'End') {
|
|
107
|
+
targetIndex = enabled.length - 1;
|
|
108
|
+
}
|
|
109
|
+
if (targetIndex === null) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
const target = enabled[targetIndex];
|
|
114
|
+
if (target === undefined) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
select(target.value);
|
|
118
|
+
tabRefs.current.get(target.value)?.focus();
|
|
119
|
+
},
|
|
120
|
+
[items, orientation, activeValue, select],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const getTabListProps = (): TabListAttributes => ({
|
|
124
|
+
role: 'tablist',
|
|
125
|
+
'aria-orientation': orientation,
|
|
126
|
+
onKeyDown: handleKeyDown,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const getTabProps = (item: TabItem): TabAttributes => {
|
|
130
|
+
const active = item.value === activeValue;
|
|
131
|
+
return {
|
|
132
|
+
role: 'tab',
|
|
133
|
+
id: `${baseId}-tab-${item.value}`,
|
|
134
|
+
'aria-selected': active,
|
|
135
|
+
'aria-controls': `${baseId}-panel-${item.value}`,
|
|
136
|
+
tabIndex: active ? 0 : -1,
|
|
137
|
+
disabled: item.disabled,
|
|
138
|
+
'data-state': active ? 'active' : 'inactive',
|
|
139
|
+
onClick: () => {
|
|
140
|
+
if (item.disabled !== true) {
|
|
141
|
+
select(item.value);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
ref: (el: HTMLButtonElement | null) => {
|
|
145
|
+
if (el) {
|
|
146
|
+
tabRefs.current.set(item.value, el);
|
|
147
|
+
} else {
|
|
148
|
+
tabRefs.current.delete(item.value);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const getPanelProps = (item: TabItem): PanelAttributes => {
|
|
155
|
+
const active = item.value === activeValue;
|
|
156
|
+
return {
|
|
157
|
+
role: 'tabpanel',
|
|
158
|
+
id: `${baseId}-panel-${item.value}`,
|
|
159
|
+
'aria-labelledby': `${baseId}-tab-${item.value}`,
|
|
160
|
+
tabIndex: 0,
|
|
161
|
+
hidden: !active,
|
|
162
|
+
'data-state': active ? 'active' : 'inactive',
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return { activeValue, getTabListProps, getTabProps, getPanelProps };
|
|
167
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
import { TimePicker } from './TimePicker.js';
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
// Radix Popover (Floating UI) needs ResizeObserver — jsdom lacks it.
|
|
9
|
+
if (!('ResizeObserver' in globalThis)) {
|
|
10
|
+
class ResizeObserverStub {
|
|
11
|
+
observe(): void {
|
|
12
|
+
/* no-op */
|
|
13
|
+
}
|
|
14
|
+
unobserve(): void {
|
|
15
|
+
/* no-op */
|
|
16
|
+
}
|
|
17
|
+
disconnect(): void {
|
|
18
|
+
/* no-op */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
|
|
22
|
+
ResizeObserverStub;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('TimePicker', () => {
|
|
27
|
+
it('renders a labelled trigger with the placeholder', () => {
|
|
28
|
+
render(<TimePicker name="alarm" label="Alarm" placeholder="Pick a time" />);
|
|
29
|
+
expect(screen.queryByText('Alarm')).not.toBeNull();
|
|
30
|
+
expect(screen.getByRole('button').textContent).toContain('Pick a time');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('shows a 24-hour controlled value', () => {
|
|
34
|
+
render(<TimePicker name="t" value="14:30" />);
|
|
35
|
+
expect(screen.getByRole('button').textContent).toContain('14:30');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('formats the value in 12-hour notation when hour12 is set', () => {
|
|
39
|
+
render(<TimePicker name="t" value="14:30" hour12 />);
|
|
40
|
+
expect(screen.getByRole('button').textContent).toContain('2:30 PM');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('opens the time-list popover from the trigger', () => {
|
|
44
|
+
render(<TimePicker name="t" />);
|
|
45
|
+
expect(screen.queryByRole('listbox')).toBeNull();
|
|
46
|
+
fireEvent.click(screen.getByRole('button'));
|
|
47
|
+
expect(screen.getByRole('listbox', { name: 'Time options' })).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('selects a time from the popover, updates the trigger and closes', () => {
|
|
51
|
+
const onChange = vi.fn();
|
|
52
|
+
render(<TimePicker name="t" onChange={onChange} />);
|
|
53
|
+
fireEvent.click(screen.getByRole('button'));
|
|
54
|
+
fireEvent.click(screen.getByRole('option', { name: '09:00' }));
|
|
55
|
+
expect(onChange).toHaveBeenCalledWith('09:00');
|
|
56
|
+
expect(screen.queryByRole('listbox')).toBeNull();
|
|
57
|
+
expect(screen.getByRole('button').textContent).toContain('09:00');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not open the popover when disabled', () => {
|
|
61
|
+
render(<TimePicker name="t" disabled />);
|
|
62
|
+
fireEvent.click(screen.getByRole('button'));
|
|
63
|
+
expect(screen.queryByRole('listbox')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|