@fragments-sdk/ui 0.7.4 → 0.8.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/README.md +58 -25
- package/fragments.json +1 -1
- package/package.json +22 -5
- package/src/blocks/AppShell.block.ts +2 -2
- package/src/blocks/InsetDashboardLayout.block.ts +1 -1
- package/src/blocks/LoginForm.block.ts +14 -7
- package/src/components/Accordion/Accordion.fragment.tsx +8 -2
- package/src/components/Accordion/Accordion.test.tsx +171 -0
- package/src/components/Alert/Alert.module.scss +4 -4
- package/src/components/Alert/Alert.test.tsx +127 -0
- package/src/components/AppShell/AppShell.fragment.tsx +1 -1
- package/src/components/AppShell/AppShell.test.tsx +80 -0
- package/src/components/AppShell/index.tsx +2 -0
- package/src/components/Avatar/Avatar.fragment.tsx +5 -1
- package/src/components/Avatar/Avatar.module.scss +1 -1
- package/src/components/Avatar/Avatar.test.tsx +40 -0
- package/src/components/Avatar/index.tsx +37 -1
- package/src/components/Badge/Badge.fragment.tsx +3 -3
- package/src/components/Badge/Badge.module.scss +4 -4
- package/src/components/Badge/Badge.test.tsx +58 -0
- package/src/components/Badge/index.tsx +5 -1
- package/src/components/Box/Box.test.tsx +43 -0
- package/src/components/Box/index.tsx +5 -1
- package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
- package/src/components/Button/Button.fragment.tsx +17 -16
- package/src/components/Button/Button.test.tsx +53 -0
- package/src/components/Button/index.tsx +5 -1
- package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
- package/src/components/ButtonGroup/index.tsx +5 -1
- package/src/components/Card/Card.fragment.tsx +5 -5
- package/src/components/Card/Card.test.tsx +71 -0
- package/src/components/Chart/Chart.fragment.tsx +9 -1
- package/src/components/Chart/Chart.test.tsx +123 -0
- package/src/components/Chart/index.tsx +22 -4
- package/src/components/Checkbox/Checkbox.test.tsx +63 -0
- package/src/components/Checkbox/index.tsx +5 -1
- package/src/components/Chip/Chip.fragment.tsx +0 -5
- package/src/components/Chip/Chip.module.scss +55 -2
- package/src/components/Chip/Chip.test.tsx +50 -0
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +9 -3
- package/src/components/CodeBlock/CodeBlock.module.scss +1 -1
- package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
- package/src/components/Collapsible/Collapsible.test.tsx +103 -0
- package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
- package/src/components/ColorPicker/index.tsx +9 -2
- package/src/components/Combobox/Combobox.fragment.tsx +15 -7
- package/src/components/Combobox/Combobox.test.tsx +202 -0
- package/src/components/ConversationList/ConversationList.fragment.tsx +3 -3
- package/src/components/ConversationList/ConversationList.module.scss +1 -1
- package/src/components/ConversationList/ConversationList.test.tsx +79 -0
- package/src/components/DatePicker/DatePicker.fragment.tsx +245 -0
- package/src/components/DatePicker/DatePicker.module.scss +394 -0
- package/src/components/DatePicker/DatePicker.test.tsx +264 -0
- package/src/components/DatePicker/index.tsx +535 -0
- package/src/components/Dialog/Dialog.test.tsx +277 -0
- package/src/components/EmptyState/EmptyState.test.tsx +67 -0
- package/src/components/Field/Field.fragment.tsx +5 -4
- package/src/components/Field/Field.test.tsx +65 -0
- package/src/components/Fieldset/Fieldset.fragment.tsx +5 -4
- package/src/components/Fieldset/Fieldset.test.tsx +48 -0
- package/src/components/Form/Form.fragment.tsx +9 -3
- package/src/components/Form/Form.test.tsx +41 -0
- package/src/components/Form/index.tsx +5 -1
- package/src/components/Grid/Grid.fragment.tsx +4 -0
- package/src/components/Grid/Grid.test.tsx +65 -0
- package/src/components/Header/Header.fragment.tsx +36 -13
- package/src/components/Header/Header.module.scss +114 -1
- package/src/components/Header/Header.test.tsx +188 -0
- package/src/components/Header/index.tsx +100 -31
- package/src/components/Icon/Icon.fragment.tsx +6 -1
- package/src/components/Icon/Icon.test.tsx +38 -0
- package/src/components/Icon/index.tsx +5 -1
- package/src/components/Image/Image.fragment.tsx +2 -2
- package/src/components/Image/Image.test.tsx +39 -0
- package/src/components/Image/index.tsx +5 -1
- package/src/components/Input/Input.fragment.tsx +21 -3
- package/src/components/Input/Input.module.scss +1 -1
- package/src/components/Input/Input.test.tsx +72 -0
- package/src/components/Input/index.tsx +5 -1
- package/src/components/Link/Link.fragment.tsx +0 -4
- package/src/components/Link/Link.test.tsx +37 -0
- package/src/components/Link/index.tsx +5 -1
- package/src/components/List/List.test.tsx +57 -0
- package/src/components/Listbox/Listbox.fragment.tsx +0 -12
- package/src/components/Listbox/Listbox.module.scss +2 -1
- package/src/components/Listbox/Listbox.test.tsx +100 -0
- package/src/components/Listbox/index.tsx +26 -3
- package/src/components/Loading/Loading.test.tsx +38 -0
- package/src/components/Markdown/Markdown.module.scss +6 -3
- package/src/components/Markdown/Markdown.test.tsx +41 -0
- package/src/components/Markdown/index.tsx +5 -1
- package/src/components/Menu/Menu.test.tsx +336 -0
- package/src/components/Message/Message.fragment.tsx +8 -6
- package/src/components/Message/Message.module.scss +1 -1
- package/src/components/Message/Message.test.tsx +75 -0
- package/src/components/Popover/Popover.test.tsx +105 -0
- package/src/components/Progress/Progress.fragment.tsx +14 -0
- package/src/components/Progress/Progress.test.tsx +58 -0
- package/src/components/Progress/index.tsx +9 -2
- package/src/components/Prompt/Prompt.fragment.tsx +11 -0
- package/src/components/Prompt/Prompt.test.tsx +89 -0
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +5 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
- package/src/components/ScrollArea/ScrollArea.fragment.tsx +185 -0
- package/src/components/ScrollArea/ScrollArea.module.scss +136 -0
- package/src/components/ScrollArea/ScrollArea.test.tsx +38 -0
- package/src/components/ScrollArea/index.tsx +121 -0
- package/src/components/Select/Select.fragment.tsx +13 -5
- package/src/components/Select/Select.test.tsx +161 -0
- package/src/components/Separator/Separator.test.tsx +33 -0
- package/src/components/Separator/index.tsx +5 -1
- package/src/components/Sidebar/Sidebar.fragment.tsx +64 -11
- package/src/components/Sidebar/Sidebar.module.scss +68 -16
- package/src/components/Sidebar/Sidebar.test.tsx +114 -0
- package/src/components/Sidebar/index.tsx +69 -45
- package/src/components/Skeleton/Skeleton.fragment.tsx +5 -0
- package/src/components/Skeleton/Skeleton.test.tsx +56 -0
- package/src/components/Slider/Slider.test.tsx +51 -0
- package/src/components/Slider/index.tsx +5 -1
- package/src/components/Stack/Stack.fragment.tsx +2 -2
- package/src/components/Stack/Stack.test.tsx +47 -0
- package/src/components/Stack/index.tsx +5 -1
- package/src/components/Table/Table.fragment.tsx +29 -0
- package/src/components/Table/Table.test.tsx +129 -0
- package/src/components/Table/index.tsx +6 -1
- package/src/components/TableOfContents/TableOfContents.fragment.tsx +149 -0
- package/src/components/TableOfContents/TableOfContents.module.scss +71 -0
- package/src/components/TableOfContents/TableOfContents.test.tsx +126 -0
- package/src/components/TableOfContents/index.tsx +105 -0
- package/src/components/Tabs/Tabs.test.tsx +180 -0
- package/src/components/Text/Text.test.tsx +40 -0
- package/src/components/Text/index.tsx +5 -1
- package/src/components/Textarea/Textarea.fragment.tsx +8 -0
- package/src/components/Textarea/Textarea.test.tsx +57 -0
- package/src/components/Textarea/index.tsx +5 -1
- package/src/components/Theme/Theme.test.tsx +114 -0
- package/src/components/Theme/index.tsx +7 -0
- package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +3 -2
- package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
- package/src/components/Toast/Toast.fragment.tsx +12 -0
- package/src/components/Toast/Toast.test.tsx +192 -0
- package/src/components/Toast/index.tsx +14 -4
- package/src/components/Toggle/Toggle.test.tsx +49 -0
- package/src/components/Toggle/index.tsx +5 -1
- package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
- package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
- package/src/components/ToggleGroup/index.tsx +17 -2
- package/src/components/Tooltip/Tooltip.fragment.tsx +18 -0
- package/src/components/Tooltip/Tooltip.test.tsx +107 -0
- package/src/components/Tooltip/index.tsx +6 -1
- package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/components/VisuallyHidden/index.tsx +5 -1
- package/src/components/compound-pattern.test.ts +40 -0
- package/src/index.ts +29 -0
- package/src/recipes/AppShell.recipe.ts +2 -2
- package/src/recipes/LoginForm.recipe.ts +14 -7
- package/src/test/setup.ts +74 -0
- package/src/test/utils.tsx +71 -0
- package/src/tokens/_computed.scss +12 -0
- package/src/tokens/_derive.scss +71 -0
- package/src/tokens/_variables.scss +22 -0
- package/src/utils/a11y.test.tsx +79 -0
|
@@ -37,6 +37,11 @@ export default defineSegment({
|
|
|
37
37
|
},
|
|
38
38
|
|
|
39
39
|
props: {
|
|
40
|
+
children: {
|
|
41
|
+
type: 'node',
|
|
42
|
+
description: 'Prompt composition using Prompt sub-components',
|
|
43
|
+
required: true,
|
|
44
|
+
},
|
|
40
45
|
value: {
|
|
41
46
|
type: 'string',
|
|
42
47
|
description: 'Controlled input value',
|
|
@@ -88,6 +93,12 @@ export default defineSegment({
|
|
|
88
93
|
default: 'true',
|
|
89
94
|
description: 'Submit on Enter (Shift+Enter for newline)',
|
|
90
95
|
},
|
|
96
|
+
variant: {
|
|
97
|
+
type: 'enum',
|
|
98
|
+
values: ['default', 'fixed', 'sticky'],
|
|
99
|
+
default: 'default',
|
|
100
|
+
description: 'Visual/positioning variant',
|
|
101
|
+
},
|
|
91
102
|
},
|
|
92
103
|
|
|
93
104
|
relations: [
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Prompt } from './index';
|
|
4
|
+
|
|
5
|
+
function renderPrompt(props: {
|
|
6
|
+
onSubmit?: (v: string) => void;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
defaultValue?: string;
|
|
10
|
+
} = {}) {
|
|
11
|
+
return render(
|
|
12
|
+
<Prompt
|
|
13
|
+
placeholder={props.placeholder ?? 'Ask something...'}
|
|
14
|
+
onSubmit={props.onSubmit}
|
|
15
|
+
disabled={props.disabled}
|
|
16
|
+
defaultValue={props.defaultValue}
|
|
17
|
+
>
|
|
18
|
+
<Prompt.Textarea />
|
|
19
|
+
<Prompt.Toolbar>
|
|
20
|
+
<Prompt.Actions>
|
|
21
|
+
<Prompt.ActionButton aria-label="Attach file">Attach</Prompt.ActionButton>
|
|
22
|
+
</Prompt.Actions>
|
|
23
|
+
</Prompt.Toolbar>
|
|
24
|
+
<Prompt.Submit />
|
|
25
|
+
</Prompt>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('Prompt', () => {
|
|
30
|
+
it('renders a textarea with placeholder', () => {
|
|
31
|
+
renderPrompt({ placeholder: 'Type here...' });
|
|
32
|
+
expect(screen.getByPlaceholderText('Type here...')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('renders compound sub-components (Toolbar, Actions)', () => {
|
|
36
|
+
renderPrompt();
|
|
37
|
+
expect(screen.getByRole('button', { name: /attach file/i })).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders submit button', () => {
|
|
41
|
+
renderPrompt();
|
|
42
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('disables submit when value is empty', () => {
|
|
46
|
+
renderPrompt();
|
|
47
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('enables submit when user types text', async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
renderPrompt();
|
|
53
|
+
const textarea = screen.getByPlaceholderText('Ask something...');
|
|
54
|
+
await user.type(textarea, 'Hello');
|
|
55
|
+
expect(screen.getByRole('button', { name: /submit/i })).not.toBeDisabled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('calls onSubmit when submit button is clicked', async () => {
|
|
59
|
+
const user = userEvent.setup();
|
|
60
|
+
const handleSubmit = vi.fn();
|
|
61
|
+
renderPrompt({ onSubmit: handleSubmit, defaultValue: 'Test message' });
|
|
62
|
+
|
|
63
|
+
await user.click(screen.getByRole('button', { name: /submit/i }));
|
|
64
|
+
expect(handleSubmit).toHaveBeenCalledWith('Test message');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('submits on Enter key by default (submitOnEnter)', async () => {
|
|
68
|
+
const user = userEvent.setup();
|
|
69
|
+
const handleSubmit = vi.fn();
|
|
70
|
+
renderPrompt({ onSubmit: handleSubmit });
|
|
71
|
+
|
|
72
|
+
const textarea = screen.getByPlaceholderText('Ask something...');
|
|
73
|
+
await user.type(textarea, 'Hello');
|
|
74
|
+
await user.keyboard('{Enter}');
|
|
75
|
+
expect(handleSubmit).toHaveBeenCalledWith('Hello');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('disables all controls when disabled prop is true', () => {
|
|
79
|
+
renderPrompt({ disabled: true });
|
|
80
|
+
expect(screen.getByPlaceholderText('Ask something...')).toBeDisabled();
|
|
81
|
+
expect(screen.getByRole('button', { name: /attach file/i })).toBeDisabled();
|
|
82
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('has no accessibility violations', async () => {
|
|
86
|
+
const { container } = renderPrompt();
|
|
87
|
+
await expectNoA11yViolations(container);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { RadioGroup } from './index';
|
|
4
|
+
|
|
5
|
+
describe('RadioGroup', () => {
|
|
6
|
+
it('renders a radiogroup role', () => {
|
|
7
|
+
render(
|
|
8
|
+
<RadioGroup label="Color">
|
|
9
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
10
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
11
|
+
</RadioGroup>
|
|
12
|
+
);
|
|
13
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('renders radio items', () => {
|
|
17
|
+
render(
|
|
18
|
+
<RadioGroup label="Color">
|
|
19
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
20
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
21
|
+
</RadioGroup>
|
|
22
|
+
);
|
|
23
|
+
expect(screen.getAllByRole('radio')).toHaveLength(2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('selects a radio on click', async () => {
|
|
27
|
+
const user = userEvent.setup();
|
|
28
|
+
render(
|
|
29
|
+
<RadioGroup label="Color">
|
|
30
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
31
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
32
|
+
</RadioGroup>
|
|
33
|
+
);
|
|
34
|
+
const radios = screen.getAllByRole('radio');
|
|
35
|
+
await user.click(radios[1]);
|
|
36
|
+
expect(radios[1]).toBeChecked();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders the group label', () => {
|
|
40
|
+
render(
|
|
41
|
+
<RadioGroup label="Choose a color">
|
|
42
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
43
|
+
</RadioGroup>
|
|
44
|
+
);
|
|
45
|
+
expect(screen.getByText('Choose a color')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('sets defaultValue as the initially selected radio', () => {
|
|
49
|
+
render(
|
|
50
|
+
<RadioGroup label="Color" defaultValue="blue">
|
|
51
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
52
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
53
|
+
</RadioGroup>
|
|
54
|
+
);
|
|
55
|
+
const radios = screen.getAllByRole('radio');
|
|
56
|
+
expect(radios[1]).toBeChecked();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('disables all items when disabled prop is set', () => {
|
|
60
|
+
render(
|
|
61
|
+
<RadioGroup label="Color" disabled>
|
|
62
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
63
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
64
|
+
</RadioGroup>
|
|
65
|
+
);
|
|
66
|
+
const radios = screen.getAllByRole('radio');
|
|
67
|
+
radios.forEach((radio) => expect(radio).toHaveAttribute('aria-disabled', 'true'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('disables individual items', () => {
|
|
71
|
+
render(
|
|
72
|
+
<RadioGroup label="Color">
|
|
73
|
+
<RadioGroup.Item value="red" label="Red" disabled />
|
|
74
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
75
|
+
</RadioGroup>
|
|
76
|
+
);
|
|
77
|
+
const radios = screen.getAllByRole('radio');
|
|
78
|
+
expect(radios[0]).toHaveAttribute('aria-disabled', 'true');
|
|
79
|
+
expect(radios[1]).not.toHaveAttribute('aria-disabled', 'true');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('calls onValueChange with the selected value', async () => {
|
|
83
|
+
const handleChange = vi.fn();
|
|
84
|
+
const user = userEvent.setup();
|
|
85
|
+
render(
|
|
86
|
+
<RadioGroup label="Color" onValueChange={handleChange}>
|
|
87
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
88
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
89
|
+
</RadioGroup>
|
|
90
|
+
);
|
|
91
|
+
await user.click(screen.getAllByRole('radio')[0]);
|
|
92
|
+
expect(handleChange).toHaveBeenCalled();
|
|
93
|
+
expect(handleChange.mock.calls[0][0]).toBe('red');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('has no accessibility violations', async () => {
|
|
97
|
+
const { container } = render(
|
|
98
|
+
<RadioGroup label="Accessible group">
|
|
99
|
+
<RadioGroup.Item value="a" label="Option A" />
|
|
100
|
+
<RadioGroup.Item value="b" label="Option B" />
|
|
101
|
+
</RadioGroup>
|
|
102
|
+
);
|
|
103
|
+
await expectNoA11yViolations(container);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineSegment } from '@fragments/core';
|
|
3
|
+
import { ScrollArea } from '.';
|
|
4
|
+
|
|
5
|
+
const tags = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4', 'Tag 5', 'Tag 6', 'Tag 7', 'Tag 8', 'Tag 9', 'Tag 10', 'Tag 11', 'Tag 12', 'Tag 13', 'Tag 14', 'Tag 15'];
|
|
6
|
+
|
|
7
|
+
export default defineSegment({
|
|
8
|
+
component: ScrollArea,
|
|
9
|
+
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'ScrollArea',
|
|
12
|
+
description: 'A styled scrollable container with thin scrollbars and optional fade indicators.',
|
|
13
|
+
category: 'layout',
|
|
14
|
+
status: 'stable',
|
|
15
|
+
tags: ['scroll', 'overflow', 'scrollbar', 'container', 'layout'],
|
|
16
|
+
since: '0.4.0',
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
usage: {
|
|
20
|
+
when: [
|
|
21
|
+
'Content overflows its container and needs scrolling',
|
|
22
|
+
'Horizontal tab bars or chip lists that may overflow',
|
|
23
|
+
'Scrollable panels, sidebars, or dropdown content',
|
|
24
|
+
'Any area where native scrollbars look too heavy',
|
|
25
|
+
],
|
|
26
|
+
whenNot: [
|
|
27
|
+
'Page-level scrolling (use native body scroll)',
|
|
28
|
+
'Very short content that never overflows',
|
|
29
|
+
],
|
|
30
|
+
guidelines: [
|
|
31
|
+
'Use `orientation` to constrain scroll direction',
|
|
32
|
+
'Use `showFades` to hint at hidden content beyond the viewport',
|
|
33
|
+
'The `hover` scrollbar visibility keeps the UI clean until the user interacts',
|
|
34
|
+
'Combine with `orientation="horizontal"` for tab bars and chip rows',
|
|
35
|
+
],
|
|
36
|
+
accessibility: [
|
|
37
|
+
'Preserves native scroll behavior and keyboard support',
|
|
38
|
+
'Scrollbar is visible on focus for keyboard users',
|
|
39
|
+
'Respects prefers-reduced-motion for fade transitions',
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
props: {
|
|
44
|
+
children: {
|
|
45
|
+
type: 'node',
|
|
46
|
+
description: 'Scrollable content',
|
|
47
|
+
required: true,
|
|
48
|
+
},
|
|
49
|
+
orientation: {
|
|
50
|
+
type: 'enum',
|
|
51
|
+
description: 'Scroll direction',
|
|
52
|
+
values: ['horizontal', 'vertical', 'both'],
|
|
53
|
+
default: 'vertical',
|
|
54
|
+
},
|
|
55
|
+
scrollbarVisibility: {
|
|
56
|
+
type: 'enum',
|
|
57
|
+
description: 'When to show the scrollbar',
|
|
58
|
+
values: ['auto', 'always', 'hover'],
|
|
59
|
+
default: 'auto',
|
|
60
|
+
},
|
|
61
|
+
showFades: {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
description: 'Show gradient fade indicators at scroll edges',
|
|
64
|
+
default: false,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
contract: {
|
|
69
|
+
propsSummary: [
|
|
70
|
+
'orientation: horizontal|vertical|both - scroll direction',
|
|
71
|
+
'scrollbarVisibility: auto|always|hover - scrollbar display mode',
|
|
72
|
+
'showFades: boolean - gradient edge indicators',
|
|
73
|
+
],
|
|
74
|
+
scenarioTags: ['layout.scroll', 'container.scroll'],
|
|
75
|
+
a11yRules: [],
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
ai: {
|
|
79
|
+
compositionPattern: 'wrapper',
|
|
80
|
+
requiredChildren: [],
|
|
81
|
+
commonPatterns: [
|
|
82
|
+
'<ScrollArea orientation="horizontal"><div style={{ display: "flex", gap: 8 }}>{items}</div></ScrollArea>',
|
|
83
|
+
'<ScrollArea style={{ height: 300 }}>{longContent}</ScrollArea>',
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
variants: [
|
|
88
|
+
{
|
|
89
|
+
name: 'Vertical',
|
|
90
|
+
description: 'Vertical scrollable area with thin scrollbar.',
|
|
91
|
+
code: `<ScrollArea style={{ height: '200px' }}>
|
|
92
|
+
{/* Long content */}
|
|
93
|
+
</ScrollArea>`,
|
|
94
|
+
render: () => (
|
|
95
|
+
<div style={{ height: '200px', width: '300px', border: '1px solid var(--fui-border)' }}>
|
|
96
|
+
<ScrollArea style={{ height: '200px' }}>
|
|
97
|
+
<div style={{ padding: '16px' }}>
|
|
98
|
+
{Array.from({ length: 20 }).map((_, i) => (
|
|
99
|
+
<p key={i} style={{ margin: '0 0 12px', color: 'var(--fui-text-secondary)', fontSize: '14px' }}>
|
|
100
|
+
Item {i + 1} — Lorem ipsum dolor sit amet
|
|
101
|
+
</p>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
</ScrollArea>
|
|
105
|
+
</div>
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'Horizontal',
|
|
110
|
+
description: 'Horizontal scrollable area for overflowing inline content like tabs or chips.',
|
|
111
|
+
code: `<ScrollArea orientation="horizontal">
|
|
112
|
+
<div style={{ display: 'flex', gap: '8px' }}>
|
|
113
|
+
{tags.map(tag => <Chip key={tag}>{tag}</Chip>)}
|
|
114
|
+
</div>
|
|
115
|
+
</ScrollArea>`,
|
|
116
|
+
render: () => (
|
|
117
|
+
<div style={{ width: '400px', border: '1px solid var(--fui-border)', borderRadius: '8px' }}>
|
|
118
|
+
<ScrollArea orientation="horizontal">
|
|
119
|
+
<div style={{ display: 'flex', gap: '8px', padding: '12px', whiteSpace: 'nowrap' }}>
|
|
120
|
+
{tags.map(tag => (
|
|
121
|
+
<span key={tag} style={{
|
|
122
|
+
padding: '4px 12px',
|
|
123
|
+
borderRadius: '16px',
|
|
124
|
+
backgroundColor: 'var(--fui-bg-secondary)',
|
|
125
|
+
color: 'var(--fui-text-secondary)',
|
|
126
|
+
fontSize: '13px',
|
|
127
|
+
flexShrink: 0,
|
|
128
|
+
}}>
|
|
129
|
+
{tag}
|
|
130
|
+
</span>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
</ScrollArea>
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'With Fades',
|
|
139
|
+
description: 'Fade indicators show when content is scrollable in either direction.',
|
|
140
|
+
code: `<ScrollArea orientation="horizontal" showFades>
|
|
141
|
+
{/* Overflowing content */}
|
|
142
|
+
</ScrollArea>`,
|
|
143
|
+
render: () => (
|
|
144
|
+
<div style={{ width: '400px', border: '1px solid var(--fui-border)', borderRadius: '8px' }}>
|
|
145
|
+
<ScrollArea orientation="horizontal" showFades>
|
|
146
|
+
<div style={{ display: 'flex', gap: '8px', padding: '12px', whiteSpace: 'nowrap' }}>
|
|
147
|
+
{tags.map(tag => (
|
|
148
|
+
<span key={tag} style={{
|
|
149
|
+
padding: '4px 12px',
|
|
150
|
+
borderRadius: '16px',
|
|
151
|
+
backgroundColor: 'var(--fui-bg-secondary)',
|
|
152
|
+
color: 'var(--fui-text-secondary)',
|
|
153
|
+
fontSize: '13px',
|
|
154
|
+
flexShrink: 0,
|
|
155
|
+
}}>
|
|
156
|
+
{tag}
|
|
157
|
+
</span>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</ScrollArea>
|
|
161
|
+
</div>
|
|
162
|
+
),
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'Hover Scrollbar',
|
|
166
|
+
description: 'Scrollbar is hidden until the user hovers over the scroll area.',
|
|
167
|
+
code: `<ScrollArea scrollbarVisibility="hover" style={{ height: '200px' }}>
|
|
168
|
+
{/* Content */}
|
|
169
|
+
</ScrollArea>`,
|
|
170
|
+
render: () => (
|
|
171
|
+
<div style={{ height: '200px', width: '300px', border: '1px solid var(--fui-border)' }}>
|
|
172
|
+
<ScrollArea scrollbarVisibility="hover" style={{ height: '200px' }}>
|
|
173
|
+
<div style={{ padding: '16px' }}>
|
|
174
|
+
{Array.from({ length: 20 }).map((_, i) => (
|
|
175
|
+
<p key={i} style={{ margin: '0 0 12px', color: 'var(--fui-text-secondary)', fontSize: '14px' }}>
|
|
176
|
+
Item {i + 1} — Hover to reveal scrollbar
|
|
177
|
+
</p>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
</ScrollArea>
|
|
181
|
+
</div>
|
|
182
|
+
),
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
@use '../../tokens/variables' as *;
|
|
2
|
+
@use '../../tokens/mixins' as *;
|
|
3
|
+
|
|
4
|
+
// ============================================
|
|
5
|
+
// ScrollArea Root
|
|
6
|
+
// ============================================
|
|
7
|
+
|
|
8
|
+
.root {
|
|
9
|
+
position: relative;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ============================================
|
|
14
|
+
// Fade masks — applied to the viewport via CSS mask-image.
|
|
15
|
+
// This fades out the actual content at the edges, which is
|
|
16
|
+
// far more visible than overlaying a gradient pseudo-element.
|
|
17
|
+
// ============================================
|
|
18
|
+
|
|
19
|
+
.fadeMaskEnd {
|
|
20
|
+
&.horizontal {
|
|
21
|
+
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&.vertical {
|
|
25
|
+
mask-image: linear-gradient(to bottom, black calc(100% - 32px), transparent 100%);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
&.both {
|
|
29
|
+
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.fadeMaskStart {
|
|
34
|
+
&.horizontal {
|
|
35
|
+
mask-image: linear-gradient(to left, black calc(100% - 48px), transparent 100%);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&.vertical {
|
|
39
|
+
mask-image: linear-gradient(to top, black calc(100% - 32px), transparent 100%);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&.both {
|
|
43
|
+
mask-image: linear-gradient(to left, black calc(100% - 48px), transparent 100%);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.fadeMaskBoth {
|
|
48
|
+
&.horizontal {
|
|
49
|
+
mask-image: linear-gradient(to right, transparent, black 48px, black calc(100% - 48px), transparent 100%);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&.vertical {
|
|
53
|
+
mask-image: linear-gradient(to bottom, transparent, black 32px, black calc(100% - 32px), transparent 100%);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&.both {
|
|
57
|
+
mask-image: linear-gradient(to right, transparent, black 48px, black calc(100% - 48px), transparent 100%);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================
|
|
62
|
+
// Viewport (scrollable area)
|
|
63
|
+
// ============================================
|
|
64
|
+
|
|
65
|
+
.viewport {
|
|
66
|
+
width: 100%;
|
|
67
|
+
height: 100%;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
scrollbar-width: thin;
|
|
70
|
+
scrollbar-color: var(--fui-border-strong, $fui-border-strong) transparent;
|
|
71
|
+
|
|
72
|
+
// Webkit custom scrollbar
|
|
73
|
+
&::-webkit-scrollbar {
|
|
74
|
+
width: 6px;
|
|
75
|
+
height: 6px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&::-webkit-scrollbar-track {
|
|
79
|
+
background: transparent;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
&::-webkit-scrollbar-thumb {
|
|
83
|
+
background-color: var(--fui-border-strong, $fui-border-strong);
|
|
84
|
+
border-radius: var(--fui-radius-full, $fui-radius-full);
|
|
85
|
+
|
|
86
|
+
&:hover {
|
|
87
|
+
background-color: var(--fui-text-tertiary, $fui-text-tertiary);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Orientation variants
|
|
93
|
+
.horizontal {
|
|
94
|
+
overflow-x: auto;
|
|
95
|
+
overflow-y: hidden;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.vertical {
|
|
99
|
+
overflow-x: hidden;
|
|
100
|
+
overflow-y: auto;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.both {
|
|
104
|
+
overflow: auto;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============================================
|
|
108
|
+
// Scrollbar visibility modes
|
|
109
|
+
// ============================================
|
|
110
|
+
|
|
111
|
+
// Always show scrollbar
|
|
112
|
+
.scrollbarAlways {
|
|
113
|
+
&::-webkit-scrollbar-thumb {
|
|
114
|
+
background-color: var(--fui-border-strong, $fui-border-strong);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Only show scrollbar on hover
|
|
119
|
+
.scrollbarHover {
|
|
120
|
+
scrollbar-width: none;
|
|
121
|
+
|
|
122
|
+
&::-webkit-scrollbar {
|
|
123
|
+
width: 0;
|
|
124
|
+
height: 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
&:hover {
|
|
128
|
+
scrollbar-width: thin;
|
|
129
|
+
|
|
130
|
+
&::-webkit-scrollbar {
|
|
131
|
+
width: 6px;
|
|
132
|
+
height: 6px;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { ScrollArea } from '.';
|
|
4
|
+
|
|
5
|
+
describe('ScrollArea', () => {
|
|
6
|
+
it('renders children', () => {
|
|
7
|
+
render(<ScrollArea>Test content</ScrollArea>);
|
|
8
|
+
expect(screen.getByText('Test content')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('sets data-orientation attribute', () => {
|
|
12
|
+
const { container } = render(
|
|
13
|
+
<ScrollArea orientation="horizontal">Content</ScrollArea>
|
|
14
|
+
);
|
|
15
|
+
expect(container.firstChild).toHaveAttribute('data-orientation', 'horizontal');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('defaults to vertical orientation', () => {
|
|
19
|
+
const { container } = render(<ScrollArea>Content</ScrollArea>);
|
|
20
|
+
expect(container.firstChild).toHaveAttribute('data-orientation', 'vertical');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('passes through HTML attributes', () => {
|
|
24
|
+
render(
|
|
25
|
+
<ScrollArea data-testid="scroll-area" style={{ height: '200px' }}>
|
|
26
|
+
Content
|
|
27
|
+
</ScrollArea>
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByTestId('scroll-area')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('applies custom className', () => {
|
|
33
|
+
const { container } = render(
|
|
34
|
+
<ScrollArea className="custom-class">Content</ScrollArea>
|
|
35
|
+
);
|
|
36
|
+
expect(container.firstChild).toHaveClass('custom-class');
|
|
37
|
+
});
|
|
38
|
+
});
|