@fragments-sdk/ui 0.8.6 → 0.8.7
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 +2 -2
- package/src/blocks/AccountSettings.block.ts +1 -1
- package/src/blocks/ActivityFeed.block.ts +1 -1
- package/src/blocks/ChatInterface.block.ts +1 -1
- package/src/blocks/ChatMessages.block.ts +1 -1
- package/src/blocks/CheckoutForm.block.ts +1 -1
- package/src/blocks/ContactForm.block.ts +1 -1
- package/src/blocks/DashboardLayout.block.ts +1 -1
- package/src/blocks/DashboardPage.block.ts +1 -1
- package/src/blocks/DataTable.block.ts +1 -1
- package/src/blocks/EmptyState.block.ts +1 -1
- package/src/blocks/FAQSection.block.ts +1 -1
- package/src/blocks/FeatureGrid.block.ts +1 -1
- package/src/blocks/HeroSection.block.ts +1 -1
- package/src/blocks/LoginForm.block.ts +1 -1
- package/src/blocks/NavigationHeader.block.ts +1 -1
- package/src/blocks/PricingComparison.block.ts +1 -1
- package/src/blocks/ProductCard.block.ts +1 -1
- package/src/blocks/RegistrationForm.block.ts +1 -1
- package/src/blocks/SettingsPanel.block.ts +1 -1
- package/src/blocks/ShoppingCart.block.ts +1 -1
- package/src/blocks/StatsCard.block.ts +1 -1
- package/src/blocks/ThinkingStates.block.ts +1 -1
- package/src/components/Accordion/Accordion.fragment.tsx +1 -1
- package/src/components/Alert/Alert.fragment.tsx +1 -1
- package/src/components/AppShell/AppShell.fragment.tsx +11 -11
- package/src/components/Avatar/Avatar.fragment.tsx +1 -1
- package/src/components/Badge/Badge.fragment.tsx +1 -1
- package/src/components/Box/Box.fragment.tsx +1 -1
- package/src/components/Breadcrumbs/Breadcrumbs.fragment.tsx +1 -1
- package/src/components/Button/Button.fragment.tsx +1 -1
- package/src/components/ButtonGroup/ButtonGroup.fragment.tsx +1 -1
- package/src/components/Card/Card.fragment.tsx +1 -1
- package/src/components/Chart/Chart.fragment.tsx +1 -1
- package/src/components/Checkbox/Checkbox.fragment.tsx +1 -1
- package/src/components/Chip/Chip.fragment.tsx +1 -1
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +1 -1
- package/src/components/Collapsible/Collapsible.fragment.tsx +1 -1
- package/src/components/ColorPicker/ColorPicker.fragment.tsx +1 -1
- package/src/components/Combobox/Combobox.fragment.tsx +1 -1
- package/src/components/ConversationList/ConversationList.fragment.tsx +1 -1
- package/src/components/DatePicker/DatePicker.fragment.tsx +10 -9
- package/src/components/Dialog/Dialog.fragment.tsx +1 -1
- package/src/components/EmptyState/EmptyState.fragment.tsx +1 -1
- package/src/components/Field/Field.fragment.tsx +1 -1
- package/src/components/Fieldset/Fieldset.fragment.tsx +1 -1
- package/src/components/Form/Form.fragment.tsx +1 -1
- package/src/components/Grid/Grid.fragment.tsx +1 -1
- package/src/components/Header/Header.fragment.tsx +1 -1
- package/src/components/Icon/Icon.fragment.tsx +1 -1
- package/src/components/Image/Image.fragment.tsx +1 -1
- package/src/components/Input/Input.fragment.tsx +1 -1
- package/src/components/Link/Link.fragment.tsx +1 -1
- package/src/components/List/List.fragment.tsx +1 -1
- package/src/components/Listbox/Listbox.fragment.tsx +1 -1
- package/src/components/Loading/Loading.fragment.tsx +1 -1
- package/src/components/Markdown/Markdown.fragment.tsx +1 -1
- package/src/components/Menu/Menu.fragment.tsx +55 -5
- package/src/components/Menu/Menu.module.scss +21 -10
- package/src/components/Menu/Menu.test.tsx +126 -3
- package/src/components/Menu/index.tsx +85 -11
- package/src/components/Message/Message.fragment.tsx +1 -1
- package/src/components/Message/Message.module.scss +2 -1
- package/src/components/NavigationMenu/NavigationMenu.fragment.tsx +1 -1
- package/src/components/Popover/Popover.fragment.tsx +1 -1
- package/src/components/Progress/Progress.fragment.tsx +1 -1
- package/src/components/Prompt/Prompt.fragment.tsx +1 -1
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +1 -1
- package/src/components/ScrollArea/ScrollArea.fragment.tsx +1 -1
- package/src/components/Select/Select.fragment.tsx +1 -1
- package/src/components/Separator/Separator.fragment.tsx +1 -1
- package/src/components/Sidebar/Sidebar.fragment.tsx +2 -2
- package/src/components/Skeleton/Skeleton.fragment.tsx +1 -1
- package/src/components/Slider/Slider.fragment.tsx +1 -1
- package/src/components/Stack/Stack.fragment.tsx +1 -1
- package/src/components/Table/Table.fragment.tsx +1 -1
- package/src/components/TableOfContents/TableOfContents.fragment.tsx +1 -1
- package/src/components/Tabs/Tabs.fragment.tsx +1 -1
- package/src/components/Text/Text.fragment.tsx +1 -1
- package/src/components/Textarea/Textarea.fragment.tsx +1 -1
- package/src/components/Theme/Theme.fragment.tsx +1 -1
- package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +1 -1
- package/src/components/Toast/Toast.fragment.tsx +1 -1
- package/src/components/Toggle/Toggle.fragment.tsx +1 -1
- package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +1 -1
- package/src/components/Tooltip/Tooltip.fragment.tsx +1 -1
- package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +1 -1
- package/src/styles/globals.scss +65 -7
- package/src/tokens/_computed.scss +1 -1
- package/src/tokens/_density.scss +1 -1
- package/src/tokens/_derive.scss +1 -1
- package/src/tokens/_index.scss +1 -1
- package/src/tokens/_mixins.scss +1 -1
- package/src/tokens/_palettes.scss +1 -1
- package/src/tokens/_radius.scss +1 -1
- package/src/tokens/_seeds.scss +1 -1
- package/src/tokens/_variables.scss +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { defineFragment } from '@fragments/core';
|
|
2
|
+
import { defineFragment } from '@fragments-sdk/cli/core';
|
|
3
3
|
import { Menu } from '.';
|
|
4
4
|
import { Button } from '../Button';
|
|
5
5
|
|
|
@@ -8,10 +8,10 @@ export default defineFragment({
|
|
|
8
8
|
|
|
9
9
|
meta: {
|
|
10
10
|
name: 'Menu',
|
|
11
|
-
description: 'Dropdown menu for actions and commands.
|
|
11
|
+
description: 'Dropdown menu for actions and commands. Supports submenus, check items, radio groups, and keyboard shortcuts.',
|
|
12
12
|
category: 'feedback',
|
|
13
13
|
status: 'stable',
|
|
14
|
-
tags: ['menu', 'dropdown', 'actions', 'context-menu', 'commands'],
|
|
14
|
+
tags: ['menu', 'dropdown', 'actions', 'context-menu', 'commands', 'submenu'],
|
|
15
15
|
since: '0.1.0',
|
|
16
16
|
},
|
|
17
17
|
|
|
@@ -21,6 +21,7 @@ export default defineFragment({
|
|
|
21
21
|
'Context menus (right-click)',
|
|
22
22
|
'User account menus',
|
|
23
23
|
'Grouped actions with separators',
|
|
24
|
+
'Nested menus with submenus',
|
|
24
25
|
],
|
|
25
26
|
whenNot: [
|
|
26
27
|
'Selecting from options (use Select)',
|
|
@@ -33,12 +34,15 @@ export default defineFragment({
|
|
|
33
34
|
'Include keyboard shortcuts where applicable',
|
|
34
35
|
'Use danger variant for destructive actions',
|
|
35
36
|
'Keep menu items under 10-12 for usability',
|
|
37
|
+
'Use checked prop on Menu.Item for simple selection state',
|
|
38
|
+
'Use Menu.Submenu for nested secondary options',
|
|
36
39
|
],
|
|
37
40
|
accessibility: [
|
|
38
41
|
'Full keyboard navigation with arrow keys',
|
|
39
42
|
'Type-ahead search for items',
|
|
40
43
|
'Focus returns to trigger on close',
|
|
41
44
|
'Proper ARIA menu roles',
|
|
45
|
+
'ArrowRight opens submenus, ArrowLeft closes them',
|
|
42
46
|
],
|
|
43
47
|
},
|
|
44
48
|
|
|
@@ -79,6 +83,7 @@ export default defineFragment({
|
|
|
79
83
|
'onOpenChange: (open) => void - state handler',
|
|
80
84
|
'Menu.Item danger: boolean - destructive styling',
|
|
81
85
|
'Menu.Item shortcut: string - keyboard shortcut text',
|
|
86
|
+
'Menu.Item checked: boolean - check indicator for selection state',
|
|
82
87
|
],
|
|
83
88
|
scenarioTags: [
|
|
84
89
|
'action.menu',
|
|
@@ -90,7 +95,7 @@ export default defineFragment({
|
|
|
90
95
|
|
|
91
96
|
ai: {
|
|
92
97
|
compositionPattern: 'compound',
|
|
93
|
-
subComponents: ['Trigger', 'Content', 'Item', 'CheckboxItem', 'RadioGroup', 'RadioItem', 'Group', 'GroupLabel', 'Separator'],
|
|
98
|
+
subComponents: ['Trigger', 'Content', 'Item', 'CheckboxItem', 'RadioGroup', 'RadioItem', 'Group', 'GroupLabel', 'Separator', 'Submenu', 'SubmenuTrigger'],
|
|
94
99
|
requiredChildren: ['Trigger', 'Content'],
|
|
95
100
|
commonPatterns: [
|
|
96
101
|
'<Menu><Menu.Trigger asChild><Button>Actions</Button></Menu.Trigger><Menu.Content><Menu.Item>{action1}</Menu.Item><Menu.Separator /><Menu.Item danger>{delete}</Menu.Item></Menu.Content></Menu>',
|
|
@@ -159,7 +164,7 @@ export default defineFragment({
|
|
|
159
164
|
},
|
|
160
165
|
{
|
|
161
166
|
name: 'With Checkboxes',
|
|
162
|
-
description: 'Menu with toggleable options',
|
|
167
|
+
description: 'Menu with toggleable checkbox options',
|
|
163
168
|
render: () => (
|
|
164
169
|
<Menu>
|
|
165
170
|
<Menu.Trigger asChild>
|
|
@@ -173,5 +178,50 @@ export default defineFragment({
|
|
|
173
178
|
</Menu>
|
|
174
179
|
),
|
|
175
180
|
},
|
|
181
|
+
{
|
|
182
|
+
name: 'With Checked Items',
|
|
183
|
+
description: 'Filter menu with check marks indicating active selections',
|
|
184
|
+
render: () => {
|
|
185
|
+
const [view, setView] = React.useState('grid');
|
|
186
|
+
return (
|
|
187
|
+
<Menu>
|
|
188
|
+
<Menu.Trigger asChild>
|
|
189
|
+
<Button variant="secondary">View</Button>
|
|
190
|
+
</Menu.Trigger>
|
|
191
|
+
<Menu.Content>
|
|
192
|
+
<Menu.Item checked={view === 'grid'} onSelect={() => setView('grid')}>Grid</Menu.Item>
|
|
193
|
+
<Menu.Item checked={view === 'list'} onSelect={() => setView('list')}>List</Menu.Item>
|
|
194
|
+
<Menu.Item checked={view === 'board'} onSelect={() => setView('board')}>Board</Menu.Item>
|
|
195
|
+
</Menu.Content>
|
|
196
|
+
</Menu>
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'With Submenu',
|
|
202
|
+
description: 'Menu with nested submenu for grouped secondary options',
|
|
203
|
+
render: () => (
|
|
204
|
+
<Menu>
|
|
205
|
+
<Menu.Trigger asChild>
|
|
206
|
+
<Button variant="secondary">File</Button>
|
|
207
|
+
</Menu.Trigger>
|
|
208
|
+
<Menu.Content>
|
|
209
|
+
<Menu.Item onSelect={() => {}}>New File</Menu.Item>
|
|
210
|
+
<Menu.Item onSelect={() => {}}>Open</Menu.Item>
|
|
211
|
+
<Menu.Separator />
|
|
212
|
+
<Menu.Submenu>
|
|
213
|
+
<Menu.SubmenuTrigger>Export As</Menu.SubmenuTrigger>
|
|
214
|
+
<Menu.Content side="right" align="start">
|
|
215
|
+
<Menu.Item onSelect={() => {}}>PNG</Menu.Item>
|
|
216
|
+
<Menu.Item onSelect={() => {}}>SVG</Menu.Item>
|
|
217
|
+
<Menu.Item onSelect={() => {}}>PDF</Menu.Item>
|
|
218
|
+
</Menu.Content>
|
|
219
|
+
</Menu.Submenu>
|
|
220
|
+
<Menu.Separator />
|
|
221
|
+
<Menu.Item danger onSelect={() => {}}>Delete</Menu.Item>
|
|
222
|
+
</Menu.Content>
|
|
223
|
+
</Menu>
|
|
224
|
+
),
|
|
225
|
+
},
|
|
176
226
|
],
|
|
177
227
|
});
|
|
@@ -101,13 +101,30 @@
|
|
|
101
101
|
margin-left: auto;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
//
|
|
105
|
-
.
|
|
104
|
+
// Check indicator (checkmark icon for checked items)
|
|
105
|
+
.checkIndicator {
|
|
106
106
|
display: flex;
|
|
107
107
|
align-items: center;
|
|
108
108
|
justify-content: center;
|
|
109
109
|
width: 1rem;
|
|
110
110
|
height: 1rem;
|
|
111
|
+
flex-shrink: 0;
|
|
112
|
+
color: var(--fui-color-accent, $fui-color-accent);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Checkbox item
|
|
116
|
+
.checkboxItem {
|
|
117
|
+
// Uses .checkIndicator for checked state visual
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Radio indicator (still uses icon)
|
|
121
|
+
.radioIndicator {
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
justify-content: center;
|
|
125
|
+
width: 1rem;
|
|
126
|
+
height: 1rem;
|
|
127
|
+
visibility: hidden;
|
|
111
128
|
|
|
112
129
|
svg {
|
|
113
130
|
width: 0.75rem;
|
|
@@ -115,16 +132,10 @@
|
|
|
115
132
|
}
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
// Checkbox item states
|
|
119
|
-
.checkboxItem {
|
|
120
|
-
&[data-checked] .itemIndicator {
|
|
121
|
-
color: var(--fui-color-accent, $fui-color-accent);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
135
|
// Radio item states
|
|
126
136
|
.radioItem {
|
|
127
|
-
&[data-checked] .
|
|
137
|
+
&[data-checked] .radioIndicator {
|
|
138
|
+
visibility: visible;
|
|
128
139
|
color: var(--fui-color-accent, $fui-color-accent);
|
|
129
140
|
}
|
|
130
141
|
}
|
|
@@ -73,23 +73,30 @@ describe('Menu', () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
it('renders checkbox items', async () => {
|
|
76
|
+
it('renders checkbox items with check icon', async () => {
|
|
77
77
|
const onCheckedChange = vi.fn();
|
|
78
|
-
const user = userEvent.setup();
|
|
79
78
|
render(
|
|
80
79
|
<Menu defaultOpen>
|
|
81
80
|
<Menu.Trigger>Open</Menu.Trigger>
|
|
82
81
|
<Menu.Content>
|
|
83
|
-
<Menu.CheckboxItem checked={
|
|
82
|
+
<Menu.CheckboxItem checked={true} onCheckedChange={onCheckedChange}>
|
|
84
83
|
Show toolbar
|
|
85
84
|
</Menu.CheckboxItem>
|
|
85
|
+
<Menu.CheckboxItem checked={false}>
|
|
86
|
+
Show sidebar
|
|
87
|
+
</Menu.CheckboxItem>
|
|
86
88
|
</Menu.Content>
|
|
87
89
|
</Menu>
|
|
88
90
|
);
|
|
89
91
|
|
|
90
92
|
await waitFor(() => {
|
|
91
93
|
expect(screen.getByText('Show toolbar')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByText('Show sidebar')).toBeInTheDocument();
|
|
92
95
|
});
|
|
96
|
+
|
|
97
|
+
// Checked item should have a checkmark SVG
|
|
98
|
+
const checkedItem = screen.getByText('Show toolbar').closest('[role="menuitemcheckbox"]');
|
|
99
|
+
expect(checkedItem?.querySelector('svg')).toBeInTheDocument();
|
|
93
100
|
});
|
|
94
101
|
|
|
95
102
|
it('renders radio group items', async () => {
|
|
@@ -142,6 +149,122 @@ describe('Menu', () => {
|
|
|
142
149
|
});
|
|
143
150
|
});
|
|
144
151
|
|
|
152
|
+
describe('checked items', () => {
|
|
153
|
+
it('renders check indicator when checked={true}', async () => {
|
|
154
|
+
render(
|
|
155
|
+
<Menu defaultOpen>
|
|
156
|
+
<Menu.Trigger>Open</Menu.Trigger>
|
|
157
|
+
<Menu.Content>
|
|
158
|
+
<Menu.Item checked={true} onSelect={() => {}}>Grid</Menu.Item>
|
|
159
|
+
</Menu.Content>
|
|
160
|
+
</Menu>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.getByText('Grid')).toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const item = screen.getByText('Grid').closest('[role="menuitem"]');
|
|
168
|
+
expect(item?.querySelector('svg')).toBeInTheDocument();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('reserves space but shows no icon when checked={false}', async () => {
|
|
172
|
+
render(
|
|
173
|
+
<Menu defaultOpen>
|
|
174
|
+
<Menu.Trigger>Open</Menu.Trigger>
|
|
175
|
+
<Menu.Content>
|
|
176
|
+
<Menu.Item checked={false} onSelect={() => {}}>List</Menu.Item>
|
|
177
|
+
</Menu.Content>
|
|
178
|
+
</Menu>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
expect(screen.getByText('List')).toBeInTheDocument();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const item = screen.getByText('List').closest('[role="menuitem"]');
|
|
186
|
+
// Should not have a checkmark SVG
|
|
187
|
+
expect(item?.querySelector('svg')).not.toBeInTheDocument();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does not render check indicator when checked is omitted', async () => {
|
|
191
|
+
const { container } = render(
|
|
192
|
+
<Menu defaultOpen>
|
|
193
|
+
<Menu.Trigger>Open</Menu.Trigger>
|
|
194
|
+
<Menu.Content>
|
|
195
|
+
<Menu.Item onSelect={() => {}}>Normal Item</Menu.Item>
|
|
196
|
+
</Menu.Content>
|
|
197
|
+
</Menu>
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await waitFor(() => {
|
|
201
|
+
expect(screen.getByText('Normal Item')).toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const item = screen.getByText('Normal Item').closest('[role="menuitem"]');
|
|
205
|
+
// Should not have any SVG or check indicator
|
|
206
|
+
expect(item?.querySelector('svg')).not.toBeInTheDocument();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('submenu', () => {
|
|
211
|
+
function renderSubmenu(props: Partial<React.ComponentProps<typeof Menu>> = {}) {
|
|
212
|
+
return render(
|
|
213
|
+
<Menu {...props}>
|
|
214
|
+
<Menu.Trigger>Open Menu</Menu.Trigger>
|
|
215
|
+
<Menu.Content>
|
|
216
|
+
<Menu.Item onSelect={() => {}}>New File</Menu.Item>
|
|
217
|
+
<Menu.Submenu>
|
|
218
|
+
<Menu.SubmenuTrigger>Export As</Menu.SubmenuTrigger>
|
|
219
|
+
<Menu.Content side="right" align="start">
|
|
220
|
+
<Menu.Item onSelect={() => {}}>PNG</Menu.Item>
|
|
221
|
+
<Menu.Item onSelect={() => {}}>SVG</Menu.Item>
|
|
222
|
+
</Menu.Content>
|
|
223
|
+
</Menu.Submenu>
|
|
224
|
+
</Menu.Content>
|
|
225
|
+
</Menu>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
it('renders submenu trigger', async () => {
|
|
230
|
+
const user = userEvent.setup();
|
|
231
|
+
renderSubmenu();
|
|
232
|
+
|
|
233
|
+
await user.click(screen.getByRole('button', { name: /open menu/i }));
|
|
234
|
+
await waitFor(() => {
|
|
235
|
+
expect(screen.getByText('Export As')).toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('opens submenu on click', async () => {
|
|
240
|
+
const user = userEvent.setup();
|
|
241
|
+
renderSubmenu();
|
|
242
|
+
|
|
243
|
+
await user.click(screen.getByRole('button', { name: /open menu/i }));
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(screen.getByText('Export As')).toBeInTheDocument();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await user.click(screen.getByText('Export As'));
|
|
249
|
+
await waitFor(() => {
|
|
250
|
+
expect(screen.getByText('PNG')).toBeInTheDocument();
|
|
251
|
+
expect(screen.getByText('SVG')).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('has no accessibility violations', async () => {
|
|
256
|
+
const { container } = renderSubmenu({ defaultOpen: true });
|
|
257
|
+
|
|
258
|
+
await waitFor(() => {
|
|
259
|
+
expect(screen.getByText('Export As')).toBeInTheDocument();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await expectNoA11yViolations(container, {
|
|
263
|
+
disabledRules: ['aria-command-name'],
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
145
268
|
describe('keyboard & focus', () => {
|
|
146
269
|
/**
|
|
147
270
|
* Opens the menu by clicking the trigger and waits for it to be present.
|
|
@@ -37,6 +37,8 @@ export interface MenuItemProps {
|
|
|
37
37
|
className?: string;
|
|
38
38
|
icon?: React.ReactNode;
|
|
39
39
|
shortcut?: string;
|
|
40
|
+
/** When passed, renders a check indicator. `true` shows a checkmark, `false` reserves space. */
|
|
41
|
+
checked?: boolean;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface MenuCheckboxItemProps {
|
|
@@ -76,25 +78,39 @@ export interface MenuSeparatorProps {
|
|
|
76
78
|
className?: string;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
export interface MenuSubmenuProps {
|
|
82
|
+
children: React.ReactNode;
|
|
83
|
+
open?: boolean;
|
|
84
|
+
defaultOpen?: boolean;
|
|
85
|
+
onOpenChange?: (open: boolean) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface MenuSubmenuTriggerProps {
|
|
89
|
+
children: React.ReactNode;
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
className?: string;
|
|
92
|
+
icon?: React.ReactNode;
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
// ============================================
|
|
80
96
|
// Icons
|
|
81
97
|
// ============================================
|
|
82
98
|
|
|
83
|
-
function
|
|
99
|
+
function CheckmarkIcon() {
|
|
84
100
|
return (
|
|
85
101
|
<svg
|
|
86
102
|
xmlns="http://www.w3.org/2000/svg"
|
|
87
|
-
width="
|
|
88
|
-
height="
|
|
103
|
+
width="14"
|
|
104
|
+
height="14"
|
|
89
105
|
viewBox="0 0 24 24"
|
|
90
106
|
fill="none"
|
|
91
107
|
stroke="currentColor"
|
|
92
|
-
strokeWidth=
|
|
108
|
+
strokeWidth={3}
|
|
93
109
|
strokeLinecap="round"
|
|
94
110
|
strokeLinejoin="round"
|
|
95
111
|
aria-hidden="true"
|
|
96
112
|
>
|
|
97
|
-
<
|
|
113
|
+
<path d="M5 13l4 4L19 7" />
|
|
98
114
|
</svg>
|
|
99
115
|
);
|
|
100
116
|
}
|
|
@@ -187,7 +203,9 @@ function MenuItem({
|
|
|
187
203
|
className,
|
|
188
204
|
icon,
|
|
189
205
|
shortcut,
|
|
206
|
+
checked,
|
|
190
207
|
}: MenuItemProps) {
|
|
208
|
+
const hasChecked = checked !== undefined;
|
|
191
209
|
const classes = [
|
|
192
210
|
styles.item,
|
|
193
211
|
danger && styles.itemDanger,
|
|
@@ -200,6 +218,11 @@ function MenuItem({
|
|
|
200
218
|
onClick={onSelect}
|
|
201
219
|
className={classes}
|
|
202
220
|
>
|
|
221
|
+
{hasChecked && (
|
|
222
|
+
<span className={styles.checkIndicator}>
|
|
223
|
+
{checked ? <CheckmarkIcon /> : null}
|
|
224
|
+
</span>
|
|
225
|
+
)}
|
|
203
226
|
{icon && <span className={styles.itemIcon}>{icon}</span>}
|
|
204
227
|
<span className={styles.itemLabel}>{children}</span>
|
|
205
228
|
{shortcut && <span className={styles.itemShortcut}>{shortcut}</span>}
|
|
@@ -209,26 +232,38 @@ function MenuItem({
|
|
|
209
232
|
|
|
210
233
|
function MenuCheckboxItem({
|
|
211
234
|
children,
|
|
212
|
-
checked,
|
|
235
|
+
checked: checkedProp,
|
|
213
236
|
defaultChecked,
|
|
214
237
|
onCheckedChange,
|
|
215
238
|
disabled,
|
|
216
239
|
className,
|
|
217
240
|
}: MenuCheckboxItemProps) {
|
|
241
|
+
const isControlled = checkedProp !== undefined;
|
|
242
|
+
const [internalChecked, setInternalChecked] = React.useState(defaultChecked ?? false);
|
|
243
|
+
const visualChecked = isControlled ? checkedProp : internalChecked;
|
|
244
|
+
|
|
245
|
+
const handleCheckedChange = React.useCallback(
|
|
246
|
+
(value: boolean) => {
|
|
247
|
+
if (!isControlled) setInternalChecked(value);
|
|
248
|
+
onCheckedChange?.(value);
|
|
249
|
+
},
|
|
250
|
+
[isControlled, onCheckedChange],
|
|
251
|
+
);
|
|
252
|
+
|
|
218
253
|
const classes = [styles.item, styles.checkboxItem, className]
|
|
219
254
|
.filter(Boolean)
|
|
220
255
|
.join(' ');
|
|
221
256
|
|
|
222
257
|
return (
|
|
223
258
|
<BaseMenu.CheckboxItem
|
|
224
|
-
checked={
|
|
259
|
+
checked={checkedProp}
|
|
225
260
|
defaultChecked={defaultChecked}
|
|
226
|
-
onCheckedChange={
|
|
261
|
+
onCheckedChange={handleCheckedChange}
|
|
227
262
|
disabled={disabled}
|
|
228
263
|
className={classes}
|
|
229
264
|
>
|
|
230
|
-
<span className={styles.
|
|
231
|
-
<
|
|
265
|
+
<span className={styles.checkIndicator}>
|
|
266
|
+
{visualChecked ? <CheckmarkIcon /> : null}
|
|
232
267
|
</span>
|
|
233
268
|
<span className={styles.itemLabel}>{children}</span>
|
|
234
269
|
</BaseMenu.CheckboxItem>
|
|
@@ -264,7 +299,7 @@ function MenuRadioItem({
|
|
|
264
299
|
|
|
265
300
|
return (
|
|
266
301
|
<BaseMenu.RadioItem value={value} disabled={disabled} className={classes}>
|
|
267
|
-
<span className={styles.
|
|
302
|
+
<span className={styles.radioIndicator}>
|
|
268
303
|
<DotIcon />
|
|
269
304
|
</span>
|
|
270
305
|
<span className={styles.itemLabel}>{children}</span>
|
|
@@ -287,6 +322,41 @@ function MenuSeparator({ className }: MenuSeparatorProps) {
|
|
|
287
322
|
return <BaseMenu.Separator className={classes} />;
|
|
288
323
|
}
|
|
289
324
|
|
|
325
|
+
function MenuSubmenu({
|
|
326
|
+
children,
|
|
327
|
+
open,
|
|
328
|
+
defaultOpen,
|
|
329
|
+
onOpenChange,
|
|
330
|
+
}: MenuSubmenuProps) {
|
|
331
|
+
return (
|
|
332
|
+
<BaseMenu.SubmenuRoot
|
|
333
|
+
open={open}
|
|
334
|
+
defaultOpen={defaultOpen}
|
|
335
|
+
onOpenChange={onOpenChange as any}
|
|
336
|
+
>
|
|
337
|
+
{children}
|
|
338
|
+
</BaseMenu.SubmenuRoot>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function MenuSubmenuTrigger({
|
|
343
|
+
children,
|
|
344
|
+
disabled,
|
|
345
|
+
className,
|
|
346
|
+
icon,
|
|
347
|
+
}: MenuSubmenuTriggerProps) {
|
|
348
|
+
const classes = [styles.item, styles.submenuTrigger, className]
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
.join(' ');
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<BaseMenu.SubmenuTrigger disabled={disabled} className={classes}>
|
|
354
|
+
{icon && <span className={styles.itemIcon}>{icon}</span>}
|
|
355
|
+
<span className={styles.itemLabel}>{children}</span>
|
|
356
|
+
</BaseMenu.SubmenuTrigger>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
290
360
|
// ============================================
|
|
291
361
|
// Export compound component
|
|
292
362
|
// ============================================
|
|
@@ -301,6 +371,8 @@ export const Menu = Object.assign(MenuRoot, {
|
|
|
301
371
|
Group: MenuGroup,
|
|
302
372
|
GroupLabel: MenuGroupLabel,
|
|
303
373
|
Separator: MenuSeparator,
|
|
374
|
+
Submenu: MenuSubmenu,
|
|
375
|
+
SubmenuTrigger: MenuSubmenuTrigger,
|
|
304
376
|
});
|
|
305
377
|
|
|
306
378
|
// Re-export individual components
|
|
@@ -315,4 +387,6 @@ export {
|
|
|
315
387
|
MenuGroup,
|
|
316
388
|
MenuGroupLabel,
|
|
317
389
|
MenuSeparator,
|
|
390
|
+
MenuSubmenu,
|
|
391
|
+
MenuSubmenuTrigger,
|
|
318
392
|
};
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
.content {
|
|
38
38
|
background-color: var(--fui-color-accent, $fui-color-accent);
|
|
39
|
-
color: var(--fui-
|
|
39
|
+
color: var(--fui-text-inverse, $fui-text-inverse);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
.content {
|
|
69
69
|
background-color: var(--fui-color-danger-bg, $fui-color-danger-bg);
|
|
70
70
|
border: 1px solid var(--fui-color-danger, $fui-color-danger);
|
|
71
|
+
color: var(--fui-color-danger-text, $fui-color-danger-text);
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { defineFragment } from '@fragments/core';
|
|
2
|
+
import { defineFragment } from '@fragments-sdk/cli/core';
|
|
3
3
|
import { ScrollArea } from '.';
|
|
4
4
|
|
|
5
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'];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import { defineFragment } from '@fragments/core';
|
|
2
|
+
import { defineFragment } from '@fragments-sdk/cli/core';
|
|
3
3
|
import { Sidebar, SidebarProvider, useSidebar } from '.';
|
|
4
4
|
import { Button } from '../Button';
|
|
5
5
|
|
|
@@ -81,7 +81,7 @@ const mainContentStyle: React.CSSProperties = {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
const demoContainerStyle: React.CSSProperties = {
|
|
84
|
-
|
|
84
|
+
minHeight: '100vh',
|
|
85
85
|
display: 'flex',
|
|
86
86
|
width: '100%',
|
|
87
87
|
};
|