@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,79 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Message } from './Message';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Message',
|
|
6
|
+
component: Message,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'padded',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
"A single chat turn, for the Sovereign Harness assistant (RFC 0040). `sender` is one of user/assistant/tool — matching harness_messages (named `sender`, not `role`, to avoid colliding with the ARIA role attribute). Content is caller-controlled ReactNode; markdown rendering, if any, is the consumer's choice.",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: { sender: 'assistant', children: 'Your task is due Thursday.' },
|
|
17
|
+
} satisfies Meta<typeof Message>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export const User: Story = {
|
|
25
|
+
args: { sender: 'user', children: 'What tasks do I have due this week?' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Assistant: Story = {
|
|
29
|
+
args: { sender: 'assistant' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const Tool: Story = {
|
|
33
|
+
args: { sender: 'tool', children: 'query_tasks({ dueBefore: "2026-08-01" }) → 3 results' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const Pending: Story = {
|
|
37
|
+
args: { sender: 'assistant', pending: true },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const WithActions: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
sender: 'assistant',
|
|
43
|
+
actions: (
|
|
44
|
+
<>
|
|
45
|
+
<button type="button" style={{ all: 'unset', cursor: 'pointer' }}>
|
|
46
|
+
Forget this
|
|
47
|
+
</button>
|
|
48
|
+
<span>·</span>
|
|
49
|
+
<button type="button" style={{ all: 'unset', cursor: 'pointer' }}>
|
|
50
|
+
Copy
|
|
51
|
+
</button>
|
|
52
|
+
</>
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const Conversation: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
60
|
+
<Message sender="user">What tasks do I have due this week?</Message>
|
|
61
|
+
<Message sender="tool">
|
|
62
|
+
query_tasks({'{'} dueBefore: "2026-08-01" {'}'}) → 3 results
|
|
63
|
+
</Message>
|
|
64
|
+
<Message
|
|
65
|
+
sender="assistant"
|
|
66
|
+
actions={
|
|
67
|
+
<button type="button" style={{ all: 'unset', cursor: 'pointer' }}>
|
|
68
|
+
Forget this
|
|
69
|
+
</button>
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
You have 3 tasks due this week: review the Q3 report, reply to the design feedback thread,
|
|
73
|
+
and renew the domain registration.
|
|
74
|
+
</Message>
|
|
75
|
+
<Message sender="user">Thanks!</Message>
|
|
76
|
+
<Message sender="assistant" pending />
|
|
77
|
+
</div>
|
|
78
|
+
),
|
|
79
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import styles from './Message.module.css';
|
|
3
|
+
|
|
4
|
+
export type MessageSender = 'user' | 'assistant' | 'tool';
|
|
5
|
+
|
|
6
|
+
export interface MessageProps {
|
|
7
|
+
/** Matches the three roles Sovereign Harness stores per RFC 0040:
|
|
8
|
+
* user, assistant, and tool messages. Named `sender`, not `role`, to
|
|
9
|
+
* avoid colliding with the ARIA `role` attribute in JSX tooling. */
|
|
10
|
+
sender: MessageSender;
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
/** Shows a "thinking" indicator instead of children — before the first
|
|
13
|
+
* token of a streaming response arrives. Once content starts streaming
|
|
14
|
+
* in, pass it as children and stop passing `pending`. */
|
|
15
|
+
pending?: boolean;
|
|
16
|
+
/** Rendered in a small row below the bubble — e.g. "Forget this" (RFC
|
|
17
|
+
* 0040's per-message memory action), "Copy", "Regenerate". */
|
|
18
|
+
actions?: ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Message — a single chat turn. Content is caller-controlled `ReactNode`
|
|
23
|
+
* (markdown rendering, if any, is the consumer's choice, not baked in here). */
|
|
24
|
+
export function Message({ sender, children, pending, actions, className }: MessageProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div className={[styles.row, styles[sender], className].filter(Boolean).join(' ')}>
|
|
27
|
+
<div className={styles.bubble}>
|
|
28
|
+
{pending ? (
|
|
29
|
+
<span className={styles.typingDots} aria-label="Thinking">
|
|
30
|
+
<span className={styles.dot} />
|
|
31
|
+
<span className={styles.dot} />
|
|
32
|
+
<span className={styles.dot} />
|
|
33
|
+
</span>
|
|
34
|
+
) : (
|
|
35
|
+
children
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
{actions && !pending && <div className={styles.actions}>{actions}</div>}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Message } from '../Message';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Message', () => {
|
|
9
|
+
it('renders children content', () => {
|
|
10
|
+
render(<Message sender="user">Hello there</Message>);
|
|
11
|
+
expect(screen.getByText('Hello there')).toBeDefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('shows a "Thinking" indicator instead of children when pending', () => {
|
|
15
|
+
render(
|
|
16
|
+
<Message sender="assistant" pending>
|
|
17
|
+
Not shown yet
|
|
18
|
+
</Message>,
|
|
19
|
+
);
|
|
20
|
+
expect(screen.getByLabelText('Thinking')).toBeDefined();
|
|
21
|
+
expect(screen.queryByText('Not shown yet')).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders actions below the bubble when given and not pending', () => {
|
|
25
|
+
render(
|
|
26
|
+
<Message sender="assistant" actions={<button type="button">Forget this</button>}>
|
|
27
|
+
Your task is due Thursday.
|
|
28
|
+
</Message>,
|
|
29
|
+
);
|
|
30
|
+
expect(screen.getByRole('button', { name: 'Forget this' })).toBeDefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('hides actions while pending', () => {
|
|
34
|
+
render(
|
|
35
|
+
<Message sender="assistant" pending actions={<button type="button">Forget this</button>}>
|
|
36
|
+
content
|
|
37
|
+
</Message>,
|
|
38
|
+
);
|
|
39
|
+
expect(screen.queryByRole('button', { name: 'Forget this' })).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each(['user', 'assistant', 'tool'] as const)('renders the %s sender', (sender) => {
|
|
43
|
+
render(<Message sender={sender}>content</Message>);
|
|
44
|
+
expect(screen.getByText('content')).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
position: relative;
|
|
3
|
+
height: 100%;
|
|
4
|
+
min-height: 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.scrollContainer {
|
|
8
|
+
height: 100%;
|
|
9
|
+
overflow-y: auto;
|
|
10
|
+
overscroll-behavior: contain;
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
gap: var(--sv-space-3);
|
|
14
|
+
padding: var(--sv-space-3);
|
|
15
|
+
scrollbar-width: thin;
|
|
16
|
+
scrollbar-color: var(--sv-color-border-strong) transparent;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.scrollContainer::-webkit-scrollbar {
|
|
20
|
+
width: 8px;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.scrollContainer::-webkit-scrollbar-thumb {
|
|
24
|
+
background: var(--sv-color-border-strong);
|
|
25
|
+
border-radius: var(--sv-radius-full);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.jumpButton {
|
|
29
|
+
position: absolute;
|
|
30
|
+
bottom: var(--sv-space-3);
|
|
31
|
+
left: 50%;
|
|
32
|
+
transform: translateX(-50%);
|
|
33
|
+
padding: var(--sv-space-1) var(--sv-space-3);
|
|
34
|
+
border: 1px solid var(--sv-color-border);
|
|
35
|
+
border-radius: var(--sv-radius-full);
|
|
36
|
+
background: var(--sv-color-surface-raised);
|
|
37
|
+
box-shadow: var(--sv-shadow-popover);
|
|
38
|
+
color: var(--sv-color-text-primary);
|
|
39
|
+
font-family: var(--sv-font-family);
|
|
40
|
+
font-size: var(--sv-font-size-caption);
|
|
41
|
+
font-weight: var(--sv-font-weight-medium);
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.jumpButton:focus-visible {
|
|
46
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
47
|
+
outline-offset: 2px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@media (hover: hover) {
|
|
51
|
+
.jumpButton:hover {
|
|
52
|
+
background: var(--sv-color-surface-sunken);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { MessageScroller } from './MessageScroller';
|
|
4
|
+
import { Message } from '../Message/Message';
|
|
5
|
+
import { Button } from '../Button/Button';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/MessageScroller',
|
|
9
|
+
component: MessageScroller,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'fullscreen',
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
'Auto-scrolling chat container. Scrolls to the newest message while the user is near the bottom; if they\'ve scrolled up, new content shows a "New messages" button instead of yanking them back down.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
args: { children: null },
|
|
20
|
+
} satisfies Meta<typeof MessageScroller>;
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof meta>;
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const SEED_MESSAGES = [
|
|
28
|
+
{ sender: 'user' as const, text: 'What tasks do I have due this week?' },
|
|
29
|
+
{
|
|
30
|
+
sender: 'assistant' as const,
|
|
31
|
+
text: 'You have 3 tasks due this week: review the Q3 report, reply to the design feedback thread, and renew the domain registration.',
|
|
32
|
+
},
|
|
33
|
+
{ sender: 'user' as const, text: 'Can you summarize the Q3 report task?' },
|
|
34
|
+
{
|
|
35
|
+
sender: 'assistant' as const,
|
|
36
|
+
text: 'It\'s tagged "high priority" and due Thursday — created from your Notes plugin two days ago.',
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export const Interactive: Story = {
|
|
41
|
+
render: () => {
|
|
42
|
+
const [messages, setMessages] = useState(SEED_MESSAGES);
|
|
43
|
+
return (
|
|
44
|
+
<div style={{ height: 420, display: 'flex', flexDirection: 'column', maxWidth: 480 }}>
|
|
45
|
+
<div style={{ flex: 1, minHeight: 0, border: '1px solid var(--sv-color-border)' }}>
|
|
46
|
+
<MessageScroller>
|
|
47
|
+
{messages.map((m, i) => (
|
|
48
|
+
<Message key={i} sender={m.sender}>
|
|
49
|
+
{m.text}
|
|
50
|
+
</Message>
|
|
51
|
+
))}
|
|
52
|
+
</MessageScroller>
|
|
53
|
+
</div>
|
|
54
|
+
<div style={{ padding: 'var(--sv-space-3)' }}>
|
|
55
|
+
<Button
|
|
56
|
+
size="sm"
|
|
57
|
+
onClick={() =>
|
|
58
|
+
setMessages((prev) => [
|
|
59
|
+
...prev,
|
|
60
|
+
{ sender: 'assistant', text: `New message #${prev.length + 1}` },
|
|
61
|
+
])
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
Add message
|
|
65
|
+
</Button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const LongHistory: Story = {
|
|
73
|
+
render: () => (
|
|
74
|
+
<div style={{ height: 420, maxWidth: 480, border: '1px solid var(--sv-color-border)' }}>
|
|
75
|
+
<MessageScroller>
|
|
76
|
+
{Array.from({ length: 30 }, (_, i) => (
|
|
77
|
+
<Message key={i} sender={i % 2 === 0 ? 'user' : 'assistant'}>
|
|
78
|
+
Message {i + 1}
|
|
79
|
+
</Message>
|
|
80
|
+
))}
|
|
81
|
+
</MessageScroller>
|
|
82
|
+
</div>
|
|
83
|
+
),
|
|
84
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { ReactNode, UIEvent } from 'react';
|
|
5
|
+
import styles from './MessageScroller.module.css';
|
|
6
|
+
|
|
7
|
+
export interface MessageScrollerProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const NEAR_BOTTOM_THRESHOLD_PX = 80;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MessageScroller — auto-scrolling chat container.
|
|
16
|
+
*
|
|
17
|
+
* Scrolls to the newest message automatically, but only while the user is
|
|
18
|
+
* already near the bottom. If they've scrolled up to read history, new
|
|
19
|
+
* content does not yank them back down — a "New messages" button appears
|
|
20
|
+
* instead, matching the pattern every chat product (Slack, Discord,
|
|
21
|
+
* ChatGPT) already uses.
|
|
22
|
+
*/
|
|
23
|
+
export function MessageScroller({ children, className }: MessageScrollerProps) {
|
|
24
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
// A ref, not state: reading the user's scroll position must not itself
|
|
26
|
+
// retrigger the auto-scroll effect below, or scrolling back near the
|
|
27
|
+
// bottom manually would immediately snap the container the rest of the
|
|
28
|
+
// way — fighting the user's own scroll input.
|
|
29
|
+
const isNearBottomRef = useRef(true);
|
|
30
|
+
const [hasNewMessages, setHasNewMessages] = useState(false);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const el = containerRef.current;
|
|
34
|
+
if (!el) return;
|
|
35
|
+
if (isNearBottomRef.current) {
|
|
36
|
+
el.scrollTop = el.scrollHeight;
|
|
37
|
+
setHasNewMessages(false);
|
|
38
|
+
} else {
|
|
39
|
+
setHasNewMessages(true);
|
|
40
|
+
}
|
|
41
|
+
}, [children]);
|
|
42
|
+
|
|
43
|
+
function handleScroll(event: UIEvent<HTMLDivElement>) {
|
|
44
|
+
const el = event.currentTarget;
|
|
45
|
+
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
46
|
+
isNearBottomRef.current = distanceFromBottom < NEAR_BOTTOM_THRESHOLD_PX;
|
|
47
|
+
if (isNearBottomRef.current) {
|
|
48
|
+
setHasNewMessages(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function scrollToBottom() {
|
|
53
|
+
const el = containerRef.current;
|
|
54
|
+
if (!el) return;
|
|
55
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
56
|
+
isNearBottomRef.current = true;
|
|
57
|
+
setHasNewMessages(false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
62
|
+
<div className={styles.scrollContainer} ref={containerRef} onScroll={handleScroll}>
|
|
63
|
+
{children}
|
|
64
|
+
</div>
|
|
65
|
+
{hasNewMessages && (
|
|
66
|
+
<button type="button" className={styles.jumpButton} onClick={scrollToBottom}>
|
|
67
|
+
New messages ↓
|
|
68
|
+
</button>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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 { MessageScroller } from '../MessageScroller';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
/** jsdom doesn't implement layout, so scrollHeight/clientHeight are always 0
|
|
9
|
+
* unless stubbed. Mirrors the common pattern for testing scroll behavior. */
|
|
10
|
+
function stubScrollMetrics(
|
|
11
|
+
el: HTMLElement,
|
|
12
|
+
{
|
|
13
|
+
scrollHeight,
|
|
14
|
+
clientHeight,
|
|
15
|
+
scrollTop,
|
|
16
|
+
}: {
|
|
17
|
+
scrollHeight: number;
|
|
18
|
+
clientHeight: number;
|
|
19
|
+
scrollTop: number;
|
|
20
|
+
},
|
|
21
|
+
) {
|
|
22
|
+
Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true });
|
|
23
|
+
Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true });
|
|
24
|
+
Object.defineProperty(el, 'scrollTop', { value: scrollTop, writable: true, configurable: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('MessageScroller', () => {
|
|
28
|
+
it('auto-scrolls to the bottom when near-bottom and children change', () => {
|
|
29
|
+
const { container, rerender } = render(
|
|
30
|
+
<MessageScroller>
|
|
31
|
+
<div>message 1</div>
|
|
32
|
+
</MessageScroller>,
|
|
33
|
+
);
|
|
34
|
+
const scrollEl = container.querySelector('[class*="scrollContainer"]') as HTMLElement;
|
|
35
|
+
stubScrollMetrics(scrollEl, { scrollHeight: 500, clientHeight: 200, scrollTop: 0 });
|
|
36
|
+
|
|
37
|
+
rerender(
|
|
38
|
+
<MessageScroller>
|
|
39
|
+
<div>message 1</div>
|
|
40
|
+
<div>message 2</div>
|
|
41
|
+
</MessageScroller>,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(scrollEl.scrollTop).toBe(500);
|
|
45
|
+
expect(screen.queryByText('New messages ↓')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('shows a "New messages" button instead of auto-scrolling when scrolled up', () => {
|
|
49
|
+
const { container, rerender } = render(
|
|
50
|
+
<MessageScroller>
|
|
51
|
+
<div>message 1</div>
|
|
52
|
+
</MessageScroller>,
|
|
53
|
+
);
|
|
54
|
+
const scrollEl = container.querySelector('[class*="scrollContainer"]') as HTMLElement;
|
|
55
|
+
// Simulate the user having scrolled up, far from the bottom.
|
|
56
|
+
stubScrollMetrics(scrollEl, { scrollHeight: 500, clientHeight: 200, scrollTop: 0 });
|
|
57
|
+
fireEvent.scroll(scrollEl);
|
|
58
|
+
|
|
59
|
+
rerender(
|
|
60
|
+
<MessageScroller>
|
|
61
|
+
<div>message 1</div>
|
|
62
|
+
<div>message 2</div>
|
|
63
|
+
</MessageScroller>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(screen.getByText('New messages ↓')).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('scrolling back near the bottom does not force scrollTop (no fighting the user)', () => {
|
|
70
|
+
const { container } = render(
|
|
71
|
+
<MessageScroller>
|
|
72
|
+
<div>message 1</div>
|
|
73
|
+
</MessageScroller>,
|
|
74
|
+
);
|
|
75
|
+
const scrollEl = container.querySelector('[class*="scrollContainer"]') as HTMLElement;
|
|
76
|
+
stubScrollMetrics(scrollEl, { scrollHeight: 500, clientHeight: 200, scrollTop: 450 });
|
|
77
|
+
fireEvent.scroll(scrollEl);
|
|
78
|
+
|
|
79
|
+
expect(scrollEl.scrollTop).toBe(450);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('clicking the jump button scrolls to bottom and hides the button', () => {
|
|
83
|
+
const { container, rerender } = render(
|
|
84
|
+
<MessageScroller>
|
|
85
|
+
<div>message 1</div>
|
|
86
|
+
</MessageScroller>,
|
|
87
|
+
);
|
|
88
|
+
const scrollEl = container.querySelector('[class*="scrollContainer"]') as HTMLElement;
|
|
89
|
+
stubScrollMetrics(scrollEl, { scrollHeight: 500, clientHeight: 200, scrollTop: 0 });
|
|
90
|
+
fireEvent.scroll(scrollEl);
|
|
91
|
+
rerender(
|
|
92
|
+
<MessageScroller>
|
|
93
|
+
<div>message 1</div>
|
|
94
|
+
<div>message 2</div>
|
|
95
|
+
</MessageScroller>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByText('New messages ↓')).toBeDefined();
|
|
98
|
+
|
|
99
|
+
scrollEl.scrollTo = vi.fn();
|
|
100
|
+
fireEvent.click(screen.getByText('New messages ↓'));
|
|
101
|
+
|
|
102
|
+
expect(scrollEl.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: 'smooth' });
|
|
103
|
+
expect(screen.queryByText('New messages ↓')).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
.nav {
|
|
2
|
+
min-width: 0;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.list {
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: var(--sv-space-1);
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
list-style: none;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.link,
|
|
15
|
+
.trigger {
|
|
16
|
+
display: inline-flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
height: 36px;
|
|
19
|
+
padding: 0 var(--sv-space-3);
|
|
20
|
+
border: none;
|
|
21
|
+
border-radius: var(--sv-radius-md);
|
|
22
|
+
background: transparent;
|
|
23
|
+
color: var(--sv-color-text-primary);
|
|
24
|
+
font-family: var(--sv-font-family);
|
|
25
|
+
font-size: var(--sv-font-size-sm);
|
|
26
|
+
font-weight: var(--sv-font-weight-medium);
|
|
27
|
+
text-decoration: none;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@media (hover: hover) {
|
|
32
|
+
.link:hover,
|
|
33
|
+
.trigger:hover {
|
|
34
|
+
background: var(--sv-color-surface-sunken);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.link:focus-visible,
|
|
39
|
+
.trigger:focus-visible {
|
|
40
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
41
|
+
outline-offset: 2px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.triggerOpen {
|
|
45
|
+
background: var(--sv-color-surface-sunken);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@media (pointer: coarse) {
|
|
49
|
+
.link,
|
|
50
|
+
.trigger {
|
|
51
|
+
min-height: var(--sv-touch-target-min, 44px);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { NavigationMenu } from './NavigationMenu';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/NavigationMenu',
|
|
6
|
+
component: NavigationMenu,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'padded',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
'Top-level nav bar where some items open a flyout panel. Desktop-oriented by design — a hover-triggered flyout bar has no mobile equivalent. Once one flyout is open, hovering a sibling switches directly to it; ArrowLeft/ArrowRight move focus between triggers.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: { 'aria-label': 'Main' },
|
|
17
|
+
} satisfies Meta<typeof NavigationMenu>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const FlyoutContent = ({ links }: { links: string[] }) => (
|
|
25
|
+
<div style={{ padding: 'var(--sv-space-3)', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
26
|
+
{links.map((link) => (
|
|
27
|
+
<a
|
|
28
|
+
key={link}
|
|
29
|
+
href={`/${link.toLowerCase().replace(/\s+/g, '-')}`}
|
|
30
|
+
style={{
|
|
31
|
+
fontSize: 14,
|
|
32
|
+
color: 'var(--sv-color-text-primary)',
|
|
33
|
+
textDecoration: 'none',
|
|
34
|
+
padding: 'var(--sv-space-1) var(--sv-space-2)',
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{link}
|
|
38
|
+
</a>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const Default: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
items: [
|
|
46
|
+
{ label: 'Home', href: '/home' },
|
|
47
|
+
{
|
|
48
|
+
label: 'Products',
|
|
49
|
+
content: <FlyoutContent links={['Tasks', 'Ledger', 'Shopper', 'Wallet']} />,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: 'Company',
|
|
53
|
+
content: <FlyoutContent links={['About', 'Blog', 'Careers']} />,
|
|
54
|
+
},
|
|
55
|
+
{ label: 'Docs', href: '/docs' },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
};
|