@boostdev/design-system-components 0.1.16 → 0.1.17
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/AGENTS.md +4 -4
- package/README.md +50 -1
- package/dist/client.cjs +202 -115
- package/dist/client.css +514 -425
- package/dist/client.d.cts +20 -1
- package/dist/client.d.ts +20 -1
- package/dist/client.js +201 -115
- package/dist/index.cjs +202 -115
- package/dist/index.css +514 -425
- package/dist/index.d.cts +20 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +201 -115
- package/package.json +1 -1
- package/src/components/interaction/Command/Command.mdx +1 -0
- package/src/components/interaction/Command/Command.spec.tsx +18 -0
- package/src/components/interaction/Command/Command.tsx +5 -0
- package/src/components/interaction/Dialog/Dialog.spec.tsx +18 -0
- package/src/components/interaction/Drawer/Drawer.spec.tsx +18 -0
- package/src/components/interaction/Drawer/Drawer.tsx +5 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.mdx +64 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.module.css +99 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.spec.tsx +87 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.stories.tsx +110 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.tsx +89 -0
- package/src/components/interaction/form/SegmentedControl/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/stories/Introduction.mdx +4 -3
package/package.json
CHANGED
|
@@ -26,3 +26,4 @@ Keyboard-first command palette for searching and executing actions. Commonly tri
|
|
|
26
26
|
- Results list uses `role="listbox"` with `role="option"` items
|
|
27
27
|
- `aria-activedescendant` tracks the highlighted option
|
|
28
28
|
- Keyboard: `↑`/`↓` to navigate, `Enter` to execute, `Escape` to dismiss
|
|
29
|
+
- Body scroll is locked while open (`overflow: hidden`) and restored on close or unmount
|
|
@@ -10,6 +10,7 @@ beforeEach(() => {
|
|
|
10
10
|
HTMLDialogElement.prototype.close = vi.fn().mockImplementation(function (this: HTMLDialogElement) {
|
|
11
11
|
this.removeAttribute('open');
|
|
12
12
|
});
|
|
13
|
+
document.body.style.overflow = '';
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
const items: CommandItem[] = [
|
|
@@ -57,4 +58,21 @@ describe('Command', () => {
|
|
|
57
58
|
render(<Command isOpen items={items} onClose={() => {}} />);
|
|
58
59
|
expect(screen.getByText('Save current file')).toBeInTheDocument();
|
|
59
60
|
});
|
|
61
|
+
|
|
62
|
+
it('locks body scroll when open', () => {
|
|
63
|
+
render(<Command isOpen items={items} onClose={() => {}} />);
|
|
64
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('restores body scroll when closed', () => {
|
|
68
|
+
const { rerender } = render(<Command isOpen items={items} onClose={() => {}} />);
|
|
69
|
+
rerender(<Command isOpen={false} items={items} onClose={() => {}} />);
|
|
70
|
+
expect(document.body.style.overflow).toBe('');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('restores body scroll on unmount', () => {
|
|
74
|
+
const { unmount } = render(<Command isOpen items={items} onClose={() => {}} />);
|
|
75
|
+
unmount();
|
|
76
|
+
expect(document.body.style.overflow).toBe('');
|
|
77
|
+
});
|
|
60
78
|
});
|
|
@@ -56,12 +56,17 @@ export function Command({
|
|
|
56
56
|
if (!dialog) return;
|
|
57
57
|
if (isOpen) {
|
|
58
58
|
dialog.showModal();
|
|
59
|
+
document.body.style.overflow = 'hidden';
|
|
59
60
|
setQuery('');
|
|
60
61
|
setActiveIndex(0);
|
|
61
62
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
62
63
|
} else if (dialog.open) {
|
|
63
64
|
dialog.close();
|
|
65
|
+
document.body.style.overflow = '';
|
|
64
66
|
}
|
|
67
|
+
return () => {
|
|
68
|
+
document.body.style.overflow = '';
|
|
69
|
+
};
|
|
65
70
|
}, [isOpen]);
|
|
66
71
|
|
|
67
72
|
useEffect(() => {
|
|
@@ -10,6 +10,7 @@ beforeEach(() => {
|
|
|
10
10
|
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
|
|
11
11
|
this.removeAttribute('open');
|
|
12
12
|
});
|
|
13
|
+
document.body.style.overflow = '';
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
describe('Dialog', () => {
|
|
@@ -40,4 +41,21 @@ describe('Dialog', () => {
|
|
|
40
41
|
render(<Dialog isOpen={false}><span>Content</span></Dialog>);
|
|
41
42
|
expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
|
|
42
43
|
});
|
|
44
|
+
|
|
45
|
+
it('locks body scroll when open', () => {
|
|
46
|
+
render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
47
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('restores body scroll when closed', () => {
|
|
51
|
+
const { rerender } = render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
52
|
+
rerender(<Dialog isOpen={false}><span>Content</span></Dialog>);
|
|
53
|
+
expect(document.body.style.overflow).toBe('');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('restores body scroll on unmount', () => {
|
|
57
|
+
const { unmount } = render(<Dialog isOpen><span>Content</span></Dialog>);
|
|
58
|
+
unmount();
|
|
59
|
+
expect(document.body.style.overflow).toBe('');
|
|
60
|
+
});
|
|
43
61
|
});
|
|
@@ -10,6 +10,7 @@ beforeEach(() => {
|
|
|
10
10
|
HTMLDialogElement.prototype.close = vi.fn().mockImplementation(function (this: HTMLDialogElement) {
|
|
11
11
|
this.removeAttribute('open');
|
|
12
12
|
});
|
|
13
|
+
document.body.style.overflow = '';
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
describe('Drawer', () => {
|
|
@@ -40,4 +41,21 @@ describe('Drawer', () => {
|
|
|
40
41
|
rerender(<Drawer isOpen={false} title="Test" onClose={() => {}}>Body</Drawer>);
|
|
41
42
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalled();
|
|
42
43
|
});
|
|
44
|
+
|
|
45
|
+
it('locks body scroll when open', () => {
|
|
46
|
+
render(<Drawer isOpen title="Settings" onClose={() => {}}>Content</Drawer>);
|
|
47
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('restores body scroll when closed', () => {
|
|
51
|
+
const { rerender } = render(<Drawer isOpen title="Test" onClose={() => {}}>Body</Drawer>);
|
|
52
|
+
rerender(<Drawer isOpen={false} title="Test" onClose={() => {}}>Body</Drawer>);
|
|
53
|
+
expect(document.body.style.overflow).toBe('');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('restores body scroll on unmount', () => {
|
|
57
|
+
const { unmount } = render(<Drawer isOpen title="Test" onClose={() => {}}>Body</Drawer>);
|
|
58
|
+
unmount();
|
|
59
|
+
expect(document.body.style.overflow).toBe('');
|
|
60
|
+
});
|
|
43
61
|
});
|
|
@@ -28,9 +28,14 @@ export function Drawer({
|
|
|
28
28
|
if (!dialog) return;
|
|
29
29
|
if (isOpen) {
|
|
30
30
|
dialog.showModal();
|
|
31
|
+
document.body.style.overflow = 'hidden';
|
|
31
32
|
} else if (dialog.open) {
|
|
32
33
|
dialog.close();
|
|
34
|
+
document.body.style.overflow = '';
|
|
33
35
|
}
|
|
36
|
+
return () => {
|
|
37
|
+
document.body.style.overflow = '';
|
|
38
|
+
};
|
|
34
39
|
}, [isOpen]);
|
|
35
40
|
|
|
36
41
|
// Close on backdrop click
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
|
|
2
|
+
import * as Stories from './SegmentedControl.stories';
|
|
3
|
+
|
|
4
|
+
<Meta of={Stories} />
|
|
5
|
+
|
|
6
|
+
# SegmentedControl
|
|
7
|
+
|
|
8
|
+
Single-select control where all options sit inside a shared track and a sliding thumb highlights the active choice. Based on radio inputs.
|
|
9
|
+
|
|
10
|
+
All options have **equal width** — the widest label determines the size for all. In the `large` variant, labels may wrap.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
- Switching between a small set of mutually exclusive views or modes (2–5 options)
|
|
14
|
+
- When all options should be visible simultaneously with clear visual feedback
|
|
15
|
+
|
|
16
|
+
## When not to use
|
|
17
|
+
- More than 5 options — use `Select` or `Tabs`
|
|
18
|
+
- Independent toggles — use `Switch` or `Checkbox`
|
|
19
|
+
|
|
20
|
+
## Examples
|
|
21
|
+
|
|
22
|
+
### Default
|
|
23
|
+
<Canvas of={Stories.Default} />
|
|
24
|
+
|
|
25
|
+
### Small
|
|
26
|
+
<Canvas of={Stories.Small} />
|
|
27
|
+
|
|
28
|
+
### Large (wrapping labels)
|
|
29
|
+
<Canvas of={Stories.WrappingLabels} />
|
|
30
|
+
|
|
31
|
+
### With disabled option
|
|
32
|
+
<Canvas of={Stories.WithDisabledOption} />
|
|
33
|
+
|
|
34
|
+
### All disabled
|
|
35
|
+
<Canvas of={Stories.AllDisabled} />
|
|
36
|
+
|
|
37
|
+
### Controlled
|
|
38
|
+
<Canvas of={Stories.Controlled} />
|
|
39
|
+
|
|
40
|
+
## Props
|
|
41
|
+
|
|
42
|
+
<ArgTypes of={Stories} />
|
|
43
|
+
|
|
44
|
+
## CSS variables
|
|
45
|
+
|
|
46
|
+
<table>
|
|
47
|
+
<thead>
|
|
48
|
+
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
|
|
49
|
+
</thead>
|
|
50
|
+
<tbody>
|
|
51
|
+
<tr><td>`--control_track-bg`</td><td>`var(--color_bg--subtle)`</td><td>Track background</td></tr>
|
|
52
|
+
<tr><td>`--control_thumb-bg`</td><td>`var(--color_bg)`</td><td>Thumb (active indicator) background</td></tr>
|
|
53
|
+
<tr><td>`--control_label-color`</td><td>`var(--color_on-bg--subtle)`</td><td>Unselected label colour</td></tr>
|
|
54
|
+
<tr><td>`--control_label-color--active`</td><td>`var(--color_on-bg)`</td><td>Selected label colour</td></tr>
|
|
55
|
+
<tr><td>`--control_label-color--hover`</td><td>`var(--color_on-bg)`</td><td>Hovered (unselected) label colour</td></tr>
|
|
56
|
+
</tbody>
|
|
57
|
+
</table>
|
|
58
|
+
|
|
59
|
+
## Accessibility
|
|
60
|
+
|
|
61
|
+
- Each option is a native `<input type="radio">` — grouped by `name`, keyboard navigable with arrow keys
|
|
62
|
+
- The visual thumb is `aria-hidden` and purely decorative
|
|
63
|
+
- Labels are `<label>` elements with `htmlFor` pointing to each radio
|
|
64
|
+
- Disabled options remain focusable for screen reader discovery but cannot be activated
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
@layer component {
|
|
2
|
+
.control {
|
|
3
|
+
position: relative;
|
|
4
|
+
display: inline-grid;
|
|
5
|
+
grid-auto-flow: column;
|
|
6
|
+
grid-auto-columns: 1fr;
|
|
7
|
+
padding: var(--space_xxxs);
|
|
8
|
+
background-color: var(--control_track-bg, var(--color_bg--subtle));
|
|
9
|
+
border-radius: var(--border_radius--m);
|
|
10
|
+
gap: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.thumb {
|
|
14
|
+
position: absolute;
|
|
15
|
+
top: var(--space_xxxs);
|
|
16
|
+
bottom: var(--space_xxxs);
|
|
17
|
+
left: var(--space_xxxs);
|
|
18
|
+
width: calc((100% - 2 * var(--space_xxxs)) / var(--control_count, 1));
|
|
19
|
+
border-radius: var(--border_radius--s);
|
|
20
|
+
background-color: var(--control_thumb-bg, var(--color_bg));
|
|
21
|
+
box-shadow: var(--shadow_s);
|
|
22
|
+
pointer-events: none;
|
|
23
|
+
transform: translateX(calc(var(--control_selected-index, 0) * 100%));
|
|
24
|
+
transition: transform var(--animation_transition-duration) var(--animation_easing);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.item {
|
|
28
|
+
position: relative;
|
|
29
|
+
z-index: 1;
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
justify-content: center;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
border-radius: var(--border_radius--s);
|
|
35
|
+
transition: var(--animation_transition);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.item.--disabled {
|
|
39
|
+
opacity: 0.4;
|
|
40
|
+
cursor: not-allowed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.radio {
|
|
44
|
+
position: absolute;
|
|
45
|
+
opacity: 0;
|
|
46
|
+
width: 0;
|
|
47
|
+
height: 0;
|
|
48
|
+
margin: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.label {
|
|
52
|
+
display: block;
|
|
53
|
+
padding: var(--space_xs) var(--space_m);
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
font-size: var(--font_size--body);
|
|
56
|
+
line-height: var(--font_line-height--body);
|
|
57
|
+
color: var(--control_label-color, var(--color_on-bg--subtle));
|
|
58
|
+
transition: color var(--animation_transition-duration) var(--animation_easing);
|
|
59
|
+
pointer-events: none;
|
|
60
|
+
user-select: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.control.--size_small .label {
|
|
64
|
+
padding: var(--space_xxs) var(--space_s);
|
|
65
|
+
font-size: var(--font_size--body--s);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.control.--size_large .label {
|
|
69
|
+
padding: var(--space_s) var(--space_m);
|
|
70
|
+
white-space: normal;
|
|
71
|
+
text-align: center;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.item.--active .label {
|
|
75
|
+
color: var(--control_label-color--active, var(--color_on-bg));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.radio:focus-visible ~ .label {
|
|
79
|
+
outline: var(--outline_default);
|
|
80
|
+
outline-offset: calc(var(--outline_offset) * -1);
|
|
81
|
+
border-radius: var(--border_radius--s);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@media (hover: hover) and (pointer: fine) {
|
|
85
|
+
.item:not(.--active, .--disabled):hover .label {
|
|
86
|
+
color: var(--control_label-color--hover, var(--color_on-bg));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@media (prefers-reduced-motion: reduce) {
|
|
91
|
+
.thumb {
|
|
92
|
+
transition: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.label {
|
|
96
|
+
transition: none;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { SegmentedControl } from './SegmentedControl';
|
|
4
|
+
|
|
5
|
+
const options = [
|
|
6
|
+
{ value: 'day', label: 'Day' },
|
|
7
|
+
{ value: 'week', label: 'Week' },
|
|
8
|
+
{ value: 'month', label: 'Month' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('SegmentedControl', () => {
|
|
12
|
+
it('renders all option labels', () => {
|
|
13
|
+
render(<SegmentedControl name="view" options={options} />);
|
|
14
|
+
expect(screen.getByText('Day')).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText('Week')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('Month')).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('selects the first option by default', () => {
|
|
20
|
+
render(<SegmentedControl name="view" options={options} />);
|
|
21
|
+
expect(screen.getByRole('radio', { name: 'Day' })).toBeChecked();
|
|
22
|
+
expect(screen.getByRole('radio', { name: 'Week' })).not.toBeChecked();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('selects defaultValue when provided', () => {
|
|
26
|
+
render(<SegmentedControl name="view" options={options} defaultValue="week" />);
|
|
27
|
+
expect(screen.getByRole('radio', { name: 'Week' })).toBeChecked();
|
|
28
|
+
expect(screen.getByRole('radio', { name: 'Day' })).not.toBeChecked();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('selects an option on click', async () => {
|
|
32
|
+
const user = userEvent.setup();
|
|
33
|
+
render(<SegmentedControl name="view" options={options} />);
|
|
34
|
+
await user.click(screen.getByRole('radio', { name: 'Month' }));
|
|
35
|
+
expect(screen.getByRole('radio', { name: 'Month' })).toBeChecked();
|
|
36
|
+
expect(screen.getByRole('radio', { name: 'Day' })).not.toBeChecked();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('calls onChange with the selected value', async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
const onChange = vi.fn();
|
|
42
|
+
render(<SegmentedControl name="view" options={options} onChange={onChange} />);
|
|
43
|
+
await user.click(screen.getByRole('radio', { name: 'Week' }));
|
|
44
|
+
expect(onChange).toHaveBeenCalledWith('week');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('respects controlled value', () => {
|
|
48
|
+
render(<SegmentedControl name="view" options={options} value="month" onChange={() => {}} />);
|
|
49
|
+
expect(screen.getByRole('radio', { name: 'Month' })).toBeChecked();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('disables a specific option', () => {
|
|
53
|
+
const opts = [
|
|
54
|
+
{ value: 'a', label: 'A' },
|
|
55
|
+
{ value: 'b', label: 'B', disabled: true },
|
|
56
|
+
];
|
|
57
|
+
render(<SegmentedControl name="opt" options={opts} />);
|
|
58
|
+
expect(screen.getByRole('radio', { name: 'B' })).toBeDisabled();
|
|
59
|
+
expect(screen.getByRole('radio', { name: 'A' })).not.toBeDisabled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('disables all options when disabled prop is set', () => {
|
|
63
|
+
render(<SegmentedControl name="view" options={options} disabled />);
|
|
64
|
+
screen.getAllByRole('radio').forEach(radio => expect(radio).toBeDisabled());
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('sets --control_count on the container', () => {
|
|
68
|
+
const { container } = render(<SegmentedControl name="view" options={options} />);
|
|
69
|
+
const control = container.firstChild as HTMLElement;
|
|
70
|
+
expect(control.style.getPropertyValue('--control_count')).toBe('3');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('sets --control_selected-index to the selected option index', async () => {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
const { container } = render(<SegmentedControl name="view" options={options} defaultValue="day" />);
|
|
76
|
+
const control = container.firstChild as HTMLElement;
|
|
77
|
+
expect(control.style.getPropertyValue('--control_selected-index')).toBe('0');
|
|
78
|
+
await user.click(screen.getByRole('radio', { name: 'Month' }));
|
|
79
|
+
expect(control.style.getPropertyValue('--control_selected-index')).toBe('2');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('renders all radios in the same name group', () => {
|
|
83
|
+
render(<SegmentedControl name="view" options={options} />);
|
|
84
|
+
const radios = screen.getAllByRole('radio');
|
|
85
|
+
radios.forEach(r => expect(r).toHaveAttribute('name', expect.stringContaining('view')));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { SegmentedControl } from './SegmentedControl';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Form/SegmentedControl',
|
|
7
|
+
component: SegmentedControl,
|
|
8
|
+
argTypes: {
|
|
9
|
+
size: { control: 'radio', options: ['small', 'medium', 'large'] },
|
|
10
|
+
},
|
|
11
|
+
} satisfies Meta<typeof SegmentedControl>;
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {
|
|
17
|
+
args: {
|
|
18
|
+
name: 'view',
|
|
19
|
+
options: [
|
|
20
|
+
{ value: 'day', label: 'Day' },
|
|
21
|
+
{ value: 'week', label: 'Week' },
|
|
22
|
+
{ value: 'month', label: 'Month' },
|
|
23
|
+
],
|
|
24
|
+
defaultValue: 'week',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Small: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
name: 'size-s',
|
|
31
|
+
size: 'small',
|
|
32
|
+
options: [
|
|
33
|
+
{ value: 'list', label: 'List' },
|
|
34
|
+
{ value: 'grid', label: 'Grid' },
|
|
35
|
+
],
|
|
36
|
+
defaultValue: 'list',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const Large: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
name: 'size-l',
|
|
43
|
+
size: 'large',
|
|
44
|
+
options: [
|
|
45
|
+
{ value: 'personal', label: 'Personal plan' },
|
|
46
|
+
{ value: 'team', label: 'Team plan' },
|
|
47
|
+
{ value: 'enterprise', label: 'Enterprise plan' },
|
|
48
|
+
],
|
|
49
|
+
defaultValue: 'team',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WrappingLabels: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
name: 'wrap',
|
|
56
|
+
size: 'large',
|
|
57
|
+
options: [
|
|
58
|
+
{ value: 'solar', label: 'Solar energy only' },
|
|
59
|
+
{ value: 'wind', label: 'Wind energy only' },
|
|
60
|
+
{ value: 'mixed', label: 'Mix of sustainable sources' },
|
|
61
|
+
],
|
|
62
|
+
defaultValue: 'mixed',
|
|
63
|
+
},
|
|
64
|
+
decorators: [Story => <div style={{ maxWidth: '360px' }}><Story /></div>],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const WithDisabledOption: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
name: 'disabled-opt',
|
|
70
|
+
options: [
|
|
71
|
+
{ value: 'active', label: 'Active' },
|
|
72
|
+
{ value: 'paused', label: 'Paused' },
|
|
73
|
+
{ value: 'archived', label: 'Archived', disabled: true },
|
|
74
|
+
],
|
|
75
|
+
defaultValue: 'active',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const AllDisabled: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
name: 'all-disabled',
|
|
82
|
+
options: [
|
|
83
|
+
{ value: 'a', label: 'Option A' },
|
|
84
|
+
{ value: 'b', label: 'Option B' },
|
|
85
|
+
],
|
|
86
|
+
defaultValue: 'a',
|
|
87
|
+
disabled: true,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const Controlled: Story = {
|
|
92
|
+
render: () => {
|
|
93
|
+
const [value, setValue] = useState('month');
|
|
94
|
+
return (
|
|
95
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
|
|
96
|
+
<SegmentedControl
|
|
97
|
+
name="controlled"
|
|
98
|
+
value={value}
|
|
99
|
+
onChange={setValue}
|
|
100
|
+
options={[
|
|
101
|
+
{ value: 'day', label: 'Day' },
|
|
102
|
+
{ value: 'week', label: 'Week' },
|
|
103
|
+
{ value: 'month', label: 'Month' },
|
|
104
|
+
]}
|
|
105
|
+
/>
|
|
106
|
+
<span style={{ fontSize: '0.875rem', opacity: 0.6 }}>Selected: {value}</span>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { InputHTMLAttributes, ReactNode, useId, useState } from 'react';
|
|
2
|
+
import css from './SegmentedControl.module.css';
|
|
3
|
+
import { cn } from '@boostdev/design-system-foundation';
|
|
4
|
+
|
|
5
|
+
export interface SegmentedControlOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SegmentedControlProps
|
|
12
|
+
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'size'> {
|
|
13
|
+
name: string;
|
|
14
|
+
options: SegmentedControlOption[];
|
|
15
|
+
/** Controlled selected value */
|
|
16
|
+
value?: string;
|
|
17
|
+
/** Uncontrolled initial value (defaults to first option) */
|
|
18
|
+
defaultValue?: string;
|
|
19
|
+
onChange?: (value: string) => void;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
size?: 'small' | 'medium' | 'large';
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SegmentedControl({
|
|
26
|
+
name,
|
|
27
|
+
options,
|
|
28
|
+
value,
|
|
29
|
+
defaultValue,
|
|
30
|
+
onChange,
|
|
31
|
+
disabled,
|
|
32
|
+
size = 'medium',
|
|
33
|
+
className,
|
|
34
|
+
...rest
|
|
35
|
+
}: Readonly<SegmentedControlProps>) {
|
|
36
|
+
const baseId = name + useId();
|
|
37
|
+
|
|
38
|
+
const [internalValue, setInternalValue] = useState<string>(
|
|
39
|
+
defaultValue ?? options[0]?.value ?? '',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const activeValue = value ?? internalValue;
|
|
43
|
+
const selectedIndex = options.findIndex(o => o.value === activeValue);
|
|
44
|
+
|
|
45
|
+
const handleChange = (optionValue: string) => {
|
|
46
|
+
if (value === undefined) setInternalValue(optionValue);
|
|
47
|
+
onChange?.(optionValue);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={cn(css.control, css[`--size_${size}`], className)}
|
|
53
|
+
style={{
|
|
54
|
+
'--control_count': options.length,
|
|
55
|
+
'--control_selected-index': Math.max(0, selectedIndex),
|
|
56
|
+
} as React.CSSProperties}
|
|
57
|
+
>
|
|
58
|
+
{/* Sliding thumb — purely visual */}
|
|
59
|
+
<span className={css.thumb} aria-hidden="true" />
|
|
60
|
+
|
|
61
|
+
{options.map((option, index) => {
|
|
62
|
+
const id = `${baseId}-${index}`;
|
|
63
|
+
const isChecked = option.value === activeValue;
|
|
64
|
+
const isDisabled = disabled || option.disabled;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<label
|
|
68
|
+
key={option.value}
|
|
69
|
+
htmlFor={id}
|
|
70
|
+
className={cn(css.item, isChecked && css['--active'], isDisabled && css['--disabled'])}
|
|
71
|
+
>
|
|
72
|
+
<input
|
|
73
|
+
{...rest}
|
|
74
|
+
type="radio"
|
|
75
|
+
id={id}
|
|
76
|
+
name={name}
|
|
77
|
+
value={option.value}
|
|
78
|
+
checked={isChecked}
|
|
79
|
+
disabled={isDisabled}
|
|
80
|
+
className={css.radio}
|
|
81
|
+
onChange={() => handleChange(option.value)}
|
|
82
|
+
/>
|
|
83
|
+
<span className={css.label}>{option.label}</span>
|
|
84
|
+
</label>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,8 @@ export { FileInput } from './components/interaction/form/FileInput';
|
|
|
48
48
|
export { FormInput } from './components/interaction/form/FormInput';
|
|
49
49
|
export { NumberInput } from './components/interaction/form/NumberInput';
|
|
50
50
|
export { Radio } from './components/interaction/form/Radio';
|
|
51
|
+
export { SegmentedControl } from './components/interaction/form/SegmentedControl';
|
|
52
|
+
export type { SegmentedControlProps, SegmentedControlOption } from './components/interaction/form/SegmentedControl';
|
|
51
53
|
export { Select } from './components/interaction/form/Select';
|
|
52
54
|
export type { SelectOption } from './components/interaction/form/Select';
|
|
53
55
|
export { Slider } from './components/interaction/form/Slider';
|
|
@@ -55,8 +55,8 @@ import {Card} from "../components/layout/Card/index.ts";
|
|
|
55
55
|
<strong>UI</strong>
|
|
56
56
|
<div className="cardContent">
|
|
57
57
|
Badge · Typography · Alert · Avatar · Loading · Skeleton · Separator · Tooltip · Progress ·
|
|
58
|
-
ProgressCircle · Tabs · Breadcrumb · Accordion · Pagination · Link · Table ·
|
|
59
|
-
NotificationBanner · SkipLink · Carousel · Calendar
|
|
58
|
+
ProgressCircle · Tabs · Breadcrumb · Accordion · Collapsible · Pagination · Link · Table ·
|
|
59
|
+
DescriptionList · NotificationBanner · SkipLink · Carousel · Calendar
|
|
60
60
|
</div>
|
|
61
61
|
</Card>
|
|
62
62
|
|
|
@@ -70,7 +70,8 @@ import {Card} from "../components/layout/Card/index.ts";
|
|
|
70
70
|
<Card className="card">
|
|
71
71
|
<strong>Form</strong>
|
|
72
72
|
<div className="cardContent">
|
|
73
|
-
FormInput · Checkbox · Radio · Switch · Select · Textarea · Slider ·
|
|
73
|
+
FormInput · Checkbox · Radio · Switch · SegmentedControl · Select · Textarea · Slider ·
|
|
74
|
+
NumberInput · FileInput · Combobox
|
|
74
75
|
</div>
|
|
75
76
|
</Card>
|
|
76
77
|
|