@fragments-sdk/ui 0.3.0 → 0.4.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/fragments.json +1 -1
- package/package.json +9 -4
- package/src/components/Accordion/Accordion.fragment.tsx +186 -0
- package/src/components/Accordion/Accordion.module.scss +111 -0
- package/src/components/Accordion/index.tsx +271 -0
- package/src/components/Alert/Alert.fragment.tsx +66 -41
- package/src/components/Alert/Alert.module.scss +31 -21
- package/src/components/Alert/index.tsx +202 -73
- package/src/components/AppShell/AppShell.fragment.tsx +315 -0
- package/src/components/AppShell/AppShell.module.scss +213 -0
- package/src/components/AppShell/index.tsx +398 -0
- package/src/components/Avatar/index.tsx +8 -9
- package/src/components/Badge/Badge.module.scss +16 -10
- package/src/components/Badge/index.tsx +20 -6
- package/src/components/Box/Box.fragment.tsx +168 -0
- package/src/components/Box/Box.module.scss +84 -0
- package/src/components/Box/index.tsx +78 -0
- package/src/components/Button/Button.module.scss +42 -0
- package/src/components/Button/index.tsx +67 -33
- package/src/components/ButtonGroup/ButtonGroup.module.scss +37 -0
- package/src/components/ButtonGroup/index.tsx +40 -0
- package/src/components/Card/Card.fragment.tsx +51 -25
- package/src/components/Card/Card.module.scss +52 -5
- package/src/components/Card/index.tsx +154 -53
- package/src/components/Checkbox/Checkbox.module.scss +4 -4
- package/src/components/Checkbox/index.tsx +3 -4
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +201 -0
- package/src/components/CodeBlock/CodeBlock.module.scss +224 -0
- package/src/components/CodeBlock/index.tsx +385 -0
- package/src/components/ColorChip/ColorChip.module.scss +165 -0
- package/src/components/ColorChip/index.tsx +157 -0
- package/src/components/ColorPicker/ColorPicker.module.scss +109 -0
- package/src/components/ColorPicker/index.tsx +107 -0
- package/src/components/Dialog/Dialog.fragment.tsx +9 -0
- package/src/components/Dialog/Dialog.module.scss +26 -7
- package/src/components/Dialog/index.tsx +12 -15
- package/src/components/EmptyState/EmptyState.fragment.tsx +54 -71
- package/src/components/EmptyState/EmptyState.module.scss +9 -9
- package/src/components/EmptyState/index.tsx +104 -69
- package/src/components/Field/Field.fragment.tsx +165 -0
- package/src/components/Field/Field.module.scss +31 -0
- package/src/components/Field/index.tsx +143 -0
- package/src/components/Fieldset/Fieldset.fragment.tsx +166 -0
- package/src/components/Fieldset/Fieldset.module.scss +22 -0
- package/src/components/Fieldset/index.tsx +47 -0
- package/src/components/Form/Form.fragment.tsx +286 -0
- package/src/components/Form/Form.module.scss +8 -0
- package/src/components/Form/index.tsx +53 -0
- package/src/components/Grid/Grid.fragment.tsx +17 -17
- package/src/components/Grid/index.tsx +6 -1
- package/src/components/Header/Header.fragment.tsx +192 -0
- package/src/components/Header/Header.module.scss +209 -0
- package/src/components/Header/index.tsx +363 -0
- package/src/components/Icon/Icon.fragment.tsx +138 -0
- package/src/components/Icon/Icon.module.scss +38 -0
- package/src/components/Icon/index.tsx +58 -0
- package/src/components/Image/Image.fragment.tsx +195 -0
- package/src/components/Image/Image.module.scss +77 -0
- package/src/components/Image/index.tsx +95 -0
- package/src/components/Input/Input.module.scss +75 -2
- package/src/components/Input/index.tsx +60 -21
- package/src/components/Link/Link.fragment.tsx +132 -0
- package/src/components/Link/Link.module.scss +67 -0
- package/src/components/Link/index.tsx +57 -0
- package/src/components/List/List.fragment.tsx +152 -0
- package/src/components/List/List.module.scss +71 -0
- package/src/components/List/index.tsx +106 -0
- package/src/components/Listbox/Listbox.fragment.tsx +191 -0
- package/src/components/Listbox/Listbox.module.scss +97 -0
- package/src/components/Listbox/index.tsx +121 -0
- package/src/components/Menu/Menu.fragment.tsx +9 -0
- package/src/components/Menu/Menu.module.scss +17 -1
- package/src/components/Menu/index.tsx +3 -3
- package/src/components/Popover/Popover.fragment.tsx +9 -0
- package/src/components/Popover/Popover.module.scss +33 -10
- package/src/components/Popover/index.tsx +9 -11
- package/src/components/Progress/Progress.module.scss +11 -11
- package/src/components/Progress/index.tsx +34 -7
- package/src/components/Prompt/Prompt.fragment.tsx +231 -0
- package/src/components/Prompt/Prompt.module.scss +243 -0
- package/src/components/Prompt/index.tsx +439 -0
- package/src/components/RadioGroup/RadioGroup.module.scss +3 -3
- package/src/components/RadioGroup/index.tsx +3 -4
- package/src/components/Select/Select.fragment.tsx +9 -0
- package/src/components/Select/index.tsx +6 -7
- package/src/components/Separator/index.tsx +7 -3
- package/src/components/Sidebar/Sidebar.fragment.tsx +9 -0
- package/src/components/Sidebar/Sidebar.module.scss +72 -47
- package/src/components/Sidebar/index.tsx +5 -3
- package/src/components/Skeleton/Skeleton.fragment.tsx +5 -5
- package/src/components/Skeleton/Skeleton.module.scss +11 -0
- package/src/components/Slider/Slider.module.scss +87 -0
- package/src/components/Slider/index.tsx +88 -0
- package/src/components/Stack/Stack.module.scss +120 -0
- package/src/components/Stack/index.tsx +148 -0
- package/src/components/Table/Table.fragment.tsx +7 -0
- package/src/components/Table/Table.module.scss +57 -0
- package/src/components/Table/index.tsx +44 -6
- package/src/components/Tabs/Tabs.fragment.tsx +9 -0
- package/src/components/Tabs/Tabs.module.scss +25 -10
- package/src/components/Tabs/index.tsx +11 -8
- package/src/components/Text/Text.module.scss +82 -0
- package/src/components/Text/index.tsx +58 -0
- package/src/components/Textarea/index.tsx +3 -7
- package/src/components/Theme/Theme.fragment.tsx +128 -0
- package/src/components/Theme/ThemeToggle.module.scss +82 -0
- package/src/components/Theme/index.tsx +343 -0
- package/src/components/Toast/Toast.fragment.tsx +5 -5
- package/src/components/Toast/Toast.module.scss +16 -1
- package/src/components/Toast/index.tsx +27 -11
- package/src/components/Toggle/Toggle.module.scss +25 -10
- package/src/components/Toggle/index.tsx +12 -0
- package/src/components/ToggleGroup/ToggleGroup.module.scss +134 -0
- package/src/components/ToggleGroup/index.tsx +144 -0
- package/src/components/Tooltip/Tooltip.module.scss +4 -4
- package/src/components/Tooltip/index.tsx +4 -2
- package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +134 -0
- package/src/components/VisuallyHidden/VisuallyHidden.module.scss +13 -0
- package/src/components/VisuallyHidden/index.tsx +29 -0
- package/src/index.ts +195 -3
- package/src/recipes/AppShell.recipe.ts +175 -0
- package/src/recipes/CardGrid.recipe.ts +6 -2
- package/src/recipes/ChatInterface.recipe.ts +87 -0
- package/src/recipes/CodeExamples.recipe.ts +66 -0
- package/src/recipes/DashboardLayout.recipe.ts +46 -12
- package/src/recipes/DashboardNav.recipe.ts +183 -0
- package/src/recipes/LoginForm.recipe.ts +8 -1
- package/src/recipes/SettingsPage.recipe.ts +37 -20
- package/src/styles/globals.scss +31 -0
- package/src/tokens/_index.scss +3 -0
- package/src/tokens/_mixins.scss +54 -1
- package/src/tokens/_variables.scss +429 -64
- package/src/utils/a11y.tsx +439 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fragments-sdk/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Customizable UI components built on Base UI headless primitives",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
".": "./src/index.ts",
|
|
11
11
|
"./styles": "./src/styles/globals.scss",
|
|
12
12
|
"./tokens": "./src/tokens/_variables.scss",
|
|
13
|
-
"./
|
|
13
|
+
"./mixins": "./src/tokens/_mixins.scss",
|
|
14
|
+
"./brand": "./src/brand.ts",
|
|
15
|
+
"./fragments.json": "./fragments.json"
|
|
14
16
|
},
|
|
15
17
|
"publishConfig": {
|
|
16
18
|
"access": "public"
|
|
@@ -21,7 +23,10 @@
|
|
|
21
23
|
},
|
|
22
24
|
"dependencies": {
|
|
23
25
|
"@base-ui/react": "^1.0.0",
|
|
24
|
-
"@
|
|
26
|
+
"@phosphor-icons/react": "^2.1.10",
|
|
27
|
+
"@tanstack/react-table": "^8.21.3",
|
|
28
|
+
"react-colorful": "^5.6.1",
|
|
29
|
+
"shiki": "^3.0.0"
|
|
25
30
|
},
|
|
26
31
|
"devDependencies": {
|
|
27
32
|
"@types/react": "^19.0.0",
|
|
@@ -30,7 +35,7 @@
|
|
|
30
35
|
"react-dom": "^19.0.0",
|
|
31
36
|
"sass": "^1.83.0",
|
|
32
37
|
"typescript": "^5.7.0",
|
|
33
|
-
"@fragments-sdk/cli": "0.
|
|
38
|
+
"@fragments-sdk/cli": "0.4.0"
|
|
34
39
|
},
|
|
35
40
|
"files": [
|
|
36
41
|
"src",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineSegment } from '@fragments/core';
|
|
3
|
+
import { Accordion } from './index.js';
|
|
4
|
+
|
|
5
|
+
export default defineSegment({
|
|
6
|
+
component: Accordion,
|
|
7
|
+
|
|
8
|
+
meta: {
|
|
9
|
+
name: 'Accordion',
|
|
10
|
+
description: 'Vertically stacked, collapsible content sections. Use for organizing related content that can be progressively disclosed.',
|
|
11
|
+
category: 'layout',
|
|
12
|
+
status: 'stable',
|
|
13
|
+
tags: ['accordion', 'collapse', 'expand', 'disclosure', 'faq'],
|
|
14
|
+
since: '0.2.0',
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
usage: {
|
|
18
|
+
when: [
|
|
19
|
+
'FAQ pages with multiple questions and answers',
|
|
20
|
+
'Settings panels with grouped options',
|
|
21
|
+
'Long forms that benefit from progressive disclosure',
|
|
22
|
+
'Navigation menus with nested items',
|
|
23
|
+
],
|
|
24
|
+
whenNot: [
|
|
25
|
+
'Primary content that all users need to see',
|
|
26
|
+
'Very short content (just display inline)',
|
|
27
|
+
'Sequential steps (use Stepper or wizard)',
|
|
28
|
+
'Tab-like navigation (use Tabs instead)',
|
|
29
|
+
],
|
|
30
|
+
guidelines: [
|
|
31
|
+
'Keep section headers concise and descriptive',
|
|
32
|
+
'Use single mode when only one section should be open at a time',
|
|
33
|
+
'Use multiple mode when users may need to compare sections',
|
|
34
|
+
'Consider defaulting important sections to open',
|
|
35
|
+
'Avoid nesting accordions more than one level deep',
|
|
36
|
+
],
|
|
37
|
+
accessibility: [
|
|
38
|
+
'Keyboard navigation with Enter/Space to toggle',
|
|
39
|
+
'Arrow keys navigate between accordion headers',
|
|
40
|
+
'Uses proper ARIA expanded/controls attributes',
|
|
41
|
+
'Focus is visible on accordion triggers',
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
props: {
|
|
46
|
+
children: {
|
|
47
|
+
type: 'node',
|
|
48
|
+
description: 'Accordion items (use Accordion.Item with Accordion.Trigger and Accordion.Content)',
|
|
49
|
+
required: true,
|
|
50
|
+
},
|
|
51
|
+
type: {
|
|
52
|
+
type: 'enum',
|
|
53
|
+
description: 'Whether one or multiple items can be open',
|
|
54
|
+
values: ['single', 'multiple'],
|
|
55
|
+
default: 'single',
|
|
56
|
+
},
|
|
57
|
+
value: {
|
|
58
|
+
type: 'string | string[]',
|
|
59
|
+
description: 'Controlled open item(s)',
|
|
60
|
+
},
|
|
61
|
+
defaultValue: {
|
|
62
|
+
type: 'string | string[]',
|
|
63
|
+
description: 'Initially open item(s) for uncontrolled usage',
|
|
64
|
+
},
|
|
65
|
+
onValueChange: {
|
|
66
|
+
type: 'function',
|
|
67
|
+
description: 'Called when open items change',
|
|
68
|
+
},
|
|
69
|
+
collapsible: {
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
description: 'Whether all items can be closed (single mode only)',
|
|
72
|
+
default: 'false',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
relations: [
|
|
77
|
+
{ component: 'Tabs', relationship: 'alternative', note: 'Use Tabs for horizontal switching between related views' },
|
|
78
|
+
{ component: 'Dialog', relationship: 'alternative', note: 'Use Dialog for focused content that interrupts the flow' },
|
|
79
|
+
{ component: 'Card', relationship: 'complementary', note: 'Accordion items can contain Card-like content' },
|
|
80
|
+
],
|
|
81
|
+
|
|
82
|
+
contract: {
|
|
83
|
+
propsSummary: [
|
|
84
|
+
'type: single|multiple - controls how many items can be open',
|
|
85
|
+
'value: string|string[] - controlled open items',
|
|
86
|
+
'defaultValue: string|string[] - initial open items',
|
|
87
|
+
'collapsible: boolean - allow all closed in single mode',
|
|
88
|
+
],
|
|
89
|
+
scenarioTags: [
|
|
90
|
+
'layout.disclosure',
|
|
91
|
+
'navigation.collapsible',
|
|
92
|
+
'content.faq',
|
|
93
|
+
],
|
|
94
|
+
a11yRules: ['A11Y_DISCLOSURE_KEYBOARD', 'A11Y_FOCUS_VISIBLE'],
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
ai: {
|
|
98
|
+
compositionPattern: 'compound',
|
|
99
|
+
subComponents: ['Item', 'Trigger', 'Content'],
|
|
100
|
+
requiredChildren: ['Item'],
|
|
101
|
+
commonPatterns: [
|
|
102
|
+
'<Accordion type="single" collapsible><Accordion.Item value="item-1"><Accordion.Trigger>{title}</Accordion.Trigger><Accordion.Content>{content}</Accordion.Content></Accordion.Item></Accordion>',
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
variants: [
|
|
107
|
+
{
|
|
108
|
+
name: 'Basic',
|
|
109
|
+
description: 'Single accordion with collapsible sections',
|
|
110
|
+
render: () => (
|
|
111
|
+
<Accordion type="single" collapsible defaultValue="item-1">
|
|
112
|
+
<Accordion.Item value="item-1">
|
|
113
|
+
<Accordion.Trigger>What is Fragments UI?</Accordion.Trigger>
|
|
114
|
+
<Accordion.Content>
|
|
115
|
+
Fragments UI is a modern React component library built on Base UI primitives, providing accessible and customizable components.
|
|
116
|
+
</Accordion.Content>
|
|
117
|
+
</Accordion.Item>
|
|
118
|
+
<Accordion.Item value="item-2">
|
|
119
|
+
<Accordion.Trigger>How do I install it?</Accordion.Trigger>
|
|
120
|
+
<Accordion.Content>
|
|
121
|
+
Install via npm or pnpm: <code>pnpm add @fragments-sdk/ui</code>
|
|
122
|
+
</Accordion.Content>
|
|
123
|
+
</Accordion.Item>
|
|
124
|
+
<Accordion.Item value="item-3">
|
|
125
|
+
<Accordion.Trigger>Is it accessible?</Accordion.Trigger>
|
|
126
|
+
<Accordion.Content>
|
|
127
|
+
Yes! All components follow WAI-ARIA guidelines and support keyboard navigation.
|
|
128
|
+
</Accordion.Content>
|
|
129
|
+
</Accordion.Item>
|
|
130
|
+
</Accordion>
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'Multiple Open',
|
|
135
|
+
description: 'Allows multiple sections to be open simultaneously',
|
|
136
|
+
render: () => (
|
|
137
|
+
<Accordion type="multiple" defaultValue={['features', 'pricing']}>
|
|
138
|
+
<Accordion.Item value="features">
|
|
139
|
+
<Accordion.Trigger>Features</Accordion.Trigger>
|
|
140
|
+
<Accordion.Content>
|
|
141
|
+
Comprehensive component library with theming support, accessibility built-in, and TypeScript-first development.
|
|
142
|
+
</Accordion.Content>
|
|
143
|
+
</Accordion.Item>
|
|
144
|
+
<Accordion.Item value="pricing">
|
|
145
|
+
<Accordion.Trigger>Pricing</Accordion.Trigger>
|
|
146
|
+
<Accordion.Content>
|
|
147
|
+
Free and open source. MIT licensed for both personal and commercial use.
|
|
148
|
+
</Accordion.Content>
|
|
149
|
+
</Accordion.Item>
|
|
150
|
+
<Accordion.Item value="support">
|
|
151
|
+
<Accordion.Trigger>Support</Accordion.Trigger>
|
|
152
|
+
<Accordion.Content>
|
|
153
|
+
Community support via GitHub issues and discussions.
|
|
154
|
+
</Accordion.Content>
|
|
155
|
+
</Accordion.Item>
|
|
156
|
+
</Accordion>
|
|
157
|
+
),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'With Disabled',
|
|
161
|
+
description: 'Accordion with a disabled item',
|
|
162
|
+
render: () => (
|
|
163
|
+
<Accordion type="single" collapsible>
|
|
164
|
+
<Accordion.Item value="available">
|
|
165
|
+
<Accordion.Trigger>Available Section</Accordion.Trigger>
|
|
166
|
+
<Accordion.Content>
|
|
167
|
+
This section can be expanded and collapsed.
|
|
168
|
+
</Accordion.Content>
|
|
169
|
+
</Accordion.Item>
|
|
170
|
+
<Accordion.Item value="disabled" disabled>
|
|
171
|
+
<Accordion.Trigger>Disabled Section</Accordion.Trigger>
|
|
172
|
+
<Accordion.Content>
|
|
173
|
+
This content is not accessible because the item is disabled.
|
|
174
|
+
</Accordion.Content>
|
|
175
|
+
</Accordion.Item>
|
|
176
|
+
<Accordion.Item value="another">
|
|
177
|
+
<Accordion.Trigger>Another Section</Accordion.Trigger>
|
|
178
|
+
<Accordion.Content>
|
|
179
|
+
This section is also available for interaction.
|
|
180
|
+
</Accordion.Content>
|
|
181
|
+
</Accordion.Item>
|
|
182
|
+
</Accordion>
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
@use '../../tokens/variables' as *;
|
|
2
|
+
@use '../../tokens/mixins' as *;
|
|
3
|
+
|
|
4
|
+
.accordion {
|
|
5
|
+
font-family: var(--fui-font-sans, $fui-font-sans);
|
|
6
|
+
width: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.item {
|
|
10
|
+
border-bottom: 1px solid var(--fui-border, $fui-border);
|
|
11
|
+
|
|
12
|
+
&:first-child {
|
|
13
|
+
border-top: 1px solid var(--fui-border, $fui-border);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&[data-disabled] {
|
|
17
|
+
opacity: 0.5;
|
|
18
|
+
pointer-events: none;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Reset heading styles (semantic wrapper for trigger)
|
|
23
|
+
.heading {
|
|
24
|
+
margin: 0;
|
|
25
|
+
font-size: inherit;
|
|
26
|
+
font-weight: inherit;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.trigger {
|
|
30
|
+
@include button-reset;
|
|
31
|
+
@include interactive-base;
|
|
32
|
+
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
width: 100%;
|
|
37
|
+
padding: var(--fui-padding-item-md, $fui-padding-item-md) 0;
|
|
38
|
+
font-size: var(--fui-font-size-sm, $fui-font-size-sm);
|
|
39
|
+
font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
|
|
40
|
+
color: var(--fui-text-primary, $fui-text-primary);
|
|
41
|
+
text-align: left;
|
|
42
|
+
|
|
43
|
+
&:hover:not(:disabled) {
|
|
44
|
+
text-decoration: underline;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
&:focus-visible {
|
|
48
|
+
@include focus-ring;
|
|
49
|
+
border-radius: var(--fui-radius-sm, $fui-radius-sm);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.triggerContent {
|
|
54
|
+
flex: 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.chevron {
|
|
58
|
+
flex-shrink: 0;
|
|
59
|
+
color: var(--fui-text-secondary, $fui-text-secondary);
|
|
60
|
+
transition: transform var(--fui-transition-normal, $fui-transition-normal);
|
|
61
|
+
|
|
62
|
+
[data-state='open'] & {
|
|
63
|
+
transform: rotate(180deg);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.content {
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
|
|
70
|
+
// Base UI Collapsible handles height animation via CSS
|
|
71
|
+
// We need to handle the collapsed state
|
|
72
|
+
&[data-state='closed'] {
|
|
73
|
+
height: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
&[data-state='open'] {
|
|
77
|
+
height: auto;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Smooth animation using Base UI's built-in transition
|
|
81
|
+
&[data-starting-style],
|
|
82
|
+
&[data-ending-style] {
|
|
83
|
+
transition:
|
|
84
|
+
height var(--fui-transition-normal, $fui-transition-normal),
|
|
85
|
+
padding var(--fui-transition-normal, $fui-transition-normal);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.contentInner {
|
|
90
|
+
padding-bottom: var(--fui-padding-item-md, $fui-padding-item-md);
|
|
91
|
+
font-size: var(--fui-font-size-sm, $fui-font-size-sm);
|
|
92
|
+
color: var(--fui-text-secondary, $fui-text-secondary);
|
|
93
|
+
line-height: var(--fui-line-height-normal, $fui-line-height-normal);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================
|
|
97
|
+
// Accessibility: Reduced Motion
|
|
98
|
+
// ============================================
|
|
99
|
+
|
|
100
|
+
@media (prefers-reduced-motion: reduce) {
|
|
101
|
+
.chevron {
|
|
102
|
+
transition: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.content {
|
|
106
|
+
&[data-starting-style],
|
|
107
|
+
&[data-ending-style] {
|
|
108
|
+
transition: none;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible';
|
|
5
|
+
import styles from './Accordion.module.scss';
|
|
6
|
+
import '../../styles/globals.scss';
|
|
7
|
+
|
|
8
|
+
// ============================================
|
|
9
|
+
// Types
|
|
10
|
+
// ============================================
|
|
11
|
+
|
|
12
|
+
export type AccordionValue = string | string[];
|
|
13
|
+
|
|
14
|
+
export interface AccordionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'defaultValue'> {
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
/** Allow multiple items to be open at once */
|
|
17
|
+
type?: 'single' | 'multiple';
|
|
18
|
+
/** Controlled value - string for single, string[] for multiple */
|
|
19
|
+
value?: AccordionValue;
|
|
20
|
+
/** Default value for uncontrolled usage */
|
|
21
|
+
defaultValue?: AccordionValue;
|
|
22
|
+
/** Callback when value changes */
|
|
23
|
+
onValueChange?: (value: AccordionValue) => void;
|
|
24
|
+
/** Whether items can be fully collapsed (only for type="single") */
|
|
25
|
+
collapsible?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Heading level for accordion triggers (for semantic HTML).
|
|
28
|
+
* The trigger will be wrapped in an <h{level}> element.
|
|
29
|
+
* @default 3
|
|
30
|
+
*/
|
|
31
|
+
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
/** Unique value for this item */
|
|
37
|
+
value: string;
|
|
38
|
+
/** Disable this item */
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AccordionTriggerProps extends React.HTMLAttributes<HTMLButtonElement> {
|
|
43
|
+
children: React.ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
47
|
+
children: React.ReactNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================
|
|
51
|
+
// Context
|
|
52
|
+
// ============================================
|
|
53
|
+
|
|
54
|
+
interface AccordionContextValue {
|
|
55
|
+
type: 'single' | 'multiple';
|
|
56
|
+
openItems: string[];
|
|
57
|
+
toggle: (value: string) => void;
|
|
58
|
+
collapsible: boolean;
|
|
59
|
+
headingLevel: 2 | 3 | 4 | 5 | 6;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
|
|
63
|
+
|
|
64
|
+
interface AccordionItemContextValue {
|
|
65
|
+
value: string;
|
|
66
|
+
isOpen: boolean;
|
|
67
|
+
disabled: boolean;
|
|
68
|
+
triggerId: string;
|
|
69
|
+
contentId: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
|
|
73
|
+
|
|
74
|
+
function useAccordionContext() {
|
|
75
|
+
const context = React.useContext(AccordionContext);
|
|
76
|
+
if (!context) {
|
|
77
|
+
throw new Error('Accordion components must be used within an Accordion');
|
|
78
|
+
}
|
|
79
|
+
return context;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function useAccordionItemContext() {
|
|
83
|
+
const context = React.useContext(AccordionItemContext);
|
|
84
|
+
if (!context) {
|
|
85
|
+
throw new Error('Accordion.Trigger/Content must be used within an Accordion.Item');
|
|
86
|
+
}
|
|
87
|
+
return context;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================
|
|
91
|
+
// Components
|
|
92
|
+
// ============================================
|
|
93
|
+
|
|
94
|
+
function AccordionRoot({
|
|
95
|
+
children,
|
|
96
|
+
type = 'single',
|
|
97
|
+
value,
|
|
98
|
+
defaultValue,
|
|
99
|
+
onValueChange,
|
|
100
|
+
collapsible = false,
|
|
101
|
+
headingLevel = 3,
|
|
102
|
+
className,
|
|
103
|
+
...htmlProps
|
|
104
|
+
}: AccordionProps) {
|
|
105
|
+
// Normalize value to array for internal handling
|
|
106
|
+
const normalizeValue = (val: AccordionValue | undefined): string[] => {
|
|
107
|
+
if (val === undefined) return [];
|
|
108
|
+
return Array.isArray(val) ? val : [val];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const [openItems, setOpenItems] = React.useState<string[]>(() =>
|
|
112
|
+
normalizeValue(defaultValue)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Use controlled value if provided
|
|
116
|
+
const controlledOpenItems = value !== undefined ? normalizeValue(value) : undefined;
|
|
117
|
+
const currentOpenItems = controlledOpenItems ?? openItems;
|
|
118
|
+
|
|
119
|
+
const toggle = React.useCallback((itemValue: string) => {
|
|
120
|
+
const newItems = (() => {
|
|
121
|
+
if (type === 'single') {
|
|
122
|
+
// For single, toggle or set new item
|
|
123
|
+
if (currentOpenItems.includes(itemValue)) {
|
|
124
|
+
return collapsible ? [] : currentOpenItems;
|
|
125
|
+
}
|
|
126
|
+
return [itemValue];
|
|
127
|
+
} else {
|
|
128
|
+
// For multiple, toggle item in array
|
|
129
|
+
if (currentOpenItems.includes(itemValue)) {
|
|
130
|
+
return currentOpenItems.filter(v => v !== itemValue);
|
|
131
|
+
}
|
|
132
|
+
return [...currentOpenItems, itemValue];
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
|
|
136
|
+
if (controlledOpenItems === undefined) {
|
|
137
|
+
setOpenItems(newItems);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (onValueChange) {
|
|
141
|
+
onValueChange(type === 'single' ? (newItems[0] ?? '') : newItems);
|
|
142
|
+
}
|
|
143
|
+
}, [type, currentOpenItems, collapsible, controlledOpenItems, onValueChange]);
|
|
144
|
+
|
|
145
|
+
const classes = [styles.accordion, className].filter(Boolean).join(' ');
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<AccordionContext.Provider value={{ type, openItems: currentOpenItems, toggle, collapsible, headingLevel }}>
|
|
149
|
+
<div {...htmlProps} className={classes} data-orientation="vertical" role="region">
|
|
150
|
+
{children}
|
|
151
|
+
</div>
|
|
152
|
+
</AccordionContext.Provider>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function AccordionItem({
|
|
157
|
+
children,
|
|
158
|
+
value,
|
|
159
|
+
disabled = false,
|
|
160
|
+
className,
|
|
161
|
+
...htmlProps
|
|
162
|
+
}: AccordionItemProps) {
|
|
163
|
+
const { openItems } = useAccordionContext();
|
|
164
|
+
const isOpen = openItems.includes(value);
|
|
165
|
+
const baseId = React.useId();
|
|
166
|
+
const triggerId = `accordion-trigger-${baseId}`;
|
|
167
|
+
const contentId = `accordion-content-${baseId}`;
|
|
168
|
+
|
|
169
|
+
const classes = [
|
|
170
|
+
styles.item,
|
|
171
|
+
isOpen && styles.itemOpen,
|
|
172
|
+
disabled && styles.itemDisabled,
|
|
173
|
+
className,
|
|
174
|
+
].filter(Boolean).join(' ');
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<AccordionItemContext.Provider value={{ value, isOpen, disabled, triggerId, contentId }}>
|
|
178
|
+
<BaseCollapsible.Root open={isOpen} disabled={disabled}>
|
|
179
|
+
<div {...htmlProps} className={classes} data-state={isOpen ? 'open' : 'closed'} data-disabled={disabled || undefined}>
|
|
180
|
+
{children}
|
|
181
|
+
</div>
|
|
182
|
+
</BaseCollapsible.Root>
|
|
183
|
+
</AccordionItemContext.Provider>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function AccordionTrigger({
|
|
188
|
+
children,
|
|
189
|
+
className,
|
|
190
|
+
}: AccordionTriggerProps) {
|
|
191
|
+
const { toggle, headingLevel } = useAccordionContext();
|
|
192
|
+
const { value, isOpen, disabled, triggerId, contentId } = useAccordionItemContext();
|
|
193
|
+
|
|
194
|
+
const handleClick = () => {
|
|
195
|
+
if (!disabled) {
|
|
196
|
+
toggle(value);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const classes = [styles.trigger, className].filter(Boolean).join(' ');
|
|
201
|
+
|
|
202
|
+
// Create the heading element dynamically based on headingLevel
|
|
203
|
+
const HeadingTag = `h${headingLevel}` as 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<HeadingTag className={styles.heading}>
|
|
207
|
+
<BaseCollapsible.Trigger
|
|
208
|
+
id={triggerId}
|
|
209
|
+
className={classes}
|
|
210
|
+
onClick={handleClick}
|
|
211
|
+
aria-expanded={isOpen}
|
|
212
|
+
aria-controls={contentId}
|
|
213
|
+
data-state={isOpen ? 'open' : 'closed'}
|
|
214
|
+
disabled={disabled}
|
|
215
|
+
>
|
|
216
|
+
<span className={styles.triggerContent}>{children}</span>
|
|
217
|
+
<svg
|
|
218
|
+
className={styles.chevron}
|
|
219
|
+
width="16"
|
|
220
|
+
height="16"
|
|
221
|
+
viewBox="0 0 16 16"
|
|
222
|
+
fill="none"
|
|
223
|
+
aria-hidden="true"
|
|
224
|
+
>
|
|
225
|
+
<path
|
|
226
|
+
d="M4 6L8 10L12 6"
|
|
227
|
+
stroke="currentColor"
|
|
228
|
+
strokeWidth="1.5"
|
|
229
|
+
strokeLinecap="round"
|
|
230
|
+
strokeLinejoin="round"
|
|
231
|
+
/>
|
|
232
|
+
</svg>
|
|
233
|
+
</BaseCollapsible.Trigger>
|
|
234
|
+
</HeadingTag>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function AccordionContent({
|
|
239
|
+
children,
|
|
240
|
+
className,
|
|
241
|
+
}: AccordionContentProps) {
|
|
242
|
+
const { isOpen, triggerId, contentId } = useAccordionItemContext();
|
|
243
|
+
|
|
244
|
+
const classes = [styles.content, className].filter(Boolean).join(' ');
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<BaseCollapsible.Panel
|
|
248
|
+
id={contentId}
|
|
249
|
+
className={classes}
|
|
250
|
+
data-state={isOpen ? 'open' : 'closed'}
|
|
251
|
+
role="region"
|
|
252
|
+
aria-labelledby={triggerId}
|
|
253
|
+
>
|
|
254
|
+
<div className={styles.contentInner}>
|
|
255
|
+
{children}
|
|
256
|
+
</div>
|
|
257
|
+
</BaseCollapsible.Panel>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ============================================
|
|
262
|
+
// Export compound component
|
|
263
|
+
// ============================================
|
|
264
|
+
|
|
265
|
+
export const Accordion = Object.assign(AccordionRoot, {
|
|
266
|
+
Item: AccordionItem,
|
|
267
|
+
Trigger: AccordionTrigger,
|
|
268
|
+
Content: AccordionContent,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
export { AccordionRoot, AccordionItem, AccordionTrigger, AccordionContent };
|