@sovereignfs/ui 0.41.0 → 0.45.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/dist/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +617 -5
- package/dist/index.js +1847 -499
- package/dist/tokens/primitives.css +22 -8
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +30 -2
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/index.ts +78 -1
- package/src/stories/DesignSystemOverview.stories.tsx +614 -2
- package/src/stories/MobilePatterns.stories.tsx +8 -2
- package/src/stories/TokenGallery.stories.tsx +112 -16
- package/src/tokens/primitives.css +22 -8
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import styles from './Progress.module.css';
|
|
2
|
+
|
|
3
|
+
export interface ProgressProps {
|
|
4
|
+
/** 0–100. Values outside that range are clamped. */
|
|
5
|
+
value: number;
|
|
6
|
+
/** Accessible name — Progress has no visible text of its own. */
|
|
7
|
+
label?: string;
|
|
8
|
+
id?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Progress — determinate bar.
|
|
14
|
+
*
|
|
15
|
+
* `role="progressbar"` with `aria-valuenow`/`aria-valuemin`/`aria-valuemax`
|
|
16
|
+
* so screen readers announce the current value. Indeterminate state is out
|
|
17
|
+
* of scope until a consumer needs it.
|
|
18
|
+
*/
|
|
19
|
+
export function Progress({ value, label, id, className }: ProgressProps) {
|
|
20
|
+
const clamped = Math.min(100, Math.max(0, value));
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
id={id}
|
|
25
|
+
role="progressbar"
|
|
26
|
+
aria-valuenow={clamped}
|
|
27
|
+
aria-valuemin={0}
|
|
28
|
+
aria-valuemax={100}
|
|
29
|
+
aria-label={label}
|
|
30
|
+
className={[styles.track, className].filter(Boolean).join(' ')}
|
|
31
|
+
>
|
|
32
|
+
<div className={styles.fill} style={{ width: `${clamped}%` }} />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Progress } from '../Progress';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Progress', () => {
|
|
9
|
+
it('exposes progressbar ARIA attributes for the given value', () => {
|
|
10
|
+
render(<Progress value={40} label="Upload progress" />);
|
|
11
|
+
const bar = screen.getByRole('progressbar', { name: 'Upload progress' });
|
|
12
|
+
expect(bar.getAttribute('aria-valuenow')).toBe('40');
|
|
13
|
+
expect(bar.getAttribute('aria-valuemin')).toBe('0');
|
|
14
|
+
expect(bar.getAttribute('aria-valuemax')).toBe('100');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('clamps a value above 100', () => {
|
|
18
|
+
render(<Progress value={140} label="Upload progress" />);
|
|
19
|
+
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('100');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('clamps a negative value to 0', () => {
|
|
23
|
+
render(<Progress value={-10} label="Upload progress" />);
|
|
24
|
+
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('0');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders without an accessible name when label is omitted', () => {
|
|
28
|
+
render(<Progress value={40} />);
|
|
29
|
+
expect(screen.getByRole('progressbar').getAttribute('aria-label')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--sv-space-2);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.option {
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: var(--sv-space-2);
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* The visual circle — same 18px size and touch-target-expansion technique
|
|
15
|
+
as Checkbox's box, radius-full instead of radius-sm. */
|
|
16
|
+
.circle {
|
|
17
|
+
--radio-circle-size: 18px;
|
|
18
|
+
position: relative;
|
|
19
|
+
flex-shrink: 0;
|
|
20
|
+
width: var(--radio-circle-size);
|
|
21
|
+
height: var(--radio-circle-size);
|
|
22
|
+
border-radius: var(--sv-radius-full);
|
|
23
|
+
border: 1.5px solid var(--sv-color-border-strong);
|
|
24
|
+
background: var(--sv-color-surface);
|
|
25
|
+
transition:
|
|
26
|
+
background-color 0.15s ease,
|
|
27
|
+
border-color 0.15s ease;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.checked {
|
|
34
|
+
border-color: var(--sv-color-accent);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.dot {
|
|
38
|
+
width: 8px;
|
|
39
|
+
height: 8px;
|
|
40
|
+
border-radius: var(--sv-radius-full);
|
|
41
|
+
background: var(--sv-color-accent);
|
|
42
|
+
pointer-events: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Hide the native input visually but keep it accessible */
|
|
46
|
+
.input {
|
|
47
|
+
position: absolute;
|
|
48
|
+
inset: 0;
|
|
49
|
+
opacity: 0;
|
|
50
|
+
margin: 0;
|
|
51
|
+
width: 100%;
|
|
52
|
+
height: 100%;
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.input:disabled {
|
|
57
|
+
cursor: not-allowed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Expand the tappable region to the platform's minimum touch target without
|
|
61
|
+
growing the visible circle. Gated to (pointer: coarse), the *primary*
|
|
62
|
+
pointer, so a touchscreen laptop with mouse/trackpad as primary keeps
|
|
63
|
+
desktop density — same reasoning as Checkbox. */
|
|
64
|
+
@media (pointer: coarse) {
|
|
65
|
+
.input {
|
|
66
|
+
inset: calc((var(--radio-circle-size) - var(--sv-touch-target-min, 44px)) / 2);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.input:focus-visible {
|
|
71
|
+
outline: none;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.circle:has(.input:focus-visible) {
|
|
75
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
76
|
+
outline-offset: 2px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.optionLabel {
|
|
80
|
+
font-size: var(--sv-font-size-sm);
|
|
81
|
+
color: var(--sv-color-text-primary);
|
|
82
|
+
user-select: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.disabled {
|
|
86
|
+
opacity: 0.5;
|
|
87
|
+
cursor: not-allowed;
|
|
88
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { RadioGroup } from './RadioGroup';
|
|
4
|
+
import { FormField } from '../FormField/FormField';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/RadioGroup',
|
|
8
|
+
component: RadioGroup,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
'Single-select list of options. Renders real `<input type="radio">` elements sharing one `name`, so keyboard arrow-key navigation between options is native browser behavior.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
items: [
|
|
20
|
+
{ label: 'Small', value: 'sm' },
|
|
21
|
+
{ label: 'Medium', value: 'md' },
|
|
22
|
+
{ label: 'Large', value: 'lg' },
|
|
23
|
+
],
|
|
24
|
+
value: 'md',
|
|
25
|
+
onChange: () => {},
|
|
26
|
+
'aria-label': 'Size',
|
|
27
|
+
},
|
|
28
|
+
} satisfies Meta<typeof RadioGroup>;
|
|
29
|
+
|
|
30
|
+
export default meta;
|
|
31
|
+
type Story = StoryObj<typeof meta>;
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export const Default: Story = {
|
|
36
|
+
render: (args) => {
|
|
37
|
+
const [value, setValue] = useState(args.value);
|
|
38
|
+
return <RadioGroup {...args} value={value} onChange={setValue} />;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const WithDisabledItem: Story = {
|
|
43
|
+
render: (args) => {
|
|
44
|
+
const [value, setValue] = useState(args.value);
|
|
45
|
+
return (
|
|
46
|
+
<RadioGroup
|
|
47
|
+
{...args}
|
|
48
|
+
items={[...args.items, { label: 'X-Large (out of stock)', value: 'xl', disabled: true }]}
|
|
49
|
+
value={value}
|
|
50
|
+
onChange={setValue}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const Disabled: Story = {
|
|
57
|
+
args: { disabled: true },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const InFormField: Story = {
|
|
61
|
+
render: (args) => {
|
|
62
|
+
const [value, setValue] = useState(args.value);
|
|
63
|
+
return (
|
|
64
|
+
<FormField label="Shirt size" error="Select a size to continue.">
|
|
65
|
+
{(field) => <RadioGroup {...args} {...field} value={value} onChange={setValue} />}
|
|
66
|
+
</FormField>
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useId } from 'react';
|
|
4
|
+
import styles from './RadioGroup.module.css';
|
|
5
|
+
|
|
6
|
+
export interface RadioGroupItem {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RadioGroupProps {
|
|
13
|
+
items: RadioGroupItem[];
|
|
14
|
+
value: string;
|
|
15
|
+
onChange: (value: string) => void;
|
|
16
|
+
/** Groups the underlying native radios. Auto-generated when omitted —
|
|
17
|
+
* only pass this to participate in a native, non-React form submission. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** Disables every item. Use `items[].disabled` to disable one. */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
id?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
'aria-label'?: string;
|
|
24
|
+
'aria-labelledby'?: string;
|
|
25
|
+
'aria-describedby'?: string;
|
|
26
|
+
'aria-invalid'?: boolean;
|
|
27
|
+
required?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* RadioGroup — single-select list of options.
|
|
32
|
+
*
|
|
33
|
+
* Renders real `<input type="radio">` elements sharing one `name`, visually
|
|
34
|
+
* hidden behind a custom circle (same technique as `Checkbox`). Keyboard
|
|
35
|
+
* arrow-key navigation between options is native browser behavior for
|
|
36
|
+
* grouped radio inputs — nothing to hand-roll here.
|
|
37
|
+
*/
|
|
38
|
+
export function RadioGroup({
|
|
39
|
+
items,
|
|
40
|
+
value,
|
|
41
|
+
onChange,
|
|
42
|
+
name,
|
|
43
|
+
disabled,
|
|
44
|
+
id,
|
|
45
|
+
className,
|
|
46
|
+
'aria-label': ariaLabel,
|
|
47
|
+
'aria-labelledby': ariaLabelledBy,
|
|
48
|
+
'aria-describedby': ariaDescribedBy,
|
|
49
|
+
'aria-invalid': ariaInvalid,
|
|
50
|
+
required,
|
|
51
|
+
}: RadioGroupProps) {
|
|
52
|
+
const generatedName = useId();
|
|
53
|
+
const groupName = name ?? generatedName;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
id={id}
|
|
58
|
+
role="radiogroup"
|
|
59
|
+
aria-label={ariaLabel}
|
|
60
|
+
aria-labelledby={ariaLabelledBy}
|
|
61
|
+
aria-describedby={ariaDescribedBy}
|
|
62
|
+
aria-invalid={ariaInvalid}
|
|
63
|
+
aria-required={required}
|
|
64
|
+
className={[styles.root, className].filter(Boolean).join(' ')}
|
|
65
|
+
>
|
|
66
|
+
{items.map((item) => {
|
|
67
|
+
const checked = item.value === value;
|
|
68
|
+
const itemDisabled = disabled || item.disabled;
|
|
69
|
+
const inputId = `${groupName}-${item.value}`;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<label
|
|
73
|
+
key={item.value}
|
|
74
|
+
htmlFor={inputId}
|
|
75
|
+
className={[styles.option, itemDisabled ? styles.disabled : '']
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.join(' ')}
|
|
78
|
+
>
|
|
79
|
+
<span className={[styles.circle, checked ? styles.checked : ''].join(' ')}>
|
|
80
|
+
<input
|
|
81
|
+
id={inputId}
|
|
82
|
+
type="radio"
|
|
83
|
+
name={groupName}
|
|
84
|
+
value={item.value}
|
|
85
|
+
checked={checked}
|
|
86
|
+
disabled={itemDisabled}
|
|
87
|
+
required={required}
|
|
88
|
+
className={styles.input}
|
|
89
|
+
onChange={() => onChange(item.value)}
|
|
90
|
+
/>
|
|
91
|
+
{checked && <span className={styles.dot} />}
|
|
92
|
+
</span>
|
|
93
|
+
<span className={styles.optionLabel}>{item.label}</span>
|
|
94
|
+
</label>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { RadioGroup } from '../RadioGroup';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
const items = [
|
|
9
|
+
{ label: 'Small', value: 'sm' },
|
|
10
|
+
{ label: 'Medium', value: 'md' },
|
|
11
|
+
{ label: 'Large', value: 'lg' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe('RadioGroup', () => {
|
|
15
|
+
it('renders all items as radio inputs', () => {
|
|
16
|
+
render(<RadioGroup items={items} value="sm" onChange={() => {}} aria-label="Size" />);
|
|
17
|
+
expect(screen.getAllByRole('radio').length).toBe(3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('marks the matching item as checked', () => {
|
|
21
|
+
render(<RadioGroup items={items} value="md" onChange={() => {}} aria-label="Size" />);
|
|
22
|
+
expect(screen.getByRole('radio', { name: 'Medium' })).toHaveProperty('checked', true);
|
|
23
|
+
expect(screen.getByRole('radio', { name: 'Small' })).toHaveProperty('checked', false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('calls onChange with the selected value', () => {
|
|
27
|
+
const onChange = vi.fn();
|
|
28
|
+
render(<RadioGroup items={items} value="sm" onChange={onChange} aria-label="Size" />);
|
|
29
|
+
fireEvent.click(screen.getByRole('radio', { name: 'Large' }));
|
|
30
|
+
expect(onChange).toHaveBeenCalledWith('lg');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('exposes a radiogroup with the given aria-label', () => {
|
|
34
|
+
render(<RadioGroup items={items} value="sm" onChange={() => {}} aria-label="Size" />);
|
|
35
|
+
expect(screen.getByRole('radiogroup', { name: 'Size' })).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('shares one name across all items so native radio semantics apply', () => {
|
|
39
|
+
render(
|
|
40
|
+
<RadioGroup items={items} value="sm" onChange={() => {}} aria-label="Size" name="size" />,
|
|
41
|
+
);
|
|
42
|
+
const radios = screen.getAllByRole('radio') as HTMLInputElement[];
|
|
43
|
+
expect(new Set(radios.map((r) => r.name)).size).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('disables an individual item via items[].disabled', () => {
|
|
47
|
+
const withDisabled = [...items, { label: 'X-Large', value: 'xl', disabled: true }];
|
|
48
|
+
render(<RadioGroup items={withDisabled} value="sm" onChange={() => {}} aria-label="Size" />);
|
|
49
|
+
expect(screen.getByRole('radio', { name: 'X-Large' })).toHaveProperty('disabled', true);
|
|
50
|
+
expect(screen.getByRole('radio', { name: 'Small' })).toHaveProperty('disabled', false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('disables every item when the group-level disabled prop is set', () => {
|
|
54
|
+
render(<RadioGroup items={items} value="sm" onChange={() => {}} aria-label="Size" disabled />);
|
|
55
|
+
for (const radio of screen.getAllByRole('radio')) {
|
|
56
|
+
expect(radio).toHaveProperty('disabled', true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
.group {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: row;
|
|
4
|
+
width: 100%;
|
|
5
|
+
height: 100%;
|
|
6
|
+
min-height: 320px;
|
|
7
|
+
border: 1px solid var(--sv-color-border);
|
|
8
|
+
border-radius: var(--sv-radius-lg);
|
|
9
|
+
overflow: hidden;
|
|
10
|
+
background: var(--sv-color-surface);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.groupVertical {
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.panel {
|
|
18
|
+
min-width: 0;
|
|
19
|
+
min-height: 0;
|
|
20
|
+
overflow: auto;
|
|
21
|
+
background: var(--sv-color-surface);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.handle {
|
|
25
|
+
position: relative;
|
|
26
|
+
flex-shrink: 0;
|
|
27
|
+
width: 12px;
|
|
28
|
+
padding: 0;
|
|
29
|
+
border: 0;
|
|
30
|
+
border-inline: 1px solid var(--sv-color-border);
|
|
31
|
+
cursor: col-resize;
|
|
32
|
+
background: var(--sv-color-surface-sunken);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.handleVertical {
|
|
36
|
+
width: 100%;
|
|
37
|
+
height: 12px;
|
|
38
|
+
border-inline: 0;
|
|
39
|
+
border-block: 1px solid var(--sv-color-border);
|
|
40
|
+
cursor: row-resize;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.handle::before {
|
|
44
|
+
position: absolute;
|
|
45
|
+
inset-block: 50%;
|
|
46
|
+
inset-inline: 50%;
|
|
47
|
+
width: 2px;
|
|
48
|
+
height: 40px;
|
|
49
|
+
content: '';
|
|
50
|
+
background: var(--sv-color-border-strong);
|
|
51
|
+
border-radius: var(--sv-radius-full);
|
|
52
|
+
transform: translate(-50%, -50%);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.handleVertical::before {
|
|
56
|
+
width: 40px;
|
|
57
|
+
height: 2px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@media (hover: hover) {
|
|
61
|
+
.handle:hover {
|
|
62
|
+
background: var(--sv-color-accent-subtle);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.handle:focus-visible {
|
|
67
|
+
background: var(--sv-color-accent-subtle);
|
|
68
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
69
|
+
outline-offset: -2px;
|
|
70
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './Resizable';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Resizable',
|
|
6
|
+
component: ResizablePanelGroup,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'padded',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
'A row or column of resizable panes. Each handle resizes only its two immediate neighbor panels, clamped to their own min/max. Desktop-oriented — panels render at their default sizes on touch.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: { direction: 'horizontal', children: null },
|
|
17
|
+
} satisfies Meta<typeof ResizablePanelGroup>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const paneStyle: React.CSSProperties = {
|
|
25
|
+
display: 'flex',
|
|
26
|
+
alignItems: 'center',
|
|
27
|
+
justifyContent: 'center',
|
|
28
|
+
height: '100%',
|
|
29
|
+
padding: 'var(--sv-space-4)',
|
|
30
|
+
fontSize: 14,
|
|
31
|
+
color: 'var(--sv-color-text-muted)',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const Horizontal: Story = {
|
|
35
|
+
render: () => (
|
|
36
|
+
<ResizablePanelGroup direction="horizontal">
|
|
37
|
+
<ResizablePanel defaultSize={25} minSize={15} maxSize={50}>
|
|
38
|
+
<div style={paneStyle}>Sidebar</div>
|
|
39
|
+
</ResizablePanel>
|
|
40
|
+
<ResizableHandle aria-label="Resize sidebar" />
|
|
41
|
+
<ResizablePanel defaultSize={75}>
|
|
42
|
+
<div style={paneStyle}>Content</div>
|
|
43
|
+
</ResizablePanel>
|
|
44
|
+
</ResizablePanelGroup>
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const ThreePanels: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<ResizablePanelGroup direction="horizontal">
|
|
51
|
+
<ResizablePanel defaultSize={20} minSize={15} maxSize={40}>
|
|
52
|
+
<div style={paneStyle}>Files</div>
|
|
53
|
+
</ResizablePanel>
|
|
54
|
+
<ResizableHandle aria-label="Resize files panel" />
|
|
55
|
+
<ResizablePanel defaultSize={50}>
|
|
56
|
+
<div style={paneStyle}>Editor</div>
|
|
57
|
+
</ResizablePanel>
|
|
58
|
+
<ResizableHandle aria-label="Resize editor panel" />
|
|
59
|
+
<ResizablePanel defaultSize={30} minSize={15} maxSize={50}>
|
|
60
|
+
<div style={paneStyle}>Preview</div>
|
|
61
|
+
</ResizablePanel>
|
|
62
|
+
</ResizablePanelGroup>
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const Vertical: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
// Vertical orientation needs a definite-height ancestor for the
|
|
69
|
+
// percentage-based flex-basis on each panel to resolve.
|
|
70
|
+
<div style={{ height: 400 }}>
|
|
71
|
+
<ResizablePanelGroup direction="vertical">
|
|
72
|
+
<ResizablePanel defaultSize={30}>
|
|
73
|
+
<div style={paneStyle}>Console output</div>
|
|
74
|
+
</ResizablePanel>
|
|
75
|
+
<ResizableHandle aria-label="Resize console" />
|
|
76
|
+
<ResizablePanel defaultSize={70}>
|
|
77
|
+
<div style={paneStyle}>Editor</div>
|
|
78
|
+
</ResizablePanel>
|
|
79
|
+
</ResizablePanelGroup>
|
|
80
|
+
</div>
|
|
81
|
+
),
|
|
82
|
+
};
|