@sovereignfs/ui 0.40.0 → 0.45.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/BalanceChip.module.css +32 -0
- 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/Marker.module.css +33 -0
- package/dist/MemberMultiSelect.module.css +27 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/NavigationMenu.module.css +53 -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/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 +698 -5
- package/dist/index.js +1938 -482
- package/dist/tokens/primitives.css +22 -8
- package/package.json +1 -1
- 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 +30 -2
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/BalanceChip/BalanceChip.module.css +32 -0
- package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
- package/src/components/BalanceChip/BalanceChip.tsx +33 -0
- 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/CurrencyInput/CurrencyInput.stories.tsx +50 -0
- package/src/components/CurrencyInput/CurrencyInput.tsx +60 -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/Dialog/Dialog.module.css +3 -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 +8 -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/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/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -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/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/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/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/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/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
- package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -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/index.ts +92 -1
- package/src/stories/DesignSystemOverview.stories.tsx +706 -2
- package/src/stories/MobilePatterns.stories.tsx +8 -2
- package/src/stories/TokenGallery.stories.tsx +112 -16
- package/src/tokens/primitives.css +22 -8
|
@@ -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,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
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.group {
|
|
2
|
+
display: grid;
|
|
3
|
+
gap: var(--sv-space-2);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.label {
|
|
7
|
+
font-size: var(--sv-font-size-xs);
|
|
8
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
9
|
+
color: var(--sv-color-text-muted);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.row {
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: space-between;
|
|
16
|
+
gap: var(--sv-space-2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.trailing {
|
|
20
|
+
flex-shrink: 0;
|
|
21
|
+
max-width: 8rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hint {
|
|
25
|
+
font-size: var(--sv-font-size-xs);
|
|
26
|
+
color: var(--sv-color-text-muted);
|
|
27
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { CurrencyInput } from '../CurrencyInput/CurrencyInput';
|
|
4
|
+
import { MemberMultiSelect, type MemberMultiSelectOption } from './MemberMultiSelect';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/MemberMultiSelect',
|
|
8
|
+
component: MemberMultiSelect,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
'Checkbox list for picking any number of people from an already-resolved set of options. Domain-agnostic about who an option represents — instance users and guest members are both just `{id, label}`, so nothing special is needed to include guests. Not a directory search or invite flow; pair with SuggestionInput for that.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
options: [],
|
|
20
|
+
selectedIds: new Set(),
|
|
21
|
+
onToggle: () => {},
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof MemberMultiSelect>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
const OPTIONS: MemberMultiSelectOption[] = [
|
|
29
|
+
{ id: '1', label: 'Priya' },
|
|
30
|
+
{ id: '2', label: 'Jamie (guest)' },
|
|
31
|
+
{ id: '3', label: 'Sam' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function ControlledMultiSelect({ withTrailing = false }: { withTrailing?: boolean }) {
|
|
35
|
+
const [selected, setSelected] = useState<Set<string>>(new Set(['1', '3']));
|
|
36
|
+
const [amounts, setAmounts] = useState<Record<string, number | null>>({});
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<MemberMultiSelect
|
|
40
|
+
options={OPTIONS}
|
|
41
|
+
selectedIds={selected}
|
|
42
|
+
onToggle={(id, checked) => {
|
|
43
|
+
setSelected((prev) => {
|
|
44
|
+
const next = new Set(prev);
|
|
45
|
+
if (checked) next.add(id);
|
|
46
|
+
else next.delete(id);
|
|
47
|
+
return next;
|
|
48
|
+
});
|
|
49
|
+
}}
|
|
50
|
+
label="Split between"
|
|
51
|
+
hint={withTrailing ? 'Remaining: 0.00' : undefined}
|
|
52
|
+
renderTrailing={
|
|
53
|
+
withTrailing
|
|
54
|
+
? (id) => (
|
|
55
|
+
<CurrencyInput
|
|
56
|
+
valueCents={amounts[id] ?? null}
|
|
57
|
+
onValueChange={(cents) => setAmounts((prev) => ({ ...prev, [id]: cents }))}
|
|
58
|
+
placeholder="0.00"
|
|
59
|
+
aria-label={`Amount for ${OPTIONS.find((o) => o.id === id)?.label}`}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
: undefined
|
|
63
|
+
}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const Default: Story = {
|
|
69
|
+
render: () => <ControlledMultiSelect />,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const WithTrailingAmountInput: Story = {
|
|
73
|
+
render: () => <ControlledMultiSelect withTrailing />,
|
|
74
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { Checkbox } from '../Checkbox/Checkbox';
|
|
5
|
+
import styles from './MemberMultiSelect.module.css';
|
|
6
|
+
|
|
7
|
+
export interface MemberMultiSelectOption {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MemberMultiSelectProps {
|
|
13
|
+
options: MemberMultiSelectOption[];
|
|
14
|
+
selectedIds: Set<string>;
|
|
15
|
+
onToggle: (id: string, checked: boolean) => void;
|
|
16
|
+
/** Rendered to the right of a selected row only — e.g. a per-person `CurrencyInput` or share count. */
|
|
17
|
+
renderTrailing?: (id: string) => ReactNode;
|
|
18
|
+
/** Section label above the list, e.g. "Paid by" or "Split between". */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Helper/validation text below the list, e.g. a running total. */
|
|
21
|
+
hint?: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* MemberMultiSelect — checkbox list for picking any number of people from an
|
|
27
|
+
* already-resolved set of options (group/project members, collaborators,
|
|
28
|
+
* etc.). Deliberately domain-agnostic about *who* an option represents —
|
|
29
|
+
* instance users and guest members are both just `{id, label}` here, so
|
|
30
|
+
* nothing special is needed to include guests alongside instance users; the
|
|
31
|
+
* caller's `options` array is the only place that distinction exists.
|
|
32
|
+
*
|
|
33
|
+
* Not a directory search or an "add a new guest" flow — pair with
|
|
34
|
+
* `SuggestionInput` (search + add) for that; this component only selects
|
|
35
|
+
* among options already passed in.
|
|
36
|
+
*/
|
|
37
|
+
export function MemberMultiSelect({
|
|
38
|
+
options,
|
|
39
|
+
selectedIds,
|
|
40
|
+
onToggle,
|
|
41
|
+
renderTrailing,
|
|
42
|
+
label,
|
|
43
|
+
hint,
|
|
44
|
+
className,
|
|
45
|
+
}: MemberMultiSelectProps) {
|
|
46
|
+
return (
|
|
47
|
+
<div className={[styles.group, className].filter(Boolean).join(' ')}>
|
|
48
|
+
{label && <span className={styles.label}>{label}</span>}
|
|
49
|
+
{options.map((option) => {
|
|
50
|
+
const checked = selectedIds.has(option.id);
|
|
51
|
+
return (
|
|
52
|
+
<div key={option.id} className={styles.row}>
|
|
53
|
+
<Checkbox
|
|
54
|
+
checked={checked}
|
|
55
|
+
onChange={(next) => onToggle(option.id, next)}
|
|
56
|
+
label={option.label}
|
|
57
|
+
/>
|
|
58
|
+
{checked && renderTrailing && (
|
|
59
|
+
<span className={styles.trailing}>{renderTrailing(option.id)}</span>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
{hint && <span className={styles.hint}>{hint}</span>}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -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 (
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Menubar } from './Menubar';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Menubar',
|
|
6
|
+
component: Menubar,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'padded',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
"Desktop app-style menu bar (File/Edit/View...). A thin composition of NavigationMenu with MenuEntries as each item's flyout content — no positioning or list-rendering logic duplicated.",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
} satisfies Meta<typeof Menubar>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
menus: [
|
|
26
|
+
{
|
|
27
|
+
label: 'File',
|
|
28
|
+
items: [
|
|
29
|
+
{ label: 'New conversation', onSelect: () => {} },
|
|
30
|
+
{ label: 'Export', onSelect: () => {} },
|
|
31
|
+
{ type: 'separator' },
|
|
32
|
+
{ label: 'Close', onSelect: () => {} },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: 'Edit',
|
|
37
|
+
items: [
|
|
38
|
+
{ label: 'Undo', onSelect: () => {} },
|
|
39
|
+
{ label: 'Redo', onSelect: () => {} },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
label: 'View',
|
|
44
|
+
items: [
|
|
45
|
+
{ type: 'label', label: 'Layout' },
|
|
46
|
+
{ label: 'Compact', checked: false, onSelect: () => {} },
|
|
47
|
+
{ label: 'Comfortable', checked: true, onSelect: () => {} },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { NavigationMenu } from '../NavigationMenu/NavigationMenu';
|
|
4
|
+
import { MenuEntries, type MenuEntry } from '../Menu/Menu';
|
|
5
|
+
|
|
6
|
+
export interface MenubarMenu {
|
|
7
|
+
label: string;
|
|
8
|
+
items: MenuEntry[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MenubarProps {
|
|
12
|
+
menus: MenubarMenu[];
|
|
13
|
+
'aria-label'?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Menubar — desktop app-style menu bar (File / Edit / View...). A thin
|
|
18
|
+
* composition of `NavigationMenu` (top-level triggers, per-item flyout
|
|
19
|
+
* positioning, hover-switches-when-one-is-open, arrow-key navigation) with
|
|
20
|
+
* `MenuEntries` (the same item list markup `Menu`'s dropdowns use) as each
|
|
21
|
+
* item's flyout content — no positioning or list-rendering logic
|
|
22
|
+
* duplicated from either.
|
|
23
|
+
*/
|
|
24
|
+
export function Menubar({ menus, 'aria-label': ariaLabel = 'Menu bar' }: MenubarProps) {
|
|
25
|
+
return (
|
|
26
|
+
<NavigationMenu
|
|
27
|
+
aria-label={ariaLabel}
|
|
28
|
+
items={menus.map((menu) => ({
|
|
29
|
+
label: menu.label,
|
|
30
|
+
content: (close: () => void) => (
|
|
31
|
+
<MenuEntries
|
|
32
|
+
items={menu.items}
|
|
33
|
+
onSelect={(entry) => {
|
|
34
|
+
close();
|
|
35
|
+
entry.onSelect();
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
}))}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { Menubar } from '../Menubar';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Menubar', () => {
|
|
9
|
+
it('opens a menu on trigger click, showing its items', () => {
|
|
10
|
+
render(<Menubar menus={[{ label: 'File', items: [{ label: 'New', onSelect: () => {} }] }]} />);
|
|
11
|
+
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
|
12
|
+
expect(screen.getByRole('menuitem', { name: 'New' })).toBeDefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('selecting an item calls onSelect and closes the menu', () => {
|
|
16
|
+
const onSelect = vi.fn();
|
|
17
|
+
render(<Menubar menus={[{ label: 'File', items: [{ label: 'New', onSelect }] }]} />);
|
|
18
|
+
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
|
19
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'New' }));
|
|
20
|
+
expect(onSelect).toHaveBeenCalled();
|
|
21
|
+
expect(screen.queryByRole('menuitem', { name: 'New' })).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('switches directly between menus on hover once one is open, like a real menu bar', () => {
|
|
25
|
+
render(
|
|
26
|
+
<Menubar
|
|
27
|
+
menus={[
|
|
28
|
+
{ label: 'File', items: [{ label: 'New', onSelect: () => {} }] },
|
|
29
|
+
{ label: 'Edit', items: [{ label: 'Undo', onSelect: () => {} }] },
|
|
30
|
+
]}
|
|
31
|
+
/>,
|
|
32
|
+
);
|
|
33
|
+
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
|
34
|
+
expect(screen.getByRole('menuitem', { name: 'New' })).toBeDefined();
|
|
35
|
+
|
|
36
|
+
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Edit' }));
|
|
37
|
+
expect(screen.getByRole('menuitem', { name: 'Undo' })).toBeDefined();
|
|
38
|
+
expect(screen.queryByRole('menuitem', { name: 'New' })).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('defaults to an accessible "Menu bar" label', () => {
|
|
42
|
+
render(<Menubar menus={[{ label: 'File', items: [] }]} />);
|
|
43
|
+
expect(screen.getByRole('navigation', { name: 'Menu bar' })).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
.row {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
max-width: 80%;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.user {
|
|
8
|
+
align-self: flex-end;
|
|
9
|
+
align-items: flex-end;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.assistant,
|
|
13
|
+
.tool {
|
|
14
|
+
align-self: flex-start;
|
|
15
|
+
align-items: flex-start;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.bubble {
|
|
19
|
+
padding: var(--sv-space-2) var(--sv-space-3);
|
|
20
|
+
border-radius: var(--sv-radius-xl);
|
|
21
|
+
font-size: var(--sv-font-size-sm);
|
|
22
|
+
line-height: 1.5;
|
|
23
|
+
white-space: pre-wrap;
|
|
24
|
+
overflow-wrap: break-word;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.user .bubble {
|
|
28
|
+
background: var(--sv-color-accent);
|
|
29
|
+
color: var(--sv-color-text-on-accent);
|
|
30
|
+
border-bottom-right-radius: var(--sv-radius-sm);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.assistant .bubble {
|
|
34
|
+
background: var(--sv-color-surface-raised);
|
|
35
|
+
border: 1px solid var(--sv-color-border);
|
|
36
|
+
color: var(--sv-color-text-primary);
|
|
37
|
+
border-bottom-left-radius: var(--sv-radius-sm);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.tool .bubble {
|
|
41
|
+
background: var(--sv-color-surface-sunken);
|
|
42
|
+
color: var(--sv-color-text-muted);
|
|
43
|
+
font-family: var(--sv-font-family-mono);
|
|
44
|
+
font-size: var(--sv-font-size-xs);
|
|
45
|
+
border-bottom-left-radius: var(--sv-radius-sm);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.actions {
|
|
49
|
+
display: flex;
|
|
50
|
+
gap: var(--sv-space-2);
|
|
51
|
+
margin-top: var(--sv-space-1);
|
|
52
|
+
font-size: var(--sv-font-size-caption);
|
|
53
|
+
color: var(--sv-color-text-muted);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Typing indicator — three dots, staggered pulse. */
|
|
57
|
+
.typingDots {
|
|
58
|
+
display: inline-flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 3px;
|
|
61
|
+
padding: var(--sv-space-1) 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.dot {
|
|
65
|
+
width: 6px;
|
|
66
|
+
height: 6px;
|
|
67
|
+
border-radius: var(--sv-radius-full);
|
|
68
|
+
background: currentColor;
|
|
69
|
+
opacity: 0.4;
|
|
70
|
+
animation: typing-pulse 1.2s ease-in-out infinite;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.dot:nth-child(2) {
|
|
74
|
+
animation-delay: 0.2s;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.dot:nth-child(3) {
|
|
78
|
+
animation-delay: 0.4s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@keyframes typing-pulse {
|
|
82
|
+
0%,
|
|
83
|
+
80%,
|
|
84
|
+
100% {
|
|
85
|
+
opacity: 0.4;
|
|
86
|
+
transform: scale(0.85);
|
|
87
|
+
}
|
|
88
|
+
40% {
|
|
89
|
+
opacity: 1;
|
|
90
|
+
transform: scale(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@media (prefers-reduced-motion: reduce) {
|
|
95
|
+
.dot {
|
|
96
|
+
animation: none;
|
|
97
|
+
opacity: 0.7;
|
|
98
|
+
}
|
|
99
|
+
}
|