@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,12 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import styles from './Kbd.module.css';
|
|
3
|
+
|
|
4
|
+
export interface KbdProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Kbd — inline keyboard-key styling. Renders a real `<kbd>` element. */
|
|
10
|
+
export function Kbd({ children, className }: KbdProps) {
|
|
11
|
+
return <kbd className={[styles.kbd, className].filter(Boolean).join(' ')}>{children}</kbd>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Kbd } from '../Kbd';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Kbd', () => {
|
|
9
|
+
it('renders a real <kbd> element', () => {
|
|
10
|
+
render(<Kbd>⌘K</Kbd>);
|
|
11
|
+
const el = screen.getByText('⌘K');
|
|
12
|
+
expect(el.tagName).toBe('KBD');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Label } from './Label';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Label',
|
|
6
|
+
component: Label,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
args: { children: 'Label text' },
|
|
9
|
+
} satisfies Meta<typeof Label>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {};
|
|
17
|
+
|
|
18
|
+
export const Disabled: Story = {
|
|
19
|
+
args: { disabled: true },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const WithControl: Story = {
|
|
23
|
+
render: () => (
|
|
24
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
25
|
+
<Label htmlFor="example-input">Email address</Label>
|
|
26
|
+
<input
|
|
27
|
+
id="example-input"
|
|
28
|
+
type="email"
|
|
29
|
+
placeholder="you@example.com"
|
|
30
|
+
style={{
|
|
31
|
+
padding: 'var(--sv-space-2) var(--sv-space-3)',
|
|
32
|
+
border: '1px solid var(--sv-color-border-strong)',
|
|
33
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
34
|
+
fontFamily: 'var(--sv-font-family)',
|
|
35
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LabelHTMLAttributes } from 'react';
|
|
2
|
+
import styles from './Label.module.css';
|
|
3
|
+
|
|
4
|
+
export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Label — standalone accessible form label, independent of `FormField`.
|
|
10
|
+
* Use `FormField` when a control needs hint/error text too; use `Label`
|
|
11
|
+
* directly for a bare label (e.g. next to a `Checkbox` group heading).
|
|
12
|
+
*/
|
|
13
|
+
export function Label({ disabled, className, children, ...rest }: LabelProps) {
|
|
14
|
+
return (
|
|
15
|
+
<label
|
|
16
|
+
className={[styles.label, disabled ? styles.disabled : '', className]
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.join(' ')}
|
|
19
|
+
{...rest}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</label>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Label } from '../Label';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Label', () => {
|
|
9
|
+
it('renders a real <label> element', () => {
|
|
10
|
+
render(<Label htmlFor="agree">I agree</Label>);
|
|
11
|
+
expect(screen.getByText('I agree').tagName).toBe('LABEL');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('associates with a control via htmlFor', () => {
|
|
15
|
+
render(
|
|
16
|
+
<>
|
|
17
|
+
<Label htmlFor="agree">I agree to the terms</Label>
|
|
18
|
+
<input id="agree" type="checkbox" />
|
|
19
|
+
</>,
|
|
20
|
+
);
|
|
21
|
+
expect(screen.getByLabelText('I agree to the terms')).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('applies disabled styling without disabling the underlying element', () => {
|
|
25
|
+
render(
|
|
26
|
+
<Label htmlFor="agree" disabled>
|
|
27
|
+
I agree
|
|
28
|
+
</Label>,
|
|
29
|
+
);
|
|
30
|
+
expect(screen.getByText('I agree').className).toContain('disabled');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
margin: var(--sv-space-5) 0 0;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
gap: var(--sv-space-2);
|
|
7
|
+
font-size: var(--sv-font-size-xs);
|
|
8
|
+
color: var(--sv-color-text-muted);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.link {
|
|
12
|
+
color: var(--sv-color-text-muted);
|
|
13
|
+
text-decoration: underline;
|
|
14
|
+
text-underline-offset: 2px;
|
|
15
|
+
text-decoration-color: var(--sv-color-border);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.link:hover {
|
|
19
|
+
color: var(--sv-color-text-primary);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.dot {
|
|
23
|
+
color: var(--sv-color-border);
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { LegalLinks } from './LegalLinks';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/LegalLinks',
|
|
6
|
+
component: LegalLinks,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
} satisfies Meta<typeof LegalLinks>;
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {};
|
|
16
|
+
|
|
17
|
+
export const CustomHrefs: Story = {
|
|
18
|
+
args: {
|
|
19
|
+
privacyHref: 'https://example.com/legal/privacy',
|
|
20
|
+
tosHref: 'https://example.com/legal/tos',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import styles from './LegalLinks.module.css';
|
|
3
|
+
|
|
4
|
+
export interface LegalLinksProps {
|
|
5
|
+
privacyHref?: string;
|
|
6
|
+
tosHref?: string;
|
|
7
|
+
/** Renders a linked item. Defaults to a plain `<a href>`, which causes a
|
|
8
|
+
* full page reload — pass Next's `<Link>` (or equivalent) to keep
|
|
9
|
+
* navigation client-side. Not needed for a full-reload navigation away
|
|
10
|
+
* from a standalone auth page, but matters if this is ever mounted inside
|
|
11
|
+
* an SPA-like shell. */
|
|
12
|
+
renderLink?: (href: string, label: string) => ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function LegalLinks({
|
|
17
|
+
privacyHref = '/privacy',
|
|
18
|
+
tosHref = '/tos',
|
|
19
|
+
renderLink,
|
|
20
|
+
className,
|
|
21
|
+
}: LegalLinksProps) {
|
|
22
|
+
const link = (href: string, label: string) =>
|
|
23
|
+
renderLink ? (
|
|
24
|
+
renderLink(href, label)
|
|
25
|
+
) : (
|
|
26
|
+
<a href={href} className={styles.link}>
|
|
27
|
+
{label}
|
|
28
|
+
</a>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<p className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
33
|
+
{link(privacyHref, 'Privacy Policy')}
|
|
34
|
+
<span aria-hidden="true" className={styles.dot}>
|
|
35
|
+
·
|
|
36
|
+
</span>
|
|
37
|
+
{link(tosHref, 'Terms of Service')}
|
|
38
|
+
</p>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Spacing lives on this wrapper div, not on the Typography-rendered tag
|
|
2
|
+
inside it — Typography's own variants reset margin: 0, which would tie
|
|
3
|
+
for CSS specificity against a bare `.root > * + *` sibling rule and make
|
|
4
|
+
the outcome depend on module load order. A dedicated class avoids that. */
|
|
5
|
+
.block {
|
|
6
|
+
margin-top: var(--sv-space-4);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.block:first-child {
|
|
10
|
+
margin-top: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.block[data-heading] {
|
|
14
|
+
margin-top: var(--sv-space-8);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.block[data-heading]:first-child {
|
|
18
|
+
margin-top: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.blockquote {
|
|
22
|
+
margin: 0;
|
|
23
|
+
padding: var(--sv-space-4);
|
|
24
|
+
border-left: 3px solid var(--sv-color-border);
|
|
25
|
+
background: var(--sv-color-surface-sunken);
|
|
26
|
+
border-radius: var(--sv-radius-sm);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.list {
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding-left: var(--sv-space-6);
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
gap: var(--sv-space-2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.root a {
|
|
38
|
+
color: var(--sv-color-text-primary);
|
|
39
|
+
text-decoration: underline;
|
|
40
|
+
text-underline-offset: 2px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.root code {
|
|
44
|
+
font-family: var(--sv-font-family-mono);
|
|
45
|
+
font-size: 0.9em;
|
|
46
|
+
background: var(--sv-color-surface-sunken);
|
|
47
|
+
padding: 0.1em 0.35em;
|
|
48
|
+
border-radius: var(--sv-radius-sm);
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Markdown } from './Markdown';
|
|
3
|
+
|
|
4
|
+
const sample = `# Privacy Policy
|
|
5
|
+
|
|
6
|
+
This document describes the built-in, default behavior of **Sovereign**.
|
|
7
|
+
|
|
8
|
+
> Replaced content from an operator overrides this default.
|
|
9
|
+
|
|
10
|
+
## What we collect
|
|
11
|
+
|
|
12
|
+
- **Email address** and password.
|
|
13
|
+
- *Time zone*, detected automatically at signup.
|
|
14
|
+
|
|
15
|
+
See the [project source](https://github.com/sovereignfs/sovereign) for details.
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const meta = {
|
|
19
|
+
title: 'Components/Markdown',
|
|
20
|
+
component: Markdown,
|
|
21
|
+
parameters: { layout: 'padded' },
|
|
22
|
+
args: { content: sample },
|
|
23
|
+
} satisfies Meta<typeof Markdown>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export const Default: Story = {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Typography } from '../Typography/Typography';
|
|
3
|
+
import styles from './Markdown.module.css';
|
|
4
|
+
|
|
5
|
+
export interface MarkdownProps {
|
|
6
|
+
/** Raw markdown source. Supports the subset used by Sovereign's own
|
|
7
|
+
* long-form content pages: headings (#/##/###), paragraphs, **bold**,
|
|
8
|
+
* *italic*, `code`, [links](url), unordered lists (-), and blockquotes
|
|
9
|
+
* (>), including soft-wrapped continuation lines within a paragraph or
|
|
10
|
+
* list item. Not a general-purpose CommonMark parser — no tables,
|
|
11
|
+
* images, ordered lists, or nested blocks. */
|
|
12
|
+
content: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let inlineKey = 0;
|
|
17
|
+
|
|
18
|
+
/** Parses **bold**, *italic*, `code`, and [text](url) within a line of text. */
|
|
19
|
+
function parseInline(text: string): ReactNode[] {
|
|
20
|
+
const nodes: ReactNode[] = [];
|
|
21
|
+
const pattern = /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`/;
|
|
22
|
+
let remaining = text;
|
|
23
|
+
|
|
24
|
+
while (remaining.length > 0) {
|
|
25
|
+
const match = pattern.exec(remaining);
|
|
26
|
+
if (!match) {
|
|
27
|
+
nodes.push(remaining);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (match.index > 0) nodes.push(remaining.slice(0, match.index));
|
|
31
|
+
|
|
32
|
+
const [full, linkText, linkHref, boldText, italicText, codeText] = match;
|
|
33
|
+
const key = `md-inline-${inlineKey++}`;
|
|
34
|
+
if (linkHref !== undefined) {
|
|
35
|
+
nodes.push(
|
|
36
|
+
<a key={key} href={linkHref}>
|
|
37
|
+
{linkText}
|
|
38
|
+
</a>,
|
|
39
|
+
);
|
|
40
|
+
} else if (boldText !== undefined) {
|
|
41
|
+
nodes.push(<strong key={key}>{boldText}</strong>);
|
|
42
|
+
} else if (italicText !== undefined) {
|
|
43
|
+
nodes.push(<em key={key}>{italicText}</em>);
|
|
44
|
+
} else if (codeText !== undefined) {
|
|
45
|
+
nodes.push(<code key={key}>{codeText}</code>);
|
|
46
|
+
}
|
|
47
|
+
remaining = remaining.slice(match.index + full.length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return nodes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isQuote = (l: string) => l.startsWith('> ') || l === '>';
|
|
54
|
+
const isListItem = (l: string) => l.startsWith('- ');
|
|
55
|
+
const isHeading = (l: string) => /^#{1,3}\s+/.test(l);
|
|
56
|
+
/** A line that ends the current *paragraph* run — a blank line, or the
|
|
57
|
+
* start of a heading, quote, or list block. Anything else (including an
|
|
58
|
+
* indented continuation line) is folded into the paragraph, matching how
|
|
59
|
+
* the source markdown soft-wraps prose across lines. */
|
|
60
|
+
const endsParagraph = (l: string) => l.trim() === '' || isHeading(l) || isQuote(l) || isListItem(l);
|
|
61
|
+
/** A line that ends the current *list* run — a blank line, or the start of
|
|
62
|
+
* a heading or quote. Deliberately does NOT include `isListItem`: a list
|
|
63
|
+
* item's own opening line legitimately satisfies `isListItem`, so reusing
|
|
64
|
+
* `endsParagraph` here (which does) would treat the first line of every
|
|
65
|
+
* list as ending the list before it started — the list-collection loop
|
|
66
|
+
* would then never advance, looping forever. */
|
|
67
|
+
const endsList = (l: string) => l.trim() === '' || isHeading(l) || isQuote(l);
|
|
68
|
+
|
|
69
|
+
function parseBlocks(content: string): ReactNode[] {
|
|
70
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
71
|
+
const blocks: ReactNode[] = [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
let blockKey = 0;
|
|
74
|
+
|
|
75
|
+
while (i < lines.length) {
|
|
76
|
+
const line = lines[i] ?? '';
|
|
77
|
+
|
|
78
|
+
if (line.trim() === '') {
|
|
79
|
+
i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
84
|
+
if (heading) {
|
|
85
|
+
const level = (heading[1]?.length ?? 1) as 1 | 2 | 3;
|
|
86
|
+
blocks.push(
|
|
87
|
+
<div key={blockKey++} className={styles.block} data-heading>
|
|
88
|
+
<Typography variant={`h${level}` as 'h1' | 'h2' | 'h3'}>
|
|
89
|
+
{parseInline(heading[2] ?? '')}
|
|
90
|
+
</Typography>
|
|
91
|
+
</div>,
|
|
92
|
+
);
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isQuote(line)) {
|
|
98
|
+
const quoteLines: string[] = [];
|
|
99
|
+
while (i < lines.length && isQuote(lines[i] ?? '')) {
|
|
100
|
+
quoteLines.push((lines[i] ?? '').slice(2));
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
blocks.push(
|
|
104
|
+
<div key={blockKey++} className={styles.block}>
|
|
105
|
+
<blockquote className={styles.blockquote}>
|
|
106
|
+
<Typography variant="body" as="p">
|
|
107
|
+
{parseInline(quoteLines.join(' '))}
|
|
108
|
+
</Typography>
|
|
109
|
+
</blockquote>
|
|
110
|
+
</div>,
|
|
111
|
+
);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isListItem(line)) {
|
|
116
|
+
const items: string[] = [];
|
|
117
|
+
while (i < lines.length && !endsList(lines[i] ?? '')) {
|
|
118
|
+
const current = lines[i] ?? '';
|
|
119
|
+
if (isListItem(current)) {
|
|
120
|
+
items.push(current.slice(2));
|
|
121
|
+
} else {
|
|
122
|
+
// Continuation of the previous item's wrapped text.
|
|
123
|
+
const lastIndex = items.length - 1;
|
|
124
|
+
items[lastIndex] = `${items[lastIndex] ?? ''} ${current.trim()}`;
|
|
125
|
+
}
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
blocks.push(
|
|
129
|
+
<div key={blockKey++} className={styles.block}>
|
|
130
|
+
<ul className={styles.list}>
|
|
131
|
+
{items.map((item, idx) => (
|
|
132
|
+
<Typography key={idx} variant="body" as="li">
|
|
133
|
+
{parseInline(item)}
|
|
134
|
+
</Typography>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
</div>,
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const paraLines: string[] = [];
|
|
143
|
+
while (i < lines.length && !endsParagraph(lines[i] ?? '')) {
|
|
144
|
+
paraLines.push((lines[i] ?? '').trim());
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
blocks.push(
|
|
148
|
+
<div key={blockKey++} className={styles.block}>
|
|
149
|
+
<Typography variant="body" as="p">
|
|
150
|
+
{parseInline(paraLines.join(' '))}
|
|
151
|
+
</Typography>
|
|
152
|
+
</div>,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return blocks;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Renders a constrained markdown subset (see MarkdownProps) as styled React
|
|
160
|
+
* elements built from Typography — no HTML string injection. Meant for
|
|
161
|
+
* long-form, first-party content pages (privacy policy, terms of service)
|
|
162
|
+
* sourced from a single markdown file, not for rendering arbitrary or
|
|
163
|
+
* user-supplied markdown. */
|
|
164
|
+
export function Markdown({ content, className }: MarkdownProps) {
|
|
165
|
+
return (
|
|
166
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')}>{parseBlocks(content)}</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
.marker {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: center;
|
|
5
|
+
min-width: 16px;
|
|
6
|
+
height: 16px;
|
|
7
|
+
padding: 0 4px;
|
|
8
|
+
margin: 0 1px;
|
|
9
|
+
border: none;
|
|
10
|
+
border-radius: var(--sv-radius-full);
|
|
11
|
+
background: var(--sv-color-accent-subtle);
|
|
12
|
+
color: var(--sv-color-accent);
|
|
13
|
+
font-family: var(--sv-font-family);
|
|
14
|
+
font-size: var(--sv-font-size-label);
|
|
15
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
16
|
+
line-height: 1;
|
|
17
|
+
vertical-align: super;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
button.marker {
|
|
21
|
+
cursor: pointer;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
button.marker:focus-visible {
|
|
25
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
26
|
+
outline-offset: 1px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@media (hover: hover) {
|
|
30
|
+
button.marker:hover {
|
|
31
|
+
background: color-mix(in srgb, var(--sv-color-accent-subtle) 70%, var(--sv-color-accent) 30%);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Marker } from './Marker';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Marker',
|
|
6
|
+
component: Marker,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'padded',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
'Inline citation/reference marker, for attributing part of an assistant answer to a source (Sovereign Harness "source trace").',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: { index: 1, label: 'Source: Notes' },
|
|
17
|
+
} satisfies Meta<typeof Marker>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export const Static: Story = {};
|
|
25
|
+
|
|
26
|
+
export const Clickable: Story = {
|
|
27
|
+
args: { onClick: () => {} },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const InContext: Story = {
|
|
31
|
+
render: () => (
|
|
32
|
+
<p style={{ fontSize: 14, maxWidth: 400 }}>
|
|
33
|
+
Your next task deadline is Thursday
|
|
34
|
+
<Marker index={1} label="Source: Tasks" onClick={() => {}} />, and you have two unread
|
|
35
|
+
messages from the team
|
|
36
|
+
<Marker index={2} label="Source: Notes" onClick={() => {}} />.
|
|
37
|
+
</p>
|
|
38
|
+
),
|
|
39
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import styles from './Marker.module.css';
|
|
2
|
+
|
|
3
|
+
export interface MarkerProps {
|
|
4
|
+
/** The number shown in the marker, e.g. 1, 2, 3. */
|
|
5
|
+
index: number;
|
|
6
|
+
/** Accessible label describing the citation, e.g. "Source: Notes". */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Reveals source details (e.g. opens a source trace panel) when given. */
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Marker — inline citation/reference marker, for attributing part of an
|
|
15
|
+
* assistant's answer to a source (matches RFC 0040's "source trace" —
|
|
16
|
+
* Harness shows which plugin/data source informed an answer).
|
|
17
|
+
*/
|
|
18
|
+
export function Marker({ index, label, onClick, className }: MarkerProps) {
|
|
19
|
+
const classes = [styles.marker, className].filter(Boolean).join(' ');
|
|
20
|
+
|
|
21
|
+
if (onClick) {
|
|
22
|
+
return (
|
|
23
|
+
<button type="button" onClick={onClick} aria-label={label} className={classes}>
|
|
24
|
+
{index}
|
|
25
|
+
</button>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<span aria-label={label} className={classes}>
|
|
31
|
+
{index}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Marker } from '../Marker';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Marker', () => {
|
|
9
|
+
it('renders as a static span with an accessible label when no onClick is given', () => {
|
|
10
|
+
render(<Marker index={1} label="Source: Notes" />);
|
|
11
|
+
const el = screen.getByLabelText('Source: Notes');
|
|
12
|
+
expect(el.tagName).toBe('SPAN');
|
|
13
|
+
expect(el.textContent).toBe('1');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('renders as a button and fires onClick when given', () => {
|
|
17
|
+
const onClick = vi.fn();
|
|
18
|
+
render(<Marker index={2} label="Source: Tasks" onClick={onClick} />);
|
|
19
|
+
const el = screen.getByRole('button', { name: 'Source: Tasks' });
|
|
20
|
+
el.click();
|
|
21
|
+
expect(onClick).toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/* Menu — the item list shared by both presentations (Popover panel on
|
|
2
2
|
* desktop, Drawer panel on mobile); the presentational chrome around it is
|
|
3
|
-
* entirely owned by those two components, not duplicated here.
|
|
3
|
+
* entirely owned by those two components, not duplicated here.
|
|
4
|
+
*
|
|
5
|
+
* Sizing audit: reviewed against this scale's other compact controls —
|
|
6
|
+
* --sv-radius-sm (6px) and --sv-font-size-sm (14px) already fit. The 44px
|
|
7
|
+
* min-height is a deliberate design choice, kept unconditional (not gated
|
|
8
|
+
* to pointer:coarse like Button/Checkbox) because this menu doubles as a
|
|
9
|
+
* full Drawer sheet on mobile, where every item must stay comfortably
|
|
10
|
+
* tappable. */
|
|
4
11
|
|
|
5
12
|
.list {
|
|
6
13
|
list-style: none;
|
|
@@ -70,10 +70,21 @@ export interface MenuProps {
|
|
|
70
70
|
* Selecting an item both closes the menu and calls its `onSelect` — a
|
|
71
71
|
* consumer's `onSelect` never needs to call `onClose` itself.
|
|
72
72
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
/**
|
|
74
|
+
* MenuEntries — the `<ul role="menu">` item list itself, factored out of
|
|
75
|
+
* `Menu` so other adaptive-surface components (e.g. `ContextMenu`) can
|
|
76
|
+
* render the exact same items/destructive/checked/icon markup inside their
|
|
77
|
+
* own positioning shell, instead of re-deriving it and risking drift.
|
|
78
|
+
*/
|
|
79
|
+
export function MenuEntries({
|
|
80
|
+
items,
|
|
81
|
+
onSelect,
|
|
82
|
+
}: {
|
|
83
|
+
items: MenuEntry[];
|
|
84
|
+
/** Called after an item's own onSelect — typically the surface's onClose. */
|
|
85
|
+
onSelect: (entry: MenuItem) => void;
|
|
86
|
+
}) {
|
|
87
|
+
return (
|
|
77
88
|
<ul className={styles.list} role="menu">
|
|
78
89
|
{items.map((entry, index) => {
|
|
79
90
|
// Entries are a static, order-stable list per render — nothing
|
|
@@ -99,10 +110,7 @@ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, a
|
|
|
99
110
|
.filter(Boolean)
|
|
100
111
|
.join(' ')}
|
|
101
112
|
disabled={entry.disabled}
|
|
102
|
-
onClick={() =>
|
|
103
|
-
onClose();
|
|
104
|
-
entry.onSelect();
|
|
105
|
-
}}
|
|
113
|
+
onClick={() => onSelect(entry)}
|
|
106
114
|
>
|
|
107
115
|
{isCheckable && (
|
|
108
116
|
<span className={styles.check} aria-hidden>
|
|
@@ -117,6 +125,20 @@ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, a
|
|
|
117
125
|
})}
|
|
118
126
|
</ul>
|
|
119
127
|
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, align }: MenuProps) {
|
|
131
|
+
const isMobile = useIsMobile();
|
|
132
|
+
|
|
133
|
+
const list = (
|
|
134
|
+
<MenuEntries
|
|
135
|
+
items={items}
|
|
136
|
+
onSelect={(entry) => {
|
|
137
|
+
onClose();
|
|
138
|
+
entry.onSelect();
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
120
142
|
|
|
121
143
|
if (isMobile) {
|
|
122
144
|
return (
|