@sovereignfs/ui 0.41.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +49 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/MobileAppsDrawer.module.css +81 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +63 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/PageContainer.module.css +20 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/SwipableMobileCarousel.module.css +55 -0
- package/dist/SwipableMobileCarouselDots.module.css +49 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +1268 -8
- package/dist/index.js +3046 -975
- package/dist/tokens/primitives.css +47 -8
- package/package.json +13 -9
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +31 -9
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
- package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
- package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
- package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/Dialog/Dialog.tsx +8 -59
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +18 -56
- package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +16 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/LegalLinks/LegalLinks.module.css +24 -0
- package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
- package/src/components/LegalLinks/LegalLinks.tsx +40 -0
- package/src/components/Markdown/Markdown.module.css +49 -0
- package/src/components/Markdown/Markdown.stories.tsx +30 -0
- package/src/components/Markdown/Markdown.tsx +168 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
- package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
- package/src/components/MobileFooter/MobileFooter.module.css +73 -0
- package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
- package/src/components/MobileFooter/MobileFooter.tsx +109 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
- package/src/components/MobileHeader/MobileHeader.module.css +63 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +77 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/PageContainer/PageContainer.module.css +20 -0
- package/src/components/PageContainer/PageContainer.tsx +31 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
- package/src/components/PageHeader/PageHeader.tsx +16 -2
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Sheet/Sheet.tsx +9 -51
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
- package/src/components/SwipableMobileCarousel/context.ts +13 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
- package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
- package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/index.ts +18 -0
- package/src/hooks/useCarouselRouteSync.ts +92 -0
- package/src/hooks/useIsOffline.ts +41 -0
- package/src/hooks/useOfflineTileState.ts +45 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +79 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/index.ts +141 -3
- package/src/overlay-shell.ts +94 -0
- package/src/stories/DesignSystemOverview.stories.tsx +936 -3
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/MobilePatterns.stories.tsx +255 -4
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +37 -0
- package/src/stories/PageHeader.stories.tsx +11 -0
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
- package/src/stories/TokenGallery.stories.tsx +177 -16
- package/src/tokens/primitives.css +47 -8
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { Command } from './Command';
|
|
4
|
+
import { Button } from '../Button/Button';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/Command',
|
|
8
|
+
component: Command,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
'⌘K-style command palette. Opened via Dialog, which supplies the modal shell — this adds the search input, substring filtering, and arrow-key/Enter selection on top of it. Controlled: the consumer owns the open state and any global shortcut listener that flips it.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
args: { open: false, onClose: () => {}, items: [] },
|
|
19
|
+
} satisfies Meta<typeof Command>;
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function CommandDemo() {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const [lastSelected, setLastSelected] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div>
|
|
32
|
+
<Button onClick={() => setOpen(true)}>Open command palette</Button>
|
|
33
|
+
{lastSelected && (
|
|
34
|
+
<p
|
|
35
|
+
style={{
|
|
36
|
+
marginTop: 'var(--sv-space-3)',
|
|
37
|
+
fontSize: 14,
|
|
38
|
+
color: 'var(--sv-color-text-muted)',
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
Last selected: {lastSelected}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
<Command
|
|
45
|
+
open={open}
|
|
46
|
+
onClose={() => setOpen(false)}
|
|
47
|
+
aria-label="Command palette"
|
|
48
|
+
items={[
|
|
49
|
+
{
|
|
50
|
+
id: 'new',
|
|
51
|
+
label: 'New conversation',
|
|
52
|
+
group: 'Actions',
|
|
53
|
+
icon: 'plus',
|
|
54
|
+
onSelect: () => setLastSelected('New conversation'),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'export',
|
|
58
|
+
label: 'Export chat',
|
|
59
|
+
group: 'Actions',
|
|
60
|
+
icon: 'upload',
|
|
61
|
+
keywords: 'download save',
|
|
62
|
+
onSelect: () => setLastSelected('Export chat'),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'profile',
|
|
66
|
+
label: 'Go to profile',
|
|
67
|
+
group: 'Navigation',
|
|
68
|
+
icon: 'user',
|
|
69
|
+
onSelect: () => setLastSelected('Go to profile'),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'settings',
|
|
73
|
+
label: 'Open settings',
|
|
74
|
+
group: 'Navigation',
|
|
75
|
+
icon: 'settings',
|
|
76
|
+
onSelect: () => setLastSelected('Open settings'),
|
|
77
|
+
},
|
|
78
|
+
]}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const Default: Story = {
|
|
85
|
+
render: () => <CommandDemo />,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const OpenWithNoResults: Story = {
|
|
89
|
+
render: () => {
|
|
90
|
+
function Demo() {
|
|
91
|
+
const [open, setOpen] = useState(true);
|
|
92
|
+
return (
|
|
93
|
+
<Command
|
|
94
|
+
open={open}
|
|
95
|
+
onClose={() => setOpen(false)}
|
|
96
|
+
aria-label="Command palette"
|
|
97
|
+
placeholder="Try typing something with no matches…"
|
|
98
|
+
items={[{ id: 'a', label: 'Alpha', onSelect: () => {} }]}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return <Demo />;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
5
|
+
import { Dialog } from '../Dialog/Dialog';
|
|
6
|
+
import { Icon, type IconName } from '../Icon/Icon';
|
|
7
|
+
import styles from './Command.module.css';
|
|
8
|
+
|
|
9
|
+
export interface CommandItem {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
onSelect: () => void;
|
|
13
|
+
icon?: IconName;
|
|
14
|
+
/** Section header this item is grouped under. */
|
|
15
|
+
group?: string;
|
|
16
|
+
/** Extra searchable text beyond the label (keywords, description). */
|
|
17
|
+
keywords?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CommandProps {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
items: CommandItem[];
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
'aria-label'?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Case-insensitive substring match against label + keywords. Not a scored
|
|
29
|
+
* fuzzy matcher — a real fuzzy-match algorithm is a meaningfully bigger
|
|
30
|
+
* undertaking than this primitive needs until a consumer's command list
|
|
31
|
+
* is large enough that substring filtering stops being good enough. */
|
|
32
|
+
function matches(item: CommandItem, query: string): boolean {
|
|
33
|
+
if (!query) return true;
|
|
34
|
+
const haystack = `${item.label} ${item.keywords ?? ''}`.toLowerCase();
|
|
35
|
+
return haystack.includes(query.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Command — a ⌘K-style palette. Opened via `Dialog`, which supplies the
|
|
40
|
+
* modal shell (scrim, panel, mobile full-screen sheet, focus handling) —
|
|
41
|
+
* this only adds the search input, filtering, and arrow-key/Enter
|
|
42
|
+
* selection on top of it. Controlled (`open`/`onClose`): the consumer wires
|
|
43
|
+
* up their own global shortcut listener to flip `open` — a DS primitive
|
|
44
|
+
* shouldn't own a document-level keydown listener itself.
|
|
45
|
+
*/
|
|
46
|
+
export function Command({
|
|
47
|
+
open,
|
|
48
|
+
onClose,
|
|
49
|
+
items,
|
|
50
|
+
placeholder = 'Type a command…',
|
|
51
|
+
'aria-label': ariaLabel = 'Command palette',
|
|
52
|
+
}: CommandProps) {
|
|
53
|
+
const [query, setQuery] = useState('');
|
|
54
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
55
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
56
|
+
const listId = useId();
|
|
57
|
+
|
|
58
|
+
const filtered = useMemo(() => items.filter((item) => matches(item, query)), [items, query]);
|
|
59
|
+
|
|
60
|
+
// Reset on every open, and whenever the query narrows/widens the result
|
|
61
|
+
// set — the top match should always be the one Enter would select.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setHighlightedIndex(0);
|
|
64
|
+
}, [query, open]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (open) {
|
|
68
|
+
setQuery('');
|
|
69
|
+
// Dialog mounts its content in the same tick it flips `open`; focus
|
|
70
|
+
// needs the next paint to land on an element that actually exists.
|
|
71
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
72
|
+
}
|
|
73
|
+
}, [open]);
|
|
74
|
+
|
|
75
|
+
function handleKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
|
|
76
|
+
if (e.key === 'ArrowDown') {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
setHighlightedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
|
79
|
+
} else if (e.key === 'ArrowUp') {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
setHighlightedIndex((i) => Math.max(i - 1, 0));
|
|
82
|
+
} else if (e.key === 'Enter') {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
const item = filtered[highlightedIndex];
|
|
85
|
+
if (item) {
|
|
86
|
+
onClose();
|
|
87
|
+
item.onSelect();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Group items in their original order, without moving an item's position
|
|
93
|
+
// relative to its own group even if groups aren't contiguous in `items`.
|
|
94
|
+
const groups = useMemo(() => {
|
|
95
|
+
const order: (string | undefined)[] = [];
|
|
96
|
+
const byGroup = new Map<string | undefined, CommandItem[]>();
|
|
97
|
+
for (const item of filtered) {
|
|
98
|
+
let bucket = byGroup.get(item.group);
|
|
99
|
+
if (!bucket) {
|
|
100
|
+
bucket = [];
|
|
101
|
+
order.push(item.group);
|
|
102
|
+
byGroup.set(item.group, bucket);
|
|
103
|
+
}
|
|
104
|
+
bucket.push(item);
|
|
105
|
+
}
|
|
106
|
+
return order.map((group) => ({ group, items: byGroup.get(group) ?? [] }));
|
|
107
|
+
}, [filtered]);
|
|
108
|
+
|
|
109
|
+
const highlightedId = filtered[highlightedIndex]
|
|
110
|
+
? `${listId}-${filtered[highlightedIndex].id}`
|
|
111
|
+
: undefined;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Dialog open={open} onClose={onClose} aria-label={ariaLabel} size="sm">
|
|
115
|
+
<div className={styles.root}>
|
|
116
|
+
<input
|
|
117
|
+
ref={inputRef}
|
|
118
|
+
type="text"
|
|
119
|
+
role="combobox"
|
|
120
|
+
aria-expanded={filtered.length > 0}
|
|
121
|
+
aria-controls={listId}
|
|
122
|
+
aria-activedescendant={highlightedId}
|
|
123
|
+
aria-autocomplete="list"
|
|
124
|
+
className={styles.input}
|
|
125
|
+
placeholder={placeholder}
|
|
126
|
+
value={query}
|
|
127
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
128
|
+
onKeyDown={handleKeyDown}
|
|
129
|
+
/>
|
|
130
|
+
<ul id={listId} role="listbox" className={styles.list}>
|
|
131
|
+
{filtered.length === 0 && <li className={styles.empty}>No matching commands.</li>}
|
|
132
|
+
{groups.map(({ group, items: groupItems }) => (
|
|
133
|
+
<li key={group ?? '__ungrouped'}>
|
|
134
|
+
{group && <div className={styles.groupLabel}>{group}</div>}
|
|
135
|
+
<ul className={styles.groupList}>
|
|
136
|
+
{groupItems.map((item) => {
|
|
137
|
+
const index = filtered.indexOf(item);
|
|
138
|
+
const isHighlighted = index === highlightedIndex;
|
|
139
|
+
return (
|
|
140
|
+
<li key={item.id}>
|
|
141
|
+
<button
|
|
142
|
+
id={`${listId}-${item.id}`}
|
|
143
|
+
type="button"
|
|
144
|
+
role="option"
|
|
145
|
+
aria-selected={isHighlighted}
|
|
146
|
+
className={[styles.item, isHighlighted ? styles.itemHighlighted : '']
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join(' ')}
|
|
149
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
150
|
+
onClick={() => {
|
|
151
|
+
onClose();
|
|
152
|
+
item.onSelect();
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{item.icon && <Icon name={item.icon} size="sm" aria-hidden />}
|
|
156
|
+
{item.label}
|
|
157
|
+
</button>
|
|
158
|
+
</li>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</ul>
|
|
162
|
+
</li>
|
|
163
|
+
))}
|
|
164
|
+
</ul>
|
|
165
|
+
</div>
|
|
166
|
+
</Dialog>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { Command, type CommandItem } from '../Command';
|
|
5
|
+
|
|
6
|
+
// Command renders inside Dialog, whose exit animation reads
|
|
7
|
+
// prefers-reduced-motion via matchMedia, which jsdom does not implement —
|
|
8
|
+
// see Dialog.test.tsx's identical setup.
|
|
9
|
+
function installMatchMedia() {
|
|
10
|
+
vi.stubGlobal(
|
|
11
|
+
'matchMedia',
|
|
12
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
13
|
+
matches: false,
|
|
14
|
+
media: query,
|
|
15
|
+
addEventListener: vi.fn(),
|
|
16
|
+
removeEventListener: vi.fn(),
|
|
17
|
+
addListener: vi.fn(),
|
|
18
|
+
removeListener: vi.fn(),
|
|
19
|
+
dispatchEvent: vi.fn(),
|
|
20
|
+
})),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(installMatchMedia);
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
cleanup();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const items: CommandItem[] = [
|
|
31
|
+
{ id: 'new', label: 'New conversation', onSelect: vi.fn(), group: 'Actions' },
|
|
32
|
+
{
|
|
33
|
+
id: 'export',
|
|
34
|
+
label: 'Export chat',
|
|
35
|
+
onSelect: vi.fn(),
|
|
36
|
+
group: 'Actions',
|
|
37
|
+
keywords: 'download save',
|
|
38
|
+
},
|
|
39
|
+
{ id: 'profile', label: 'Go to profile', onSelect: vi.fn(), group: 'Navigation' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
describe('Command', () => {
|
|
43
|
+
it('renders nothing when closed', () => {
|
|
44
|
+
render(<Command open={false} onClose={vi.fn()} items={items} />);
|
|
45
|
+
expect(screen.queryByRole('combobox')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('lists all items when the query is empty', () => {
|
|
49
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
50
|
+
expect(screen.getAllByRole('option')).toHaveLength(3);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('filters by label', () => {
|
|
54
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
55
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'profile' } });
|
|
56
|
+
expect(screen.getAllByRole('option')).toHaveLength(1);
|
|
57
|
+
expect(screen.getByRole('option', { name: 'Go to profile' })).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('filters by keywords in addition to the label', () => {
|
|
61
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
62
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'download' } });
|
|
63
|
+
expect(screen.getAllByRole('option')).toHaveLength(1);
|
|
64
|
+
expect(screen.getByRole('option', { name: 'Export chat' })).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('shows an empty state when nothing matches', () => {
|
|
68
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
69
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'zzz' } });
|
|
70
|
+
expect(screen.queryAllByRole('option')).toHaveLength(0);
|
|
71
|
+
expect(screen.getByText('No matching commands.')).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('renders group headers', () => {
|
|
75
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
76
|
+
expect(screen.getByText('Actions')).toBeDefined();
|
|
77
|
+
expect(screen.getByText('Navigation')).toBeDefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('highlights the first item by default', () => {
|
|
81
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
82
|
+
expect(
|
|
83
|
+
screen.getByRole('option', { name: 'New conversation' }).getAttribute('aria-selected'),
|
|
84
|
+
).toBe('true');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('ArrowDown/ArrowUp move the highlighted item', () => {
|
|
88
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
89
|
+
const input = screen.getByRole('combobox');
|
|
90
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
91
|
+
expect(screen.getByRole('option', { name: 'Export chat' }).getAttribute('aria-selected')).toBe(
|
|
92
|
+
'true',
|
|
93
|
+
);
|
|
94
|
+
fireEvent.keyDown(input, { key: 'ArrowUp' });
|
|
95
|
+
expect(
|
|
96
|
+
screen.getByRole('option', { name: 'New conversation' }).getAttribute('aria-selected'),
|
|
97
|
+
).toBe('true');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('ArrowDown does not move past the last item', () => {
|
|
101
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
102
|
+
const input = screen.getByRole('combobox');
|
|
103
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
104
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
105
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
106
|
+
expect(
|
|
107
|
+
screen.getByRole('option', { name: 'Go to profile' }).getAttribute('aria-selected'),
|
|
108
|
+
).toBe('true');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('resets the highlighted index when the query changes', () => {
|
|
112
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
113
|
+
const input = screen.getByRole('combobox');
|
|
114
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
115
|
+
fireEvent.change(input, { target: { value: 'e' } });
|
|
116
|
+
const [firstMatch] = screen.getAllByRole('option');
|
|
117
|
+
expect(firstMatch?.getAttribute('aria-selected')).toBe('true');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('Enter selects the highlighted item and closes', () => {
|
|
121
|
+
const onClose = vi.fn();
|
|
122
|
+
const onSelect = vi.fn();
|
|
123
|
+
const localItems: CommandItem[] = [{ id: 'a', label: 'Alpha', onSelect }];
|
|
124
|
+
render(<Command open onClose={onClose} items={localItems} />);
|
|
125
|
+
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' });
|
|
126
|
+
expect(onSelect).toHaveBeenCalled();
|
|
127
|
+
expect(onClose).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('clicking an item selects it and closes', () => {
|
|
131
|
+
const onClose = vi.fn();
|
|
132
|
+
const onSelect = vi.fn();
|
|
133
|
+
const localItems: CommandItem[] = [{ id: 'a', label: 'Alpha', onSelect }];
|
|
134
|
+
render(<Command open onClose={onClose} items={localItems} />);
|
|
135
|
+
fireEvent.click(screen.getByRole('option', { name: 'Alpha' }));
|
|
136
|
+
expect(onSelect).toHaveBeenCalled();
|
|
137
|
+
expect(onClose).toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('sets aria-activedescendant to the highlighted option', () => {
|
|
141
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
142
|
+
const input = screen.getByRole('combobox');
|
|
143
|
+
const firstOption = screen.getByRole('option', { name: 'New conversation' });
|
|
144
|
+
expect(input.getAttribute('aria-activedescendant')).toBe(firstOption.id);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('resets the query each time it opens', () => {
|
|
148
|
+
const { rerender } = render(<Command open onClose={vi.fn()} items={items} />);
|
|
149
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'profile' } });
|
|
150
|
+
rerender(<Command open={false} onClose={vi.fn()} items={items} />);
|
|
151
|
+
rerender(<Command open onClose={vi.fn()} items={items} />);
|
|
152
|
+
expect(screen.getByRole('combobox').getAttribute('value')).toBe('');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('focuses the input on open', async () => {
|
|
156
|
+
render(<Command open onClose={vi.fn()} items={items} />);
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
159
|
+
});
|
|
160
|
+
expect(document.activeElement).toBe(screen.getByRole('combobox'));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -65,7 +65,11 @@
|
|
|
65
65
|
align-items: center;
|
|
66
66
|
justify-content: center;
|
|
67
67
|
gap: var(--sv-space-2);
|
|
68
|
-
|
|
68
|
+
/* Kept in sync with Button's .md rule by hand (see design-system.md
|
|
69
|
+
"Button and Checkbox touch ergonomics" — not a literal reuse of
|
|
70
|
+
Button). Matches Button's md padding, updated alongside it (10px,
|
|
71
|
+
36px rendered height). */
|
|
72
|
+
padding: 10px var(--sv-space-4);
|
|
69
73
|
border: 1px solid transparent;
|
|
70
74
|
border-radius: var(--sv-radius-md);
|
|
71
75
|
font-family: var(--sv-font-family);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ContextMenu } from './ContextMenu';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/ContextMenu',
|
|
6
|
+
component: ContextMenu,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'padded',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
"Right-click menu on desktop, long-press on touch. Desktop positioning reuses Popover's existing collision detection anchored to the click point; touch opens the same items in a Drawer bottom sheet — a floating panel at a touch point isn't a pattern touch interfaces have.",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: {
|
|
17
|
+
children: <span />,
|
|
18
|
+
items: [
|
|
19
|
+
{ label: 'Rename', onSelect: () => {} },
|
|
20
|
+
{ label: 'Duplicate', onSelect: () => {} },
|
|
21
|
+
{ type: 'separator' },
|
|
22
|
+
{ label: 'Delete', destructive: true, onSelect: () => {} },
|
|
23
|
+
],
|
|
24
|
+
'aria-label': 'Row actions',
|
|
25
|
+
},
|
|
26
|
+
} satisfies Meta<typeof ContextMenu>;
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
type Story = StoryObj<typeof meta>;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export const Default: Story = {
|
|
34
|
+
render: (args) => (
|
|
35
|
+
<ContextMenu {...args}>
|
|
36
|
+
<div
|
|
37
|
+
style={{
|
|
38
|
+
width: 240,
|
|
39
|
+
padding: 'var(--sv-space-4)',
|
|
40
|
+
border: '1px dashed var(--sv-color-border-strong)',
|
|
41
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
42
|
+
textAlign: 'center',
|
|
43
|
+
fontSize: 14,
|
|
44
|
+
color: 'var(--sv-color-text-muted)',
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
Right-click here (or long-press on touch)
|
|
48
|
+
</div>
|
|
49
|
+
</ContextMenu>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WithIconsAndChecked: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
items: [
|
|
56
|
+
{ type: 'label', label: 'Sort by' },
|
|
57
|
+
{ label: 'Name', checked: true, onSelect: () => {} },
|
|
58
|
+
{ label: 'Date modified', checked: false, onSelect: () => {} },
|
|
59
|
+
{ type: 'separator' },
|
|
60
|
+
{ label: 'Rename', icon: 'pencil', onSelect: () => {} },
|
|
61
|
+
{ label: 'Delete', icon: 'trash-2', destructive: true, onSelect: () => {} },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
render: (args) => (
|
|
65
|
+
<ContextMenu {...args}>
|
|
66
|
+
<div
|
|
67
|
+
style={{
|
|
68
|
+
width: 240,
|
|
69
|
+
padding: 'var(--sv-space-4)',
|
|
70
|
+
border: '1px dashed var(--sv-color-border-strong)',
|
|
71
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
72
|
+
textAlign: 'center',
|
|
73
|
+
fontSize: 14,
|
|
74
|
+
color: 'var(--sv-color-text-muted)',
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
Right-click here
|
|
78
|
+
</div>
|
|
79
|
+
</ContextMenu>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cloneElement, useState } from 'react';
|
|
4
|
+
import type { ReactElement } from 'react';
|
|
5
|
+
import type { MouseEvent as ReactMouseEvent } from 'react';
|
|
6
|
+
import { useIsMobile, useLongPress } from '../../hooks';
|
|
7
|
+
import { Popover } from '../Popover/Popover';
|
|
8
|
+
import { Drawer } from '../Drawer/Drawer';
|
|
9
|
+
import { MenuEntries, type MenuEntry, type MenuItem } from '../Menu/Menu';
|
|
10
|
+
|
|
11
|
+
export interface ContextMenuProps {
|
|
12
|
+
/** The element that gets right-click (desktop) / long-press (touch)
|
|
13
|
+
* behavior — cloned with the trigger handlers, rendered as-is otherwise.
|
|
14
|
+
* Typed with `<any>` props (not a bare `ReactElement`, whose implicit
|
|
15
|
+
* `unknown` props type makes `cloneElement` reject *both* branches of
|
|
16
|
+
* the long-press/onContextMenu union below — an interface value like
|
|
17
|
+
* `LongPressHandlers` isn't assignable to an indexed props type without
|
|
18
|
+
* its own index signature) — the standard, narrowly-scoped escape hatch
|
|
19
|
+
* for "clone an arbitrary child with props not known ahead of time". */
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above
|
|
21
|
+
children: ReactElement<any>;
|
|
22
|
+
items: MenuEntry[];
|
|
23
|
+
'aria-label': string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ContextMenu — right-click menu on desktop, long-press on touch.
|
|
28
|
+
*
|
|
29
|
+
* Desktop positioning reuses `Popover`'s existing collision-detection logic
|
|
30
|
+
* rather than reimplementing it: the panel is wrapped in a zero-size,
|
|
31
|
+
* `position: fixed` anchor placed at the click coordinates, so `Popover`
|
|
32
|
+
* measures and flips against *that* point exactly as it would a normal
|
|
33
|
+
* trigger element.
|
|
34
|
+
*
|
|
35
|
+
* Touch uses `useLongPress` (which already handles the OS's own long-press
|
|
36
|
+
* callout/context-menu suppression) and opens the same items in a `Drawer`
|
|
37
|
+
* bottom sheet — the same adaptive-surface split `Menu` already uses,
|
|
38
|
+
* because a floating panel positioned at a touch point isn't a pattern
|
|
39
|
+
* touch interfaces have in the first place.
|
|
40
|
+
*/
|
|
41
|
+
export function ContextMenu({ children, items, 'aria-label': ariaLabel }: ContextMenuProps) {
|
|
42
|
+
const isMobile = useIsMobile();
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const [point, setPoint] = useState({ x: 0, y: 0 });
|
|
45
|
+
|
|
46
|
+
const longPressHandlers = useLongPress({
|
|
47
|
+
onLongPress: () => setOpen(true),
|
|
48
|
+
disabled: !isMobile,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function handleContextMenu(e: ReactMouseEvent) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
setPoint({ x: e.clientX, y: e.clientY });
|
|
54
|
+
setOpen(true);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const wrappedChild = cloneElement(
|
|
58
|
+
children,
|
|
59
|
+
isMobile ? longPressHandlers : { onContextMenu: handleContextMenu },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const list = (
|
|
63
|
+
<MenuEntries
|
|
64
|
+
items={items}
|
|
65
|
+
onSelect={(entry: MenuItem) => {
|
|
66
|
+
setOpen(false);
|
|
67
|
+
entry.onSelect();
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (isMobile) {
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
{wrappedChild}
|
|
76
|
+
<Drawer open={open} onClose={() => setOpen(false)} aria-label={ariaLabel}>
|
|
77
|
+
{list}
|
|
78
|
+
</Drawer>
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
{wrappedChild}
|
|
86
|
+
{open && (
|
|
87
|
+
<div style={{ position: 'fixed', left: point.x, top: point.y, width: 0, height: 0 }}>
|
|
88
|
+
<Popover
|
|
89
|
+
trigger={<span />}
|
|
90
|
+
open={open}
|
|
91
|
+
onClose={() => setOpen(false)}
|
|
92
|
+
aria-label={ariaLabel}
|
|
93
|
+
>
|
|
94
|
+
{list}
|
|
95
|
+
</Popover>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|