@sovereignfs/ui 0.54.0 → 0.83.1
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 +1 -1
- package/dist/Alert.module.css +1 -1
- package/dist/AppsLauncher.module.css +96 -0
- package/dist/Avatar.module.css +1 -1
- package/dist/Badge.module.css +30 -7
- package/dist/BalanceChip.module.css +1 -1
- package/dist/Button.module.css +18 -2
- package/dist/Card.module.css +1 -1
- package/dist/CardTile.module.css +181 -0
- package/dist/CodeTextarea.module.css +1 -1
- package/dist/Collapsible.module.css +1 -1
- package/dist/ColorPicker.module.css +132 -0
- package/dist/Combobox.module.css +2 -2
- package/dist/Command.module.css +1 -1
- package/dist/ConfirmDialog.module.css +2 -2
- package/dist/ConsentPrompt.module.css +21 -0
- package/dist/DatePicker.module.css +1 -1
- package/dist/Dialog.module.css +130 -21
- package/dist/Drawer.module.css +36 -7
- package/dist/Header.module.css +62 -0
- package/dist/HeaderFooterLayout.module.css +21 -0
- package/dist/IconPicker.module.css +1 -1
- package/dist/Input.module.css +1 -1
- package/dist/Kbd.module.css +1 -1
- package/dist/Markdown.module.css +24 -0
- package/dist/Menu.module.css +1 -0
- package/dist/Message.module.css +1 -1
- package/dist/MessageScroller.module.css +12 -1
- package/dist/MobileAppsDrawer.module.css +10 -9
- package/dist/MobileFooter.module.css +1 -1
- package/dist/MobileHeader.module.css +14 -1
- package/dist/NavList.module.css +130 -0
- package/dist/NavTabs.module.css +1 -1
- package/dist/NotificationsPanel.module.css +258 -0
- package/dist/OverlayHeader.module.css +1 -1
- package/dist/PageContainer.module.css +62 -0
- package/dist/PageHeader.module.css +54 -0
- package/dist/PageLayout.module.css +51 -0
- package/dist/Popover.module.css +1 -1
- package/dist/QuantityStepper.module.css +1 -1
- package/dist/Resizable.module.css +3 -3
- package/dist/RootLayout.module.css +12 -0
- package/dist/SegmentedControl.module.css +1 -1
- package/dist/Select.module.css +1 -1
- package/dist/Sheet.module.css +4 -1
- package/dist/SplitPane.module.css +4 -4
- package/dist/StatusBadge.module.css +1 -1
- package/dist/StepStrip.module.css +53 -0
- package/dist/SuggestionInput.module.css +2 -2
- package/dist/SwipableMobileCarousel.module.css +12 -3
- package/dist/SwipableMobileCarouselDots.module.css +15 -0
- package/dist/SwipeStack.module.css +101 -0
- package/dist/SystemBanner.module.css +10 -0
- package/dist/Table.module.css +2 -2
- package/dist/Tabs.module.css +1 -1
- package/dist/TagInput.module.css +2 -2
- package/dist/Textarea.module.css +9 -1
- package/dist/ThreeColumnLayout.module.css +35 -0
- package/dist/Toast.module.css +62 -3
- package/dist/Tooltip.module.css +1 -1
- package/dist/UserMenu.module.css +90 -0
- package/dist/index.d.ts +1047 -45
- package/dist/index.js +3908 -1456
- package/dist/tokens/primitives.css +11 -0
- package/dist/tokens/semantic.css +32 -0
- package/dist/tokens/theme-presets.css +33 -0
- package/package.json +25 -9
- package/src/__tests__/package-json.test.ts +8 -0
- package/src/components/Accordion/Accordion.module.css +1 -1
- package/src/components/Alert/Alert.module.css +1 -1
- package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
- package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
- package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
- package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
- package/src/components/Avatar/Avatar.module.css +1 -1
- package/src/components/Badge/Badge.module.css +30 -7
- package/src/components/Badge/Badge.stories.tsx +16 -4
- package/src/components/Badge/Badge.tsx +7 -4
- package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
- package/src/components/BalanceChip/BalanceChip.module.css +1 -1
- package/src/components/Button/Button.module.css +18 -2
- package/src/components/Card/Card.module.css +1 -1
- package/src/components/CardTile/CardTile.module.css +181 -0
- package/src/components/CardTile/CardTile.stories.tsx +86 -0
- package/src/components/CardTile/CardTile.tsx +128 -0
- package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
- package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
- package/src/components/Collapsible/Collapsible.module.css +1 -1
- package/src/components/ColorPicker/ColorPicker.module.css +132 -0
- package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
- package/src/components/ColorPicker/ColorPicker.tsx +157 -0
- package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
- package/src/components/Combobox/Combobox.module.css +2 -2
- package/src/components/Command/Command.module.css +1 -1
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
- package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
- package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
- package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
- package/src/components/ContextMenu/ContextMenu.tsx +1 -1
- package/src/components/DatePicker/DatePicker.module.css +1 -1
- package/src/components/Dialog/Dialog.module.css +130 -21
- package/src/components/Dialog/Dialog.stories.tsx +179 -2
- package/src/components/Dialog/Dialog.tsx +129 -35
- package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
- package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
- package/src/components/Drawer/Drawer.module.css +36 -7
- package/src/components/Drawer/Drawer.tsx +9 -10
- package/src/components/Drawer/__tests__/Drawer.test.tsx +33 -6
- package/src/components/GripIcon/GripIcon.tsx +27 -0
- package/src/components/Header/Header.module.css +62 -0
- package/src/components/Header/Header.stories.tsx +452 -0
- package/src/components/Header/Header.tsx +102 -0
- package/src/components/Header/__tests__/Header.test.tsx +104 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
- package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
- package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
- package/src/components/Icon/icons/bold.tsx +20 -0
- package/src/components/Icon/icons/calendar.tsx +4 -4
- package/src/components/Icon/icons/carrot.tsx +3 -3
- package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
- package/src/components/Icon/icons/code.tsx +21 -0
- package/src/components/Icon/icons/copy.tsx +21 -0
- package/src/components/Icon/icons/credit-card.tsx +21 -0
- package/src/components/Icon/icons/database.tsx +22 -0
- package/src/components/Icon/icons/download.tsx +22 -0
- package/src/components/Icon/icons/file-text.tsx +24 -0
- package/src/components/Icon/icons/folder-closed.tsx +21 -0
- package/src/components/Icon/icons/folder-open.tsx +20 -0
- package/src/components/Icon/icons/folder.tsx +20 -0
- package/src/components/Icon/icons/folders.tsx +21 -0
- package/src/components/Icon/icons/hat-glasses.tsx +24 -0
- package/src/components/Icon/icons/history.tsx +22 -0
- package/src/components/Icon/icons/inbox.tsx +21 -0
- package/src/components/Icon/icons/index.ts +161 -1
- package/src/components/Icon/icons/italic.tsx +22 -0
- package/src/components/Icon/icons/layers.tsx +22 -0
- package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
- package/src/components/Icon/icons/layout-grid.tsx +23 -0
- package/src/components/Icon/icons/link.tsx +21 -0
- package/src/components/Icon/icons/list-ordered.tsx +25 -0
- package/src/components/Icon/icons/list.tsx +25 -0
- package/src/components/Icon/icons/luggage.tsx +24 -0
- package/src/components/Icon/icons/map-pin.tsx +21 -0
- package/src/components/Icon/icons/minus.tsx +20 -0
- package/src/components/Icon/icons/paint-bucket.tsx +23 -0
- package/src/components/Icon/icons/panel-left.tsx +21 -0
- package/src/components/Icon/icons/pin.tsx +21 -0
- package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
- package/src/components/Icon/icons/refresh-cw.tsx +23 -0
- package/src/components/Icon/icons/rotate-cw.tsx +21 -0
- package/src/components/Icon/icons/route.tsx +22 -0
- package/src/components/Icon/icons/send.tsx +21 -0
- package/src/components/Icon/icons/share-2.tsx +24 -0
- package/src/components/Icon/icons/sheet.tsx +24 -0
- package/src/components/Icon/icons/square-kanban.tsx +23 -0
- package/src/components/Icon/icons/table.tsx +23 -0
- package/src/components/Icon/icons/user-round-plus.tsx +23 -0
- package/src/components/Icon/icons/users.tsx +23 -0
- package/src/components/IconPicker/IconPicker.module.css +1 -1
- package/src/components/Input/Input.module.css +1 -1
- package/src/components/Input/Input.tsx +8 -2
- package/src/components/Kbd/Kbd.module.css +1 -1
- package/src/components/Markdown/Markdown.module.css +24 -0
- package/src/components/Markdown/Markdown.stories.tsx +10 -0
- package/src/components/Markdown/Markdown.tsx +166 -29
- package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
- package/src/components/Menu/Menu.module.css +1 -0
- package/src/components/Menu/Menu.tsx +72 -22
- package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
- package/src/components/Menubar/Menubar.tsx +1 -1
- package/src/components/Message/Message.module.css +1 -1
- package/src/components/MessageScroller/MessageScroller.module.css +12 -1
- package/src/components/MessageScroller/MessageScroller.tsx +12 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +10 -9
- package/src/components/MobileFooter/MobileFooter.module.css +1 -1
- package/src/components/MobileFooter/MobileFooter.tsx +15 -2
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +58 -0
- package/src/components/MobileHeader/MobileHeader.module.css +14 -1
- package/src/components/MobileHeader/MobileHeader.tsx +15 -2
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +56 -1
- package/src/components/NavList/NavList.module.css +130 -0
- package/src/components/NavList/NavList.tsx +136 -0
- package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
- package/src/components/NavTabs/NavTabs.module.css +1 -1
- package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
- package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
- package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
- package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
- package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
- package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
- package/src/components/PageContainer/PageContainer.module.css +62 -0
- package/src/components/PageContainer/PageContainer.tsx +37 -4
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +41 -8
- package/src/components/PageHeader/PageHeader.module.css +54 -0
- package/src/components/PageHeader/PageHeader.tsx +146 -5
- package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
- package/src/components/PageLayout/PageLayout.module.css +51 -0
- package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
- package/src/components/PageLayout/PageLayout.tsx +51 -0
- package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
- package/src/components/Popover/Popover.module.css +1 -1
- package/src/components/Popover/Popover.tsx +86 -4
- package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
- package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
- package/src/components/Resizable/Resizable.module.css +3 -3
- package/src/components/RootLayout/RootLayout.module.css +12 -0
- package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
- package/src/components/RootLayout/RootLayout.tsx +185 -0
- package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
- package/src/components/Select/Select.module.css +1 -1
- package/src/components/Sheet/Sheet.module.css +4 -1
- package/src/components/Sheet/Sheet.tsx +5 -5
- package/src/components/SplitPane/SplitPane.module.css +4 -4
- package/src/components/StatusBadge/StatusBadge.module.css +1 -1
- package/src/components/StepStrip/StepStrip.module.css +53 -0
- package/src/components/StepStrip/StepStrip.tsx +74 -0
- package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +12 -3
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +25 -9
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +81 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +15 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +12 -1
- package/src/components/SwipeStack/SwipeStack.module.css +101 -0
- package/src/components/SwipeStack/SwipeStack.tsx +199 -0
- package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
- package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
- package/src/components/SystemBanner/SystemBanner.module.css +10 -0
- package/src/components/Table/Table.module.css +2 -2
- package/src/components/Tabs/Tabs.module.css +1 -1
- package/src/components/TagInput/TagInput.module.css +2 -2
- package/src/components/Textarea/Textarea.module.css +9 -1
- package/src/components/Textarea/Textarea.tsx +28 -5
- package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
- package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
- package/src/components/Toast/Toast.module.css +62 -3
- package/src/components/Toast/Toast.stories.tsx +1 -1
- package/src/components/Tooltip/Tooltip.module.css +1 -1
- package/src/components/UserMenu/UserMenu.module.css +90 -0
- package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
- package/src/components/UserMenu/UserMenu.tsx +132 -0
- package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
- package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
- package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +82 -0
- package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/usePublishShellChromeHeight.ts +96 -0
- package/src/hooks/useReorderSensors.ts +88 -0
- package/src/hooks/useSnapCarousel.ts +211 -6
- package/src/hooks/useSwipeStack.ts +235 -0
- package/src/index.ts +61 -1
- package/src/overlay-shell.ts +65 -0
- package/src/stories/DesignSystemOverview.stories.tsx +617 -11
- package/src/stories/GripIcon.stories.tsx +29 -0
- package/src/stories/LayoutSystem.stories.tsx +639 -0
- package/src/stories/MenuEntries.stories.tsx +86 -0
- package/src/stories/NavList.stories.tsx +147 -0
- package/src/stories/PageContainer.stories.tsx +52 -7
- package/src/stories/PageHeader.stories.tsx +54 -1
- package/src/stories/StepStrip.stories.tsx +76 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +58 -0
- package/src/stories/SwipeStack.stories.tsx +200 -0
- package/src/stories/Textarea.stories.tsx +12 -0
- package/src/stories/TokenGallery.stories.tsx +50 -0
- package/src/tokens/primitives.css +11 -0
- package/src/tokens/semantic.css +32 -0
- package/src/tokens/theme-presets.css +33 -0
- package/src/tokens/theme-presets.ts +72 -0
|
@@ -1,20 +1,65 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Fragment, type ReactNode } from 'react';
|
|
2
2
|
import { Typography } from '../Typography/Typography';
|
|
3
3
|
import styles from './Markdown.module.css';
|
|
4
4
|
|
|
5
5
|
export interface MarkdownProps {
|
|
6
|
-
/** Raw markdown source. Supports
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
/** Raw markdown source. Supports: headings (#/##/###), paragraphs,
|
|
7
|
+
* **bold**, *italic*, `code`, [links](url), unordered lists (-), ordered
|
|
8
|
+
* lists (1. / 1)), fenced code blocks (```), and blockquotes (>),
|
|
9
|
+
* including soft-wrapped continuation lines within a paragraph or list
|
|
10
|
+
* item. Not a general-purpose CommonMark parser — no tables, images, or
|
|
11
|
+
* nested blocks. */
|
|
12
12
|
content: string;
|
|
13
|
+
/** Renders every line within a paragraph on its own visual line (a
|
|
14
|
+
* `<br>` between them) instead of the default CommonMark-style soft-wrap
|
|
15
|
+
* (consecutive lines joined with a single space, requiring a blank line
|
|
16
|
+
* for a new paragraph). Off by default — the soft-wrap behavior is
|
|
17
|
+
* correct for this component's primary use (long-form, first-party
|
|
18
|
+
* content authored *as* markdown, where a hard line break is a
|
|
19
|
+
* deliberate `\` ` or blank-line choice). Turn this on when rendering
|
|
20
|
+
* plain, user-typed multi-line text that happens to be passed through
|
|
21
|
+
* this component (e.g. a card description or comment body edited in an
|
|
22
|
+
* ordinary `<textarea>`) — there, every Enter press is a real,
|
|
23
|
+
* intentional line break the user typed, and silently collapsing it
|
|
24
|
+
* into one run-on paragraph reads as broken, not as "markdown
|
|
25
|
+
* formatting applied." */
|
|
26
|
+
preserveLineBreaks?: boolean;
|
|
13
27
|
className?: string;
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
let inlineKey = 0;
|
|
17
31
|
|
|
32
|
+
/** Schemes allowed in a rendered `[text](url)` link's `href`. Anything else —
|
|
33
|
+
* most importantly `javascript:` — is rejected: `content` here can be
|
|
34
|
+
* LLM-generated (Warden's chat replies), so a prompt-injected document or a
|
|
35
|
+
* compromised model is a realistic way for an attacker-chosen URL to end up
|
|
36
|
+
* in this position. */
|
|
37
|
+
const SAFE_HREF_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
|
|
38
|
+
|
|
39
|
+
function isSafeHref(href: string): boolean {
|
|
40
|
+
const scheme = /^([a-zA-Z][a-zA-Z\d+.-]*):/.exec(href)?.[1];
|
|
41
|
+
return scheme === undefined || SAFE_HREF_SCHEMES.has(scheme.toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns a link's `href` value ready to render, or `null` to reject it.
|
|
45
|
+
* Rejecting is what keeps an unsafe URL out of the DOM; `encodeURI` on top
|
|
46
|
+
* is the contextual output-encoding this class of finding calls for. The
|
|
47
|
+
* sink below reads this function's *return value*, never the raw `href` —
|
|
48
|
+
* a boolean guard around the original string still leaves the tainted
|
|
49
|
+
* value itself flowing into `href`. Browsers ignore tabs/newlines when
|
|
50
|
+
* parsing a URL's scheme, so those are stripped first to close a
|
|
51
|
+
* `jav\tascript:`-style bypass; `encodeURI` throws on a lone unpaired
|
|
52
|
+
* surrogate, hence the try/catch. */
|
|
53
|
+
function sanitizeHref(href: string): string | null {
|
|
54
|
+
const normalized = href.replace(/[\t\n\r]/g, '').trim();
|
|
55
|
+
if (!normalized || !isSafeHref(normalized)) return null;
|
|
56
|
+
try {
|
|
57
|
+
return encodeURI(normalized);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
18
63
|
/** Parses **bold**, *italic*, `code`, and [text](url) within a line of text. */
|
|
19
64
|
function parseInline(text: string): ReactNode[] {
|
|
20
65
|
const nodes: ReactNode[] = [];
|
|
@@ -32,10 +77,15 @@ function parseInline(text: string): ReactNode[] {
|
|
|
32
77
|
const [full, linkText, linkHref, boldText, italicText, codeText] = match;
|
|
33
78
|
const key = `md-inline-${inlineKey++}`;
|
|
34
79
|
if (linkHref !== undefined) {
|
|
80
|
+
const safeHref = sanitizeHref(linkHref);
|
|
35
81
|
nodes.push(
|
|
36
|
-
|
|
37
|
-
{
|
|
38
|
-
|
|
82
|
+
safeHref !== null ? (
|
|
83
|
+
<a key={key} href={safeHref}>
|
|
84
|
+
{linkText}
|
|
85
|
+
</a>
|
|
86
|
+
) : (
|
|
87
|
+
linkText
|
|
88
|
+
),
|
|
39
89
|
);
|
|
40
90
|
} else if (boldText !== undefined) {
|
|
41
91
|
nodes.push(<strong key={key}>{boldText}</strong>);
|
|
@@ -53,20 +103,31 @@ function parseInline(text: string): ReactNode[] {
|
|
|
53
103
|
const isQuote = (l: string) => l.startsWith('> ') || l === '>';
|
|
54
104
|
const isListItem = (l: string) => l.startsWith('- ');
|
|
55
105
|
const isHeading = (l: string) => /^#{1,3}\s+/.test(l);
|
|
106
|
+
/** ```` ```lang ```` — an opening (or closing) code fence. */
|
|
107
|
+
const isFence = (l: string) => /^\s*`{3,}/.test(l);
|
|
108
|
+
/** `1. item` or `1) item`. Bounded digit count so a long numeric line
|
|
109
|
+
* can't be mistaken for a list marker. */
|
|
110
|
+
const isOrderedItem = (l: string) => /^\d{1,9}[.)]\s+/.test(l);
|
|
56
111
|
/** A line that ends the current *paragraph* run — a blank line, or the
|
|
57
|
-
* start of a heading, quote, or
|
|
58
|
-
* indented continuation line) is folded into the paragraph, matching how
|
|
112
|
+
* start of a heading, quote, list, or code block. Anything else (including
|
|
113
|
+
* an indented continuation line) is folded into the paragraph, matching how
|
|
59
114
|
* the source markdown soft-wraps prose across lines. */
|
|
60
|
-
const endsParagraph = (l: string) =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
115
|
+
const endsParagraph = (l: string) =>
|
|
116
|
+
l.trim() === '' || isHeading(l) || isQuote(l) || isListItem(l) || isOrderedItem(l) || isFence(l);
|
|
117
|
+
/** A line that ends the current *unordered list* run — a blank line, or the
|
|
118
|
+
* start of a heading, quote, code block, or an *ordered* item. Deliberately
|
|
119
|
+
* does NOT include `isListItem`: a list item's own opening line legitimately
|
|
120
|
+
* satisfies `isListItem`, so reusing `endsParagraph` here (which does) would
|
|
121
|
+
* treat the first line of every list as ending the list before it started —
|
|
122
|
+
* the list-collection loop would then never advance, looping forever. */
|
|
123
|
+
const endsList = (l: string) =>
|
|
124
|
+
l.trim() === '' || isHeading(l) || isQuote(l) || isFence(l) || isOrderedItem(l);
|
|
125
|
+
/** The ordered-list mirror of `endsList` — excludes `isOrderedItem` for the
|
|
126
|
+
* same reason, and treats an unordered item as the start of a new block. */
|
|
127
|
+
const endsOrderedList = (l: string) =>
|
|
128
|
+
l.trim() === '' || isHeading(l) || isQuote(l) || isFence(l) || isListItem(l);
|
|
129
|
+
|
|
130
|
+
function parseBlocks(content: string, preserveLineBreaks: boolean): ReactNode[] {
|
|
70
131
|
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
71
132
|
const blocks: ReactNode[] = [];
|
|
72
133
|
let i = 0;
|
|
@@ -80,6 +141,34 @@ function parseBlocks(content: string): ReactNode[] {
|
|
|
80
141
|
continue;
|
|
81
142
|
}
|
|
82
143
|
|
|
144
|
+
// Checked before every other block type: everything between the fences
|
|
145
|
+
// is verbatim, including blank lines and lines that would otherwise
|
|
146
|
+
// read as headings, quotes, or list items.
|
|
147
|
+
if (isFence(line)) {
|
|
148
|
+
const fenceMatch = /^\s*(`{3,})(.*)$/.exec(line);
|
|
149
|
+
const fence = fenceMatch?.[1] ?? '```';
|
|
150
|
+
const language = (fenceMatch?.[2] ?? '').trim();
|
|
151
|
+
const closing = new RegExp(`^\\s*${fence}\\s*$`);
|
|
152
|
+
i++;
|
|
153
|
+
const codeLines: string[] = [];
|
|
154
|
+
while (i < lines.length && !closing.test(lines[i] ?? '')) {
|
|
155
|
+
codeLines.push(lines[i] ?? '');
|
|
156
|
+
i++;
|
|
157
|
+
}
|
|
158
|
+
// Steps past the closing fence; a no-op at end of input, so an
|
|
159
|
+
// unterminated fence renders the remainder as code (as CommonMark
|
|
160
|
+
// does) rather than looping.
|
|
161
|
+
i++;
|
|
162
|
+
blocks.push(
|
|
163
|
+
<div key={blockKey++} className={styles.block}>
|
|
164
|
+
<pre className={styles.codeBlock}>
|
|
165
|
+
<code data-language={language || undefined}>{codeLines.join('\n')}</code>
|
|
166
|
+
</pre>
|
|
167
|
+
</div>,
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
83
172
|
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
84
173
|
if (heading) {
|
|
85
174
|
const level = (heading[1]?.length ?? 1) as 1 | 2 | 3;
|
|
@@ -112,6 +201,41 @@ function parseBlocks(content: string): ReactNode[] {
|
|
|
112
201
|
continue;
|
|
113
202
|
}
|
|
114
203
|
|
|
204
|
+
if (isOrderedItem(line)) {
|
|
205
|
+
const items: string[] = [];
|
|
206
|
+
let start: number | undefined;
|
|
207
|
+
while (i < lines.length && !endsOrderedList(lines[i] ?? '')) {
|
|
208
|
+
const current = lines[i] ?? '';
|
|
209
|
+
const match = /^(\d{1,9})[.)]\s+(.*)$/.exec(current);
|
|
210
|
+
if (match) {
|
|
211
|
+
// Honour the author's own starting number (a list resuming at 4),
|
|
212
|
+
// but let the browser number the rest — markdown's own numbers
|
|
213
|
+
// are conventionally ignored after the first.
|
|
214
|
+
if (start === undefined) start = Number(match[1]);
|
|
215
|
+
items.push(match[2] ?? '');
|
|
216
|
+
} else {
|
|
217
|
+
const lastIndex = items.length - 1;
|
|
218
|
+
items[lastIndex] = `${items[lastIndex] ?? ''} ${current.trim()}`;
|
|
219
|
+
}
|
|
220
|
+
i++;
|
|
221
|
+
}
|
|
222
|
+
blocks.push(
|
|
223
|
+
<div key={blockKey++} className={styles.block}>
|
|
224
|
+
<ol
|
|
225
|
+
className={styles.list}
|
|
226
|
+
start={start !== undefined && start !== 1 ? start : undefined}
|
|
227
|
+
>
|
|
228
|
+
{items.map((item, idx) => (
|
|
229
|
+
<Typography key={idx} variant="body" as="li">
|
|
230
|
+
{parseInline(item)}
|
|
231
|
+
</Typography>
|
|
232
|
+
))}
|
|
233
|
+
</ol>
|
|
234
|
+
</div>,
|
|
235
|
+
);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
115
239
|
if (isListItem(line)) {
|
|
116
240
|
const items: string[] = [];
|
|
117
241
|
while (i < lines.length && !endsList(lines[i] ?? '')) {
|
|
@@ -147,7 +271,14 @@ function parseBlocks(content: string): ReactNode[] {
|
|
|
147
271
|
blocks.push(
|
|
148
272
|
<div key={blockKey++} className={styles.block}>
|
|
149
273
|
<Typography variant="body" as="p">
|
|
150
|
-
{
|
|
274
|
+
{preserveLineBreaks
|
|
275
|
+
? paraLines.map((paraLine, lineIdx) => (
|
|
276
|
+
<Fragment key={lineIdx}>
|
|
277
|
+
{lineIdx > 0 && <br />}
|
|
278
|
+
{parseInline(paraLine)}
|
|
279
|
+
</Fragment>
|
|
280
|
+
))
|
|
281
|
+
: parseInline(paraLines.join(' '))}
|
|
151
282
|
</Typography>
|
|
152
283
|
</div>,
|
|
153
284
|
);
|
|
@@ -157,12 +288,18 @@ function parseBlocks(content: string): ReactNode[] {
|
|
|
157
288
|
}
|
|
158
289
|
|
|
159
290
|
/** Renders a constrained markdown subset (see MarkdownProps) as styled React
|
|
160
|
-
* elements built from Typography — no HTML string injection.
|
|
161
|
-
* long-form, first-party content pages (privacy policy, terms of
|
|
162
|
-
* sourced from a single markdown file
|
|
163
|
-
*
|
|
164
|
-
|
|
291
|
+
* elements built from Typography — no HTML string injection. Primarily
|
|
292
|
+
* meant for long-form, first-party content pages (privacy policy, terms of
|
|
293
|
+
* service) sourced from a single markdown file — not a general-purpose
|
|
294
|
+
* CommonMark renderer for arbitrary markdown. Also fine for plain,
|
|
295
|
+
* user-typed multi-line text (a card description, a comment body) edited
|
|
296
|
+
* in an ordinary `<textarea>` and passed through unchanged, as long as
|
|
297
|
+
* `preserveLineBreaks` is set — see that prop's own doc comment for why it
|
|
298
|
+
* isn't the default. */
|
|
299
|
+
export function Markdown({ content, preserveLineBreaks = false, className }: MarkdownProps) {
|
|
165
300
|
return (
|
|
166
|
-
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
301
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
302
|
+
{parseBlocks(content, preserveLineBreaks)}
|
|
303
|
+
</div>
|
|
167
304
|
);
|
|
168
305
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { Markdown } from '../Markdown';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Markdown', () => {
|
|
9
|
+
it('joins consecutive lines into one paragraph by default (soft-wrap)', () => {
|
|
10
|
+
const { container } = render(<Markdown content={'Line one.\nLine two.'} />);
|
|
11
|
+
const p = container.querySelector('p');
|
|
12
|
+
expect(p?.textContent).toBe('Line one. Line two.');
|
|
13
|
+
expect(p?.querySelector('br')).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('preserveLineBreaks keeps each line on its own line via <br>', () => {
|
|
17
|
+
const { container } = render(
|
|
18
|
+
<Markdown content={'Line one.\nLine two.\nLine three.'} preserveLineBreaks />,
|
|
19
|
+
);
|
|
20
|
+
const p = container.querySelector('p');
|
|
21
|
+
expect(p?.querySelectorAll('br')).toHaveLength(2);
|
|
22
|
+
expect(p?.textContent).toBe('Line one.Line two.Line three.');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('preserveLineBreaks still starts a new paragraph on a blank line', () => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<Markdown content={'First paragraph.\n\nSecond paragraph.'} preserveLineBreaks />,
|
|
28
|
+
);
|
|
29
|
+
const paragraphs = container.querySelectorAll('p');
|
|
30
|
+
expect(paragraphs).toHaveLength(2);
|
|
31
|
+
expect(paragraphs[0]?.textContent).toBe('First paragraph.');
|
|
32
|
+
expect(paragraphs[1]?.textContent).toBe('Second paragraph.');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('preserveLineBreaks does not affect headings, quotes, or lists', () => {
|
|
36
|
+
const { container } = render(
|
|
37
|
+
<Markdown content={'# Heading\n\n- item one\n- item two'} preserveLineBreaks />,
|
|
38
|
+
);
|
|
39
|
+
expect(container.querySelector('h1')?.textContent).toBe('Heading');
|
|
40
|
+
expect(container.querySelectorAll('li')).toHaveLength(2);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('Markdown — ordered lists and fenced code', () => {
|
|
45
|
+
it('renders an ordered list, numbering from the browser', () => {
|
|
46
|
+
const { container } = render(<Markdown content={'1. first\n2. second\n3. third'} />);
|
|
47
|
+
|
|
48
|
+
const list = container.querySelector('ol');
|
|
49
|
+
expect(list).not.toBeNull();
|
|
50
|
+
expect([...(list?.querySelectorAll('li') ?? [])].map((li) => li.textContent)).toEqual([
|
|
51
|
+
'first',
|
|
52
|
+
'second',
|
|
53
|
+
'third',
|
|
54
|
+
]);
|
|
55
|
+
expect(list?.getAttribute('start')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('honours a list that starts at a number other than 1', () => {
|
|
59
|
+
const { container } = render(<Markdown content={'4. fourth\n5. fifth'} />);
|
|
60
|
+
expect(container.querySelector('ol')?.getAttribute('start')).toBe('4');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('accepts the 1) marker style and applies inline formatting inside items', () => {
|
|
64
|
+
const { container } = render(<Markdown content={'1) do **this**'} />);
|
|
65
|
+
|
|
66
|
+
expect(container.querySelector('ol')).not.toBeNull();
|
|
67
|
+
expect(container.querySelector('ol strong')?.textContent).toBe('this');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps ordered and unordered lists as separate blocks', () => {
|
|
71
|
+
const { container } = render(<Markdown content={'- bullet\n1. number'} />);
|
|
72
|
+
|
|
73
|
+
expect(container.querySelectorAll('ul')).toHaveLength(1);
|
|
74
|
+
expect(container.querySelectorAll('ol')).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders a fenced code block verbatim, without parsing its contents', () => {
|
|
78
|
+
const content = ['```ts', 'const a = 1; // **not bold**', '', '# not a heading', '```'].join(
|
|
79
|
+
'\n',
|
|
80
|
+
);
|
|
81
|
+
const { container } = render(<Markdown content={content} />);
|
|
82
|
+
|
|
83
|
+
const code = container.querySelector('pre code');
|
|
84
|
+
expect(code?.textContent).toBe('const a = 1; // **not bold**\n\n# not a heading');
|
|
85
|
+
expect(container.querySelector('strong')).toBeNull();
|
|
86
|
+
expect(container.querySelector('h1')).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('exposes the fence language for styling hooks', () => {
|
|
90
|
+
const { container } = render(<Markdown content={'```python\nx = 1\n```'} />);
|
|
91
|
+
expect(container.querySelector('pre code')?.getAttribute('data-language')).toBe('python');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('terminates on an unclosed fence instead of looping', () => {
|
|
95
|
+
const { container } = render(<Markdown content={'text\n\n```\nnever closed'} />);
|
|
96
|
+
expect(container.querySelector('pre code')?.textContent).toBe('never closed');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('separates a code block from the paragraph before it', () => {
|
|
100
|
+
const { container } = render(<Markdown content={'Try this:\n```\nrun me\n```'} />);
|
|
101
|
+
|
|
102
|
+
expect(container.querySelector('p')?.textContent).toBe('Try this:');
|
|
103
|
+
expect(container.querySelector('pre code')?.textContent).toBe('run me');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('Markdown — link hrefs', () => {
|
|
108
|
+
it('renders an http(s) link as a real anchor', () => {
|
|
109
|
+
const { container } = render(<Markdown content={'[docs](https://example.com/docs)'} />);
|
|
110
|
+
const link = container.querySelector('a');
|
|
111
|
+
expect(link?.getAttribute('href')).toBe('https://example.com/docs');
|
|
112
|
+
expect(link?.textContent).toBe('docs');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('renders a relative link as a real anchor', () => {
|
|
116
|
+
const { container } = render(<Markdown content={'[here](/warden/models)'} />);
|
|
117
|
+
expect(container.querySelector('a')?.getAttribute('href')).toBe('/warden/models');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('renders a mailto: link as a real anchor', () => {
|
|
121
|
+
const { container } = render(<Markdown content={'[email](mailto:a@example.com)'} />);
|
|
122
|
+
expect(container.querySelector('a')?.getAttribute('href')).toBe('mailto:a@example.com');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('neutralizes a javascript: URL instead of making it clickable', () => {
|
|
126
|
+
const { container } = render(
|
|
127
|
+
<Markdown content={'[click me](javascript:alert(document.cookie))'} />,
|
|
128
|
+
);
|
|
129
|
+
expect(container.querySelector('a')).toBeNull();
|
|
130
|
+
expect(container.textContent).toContain('click me');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('neutralizes a javascript: URL disguised with an embedded tab', () => {
|
|
134
|
+
const { container } = render(<Markdown content={'[x](jav\tascript:alert(1))'} />);
|
|
135
|
+
expect(container.querySelector('a')).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('neutralizes a data: URL', () => {
|
|
139
|
+
const { container } = render(
|
|
140
|
+
<Markdown content={'[x](data:text/html,<script>alert(1)</script>)'} />,
|
|
141
|
+
);
|
|
142
|
+
expect(container.querySelector('a')).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -10,7 +10,16 @@ import styles from './Menu.module.css';
|
|
|
10
10
|
export interface MenuItem {
|
|
11
11
|
type?: 'item';
|
|
12
12
|
label: string;
|
|
13
|
-
|
|
13
|
+
/** Optional when `href` is provided (a pure navigation entry needs no
|
|
14
|
+
* extra callback) — required otherwise. Called before the surface closes
|
|
15
|
+
* is the caller's job to avoid; `Menu`'s own wiring always closes first,
|
|
16
|
+
* then calls this. */
|
|
17
|
+
onSelect?: () => void;
|
|
18
|
+
/** Renders the entry as a link (`<a href>`, `role="menuitem"`) instead of
|
|
19
|
+
* a `<button>` — same href-vs-onClick convention as `MobileAppsDrawer`'s
|
|
20
|
+
* items. Framework-agnostic (a plain anchor, not `next/link`), matching
|
|
21
|
+
* every other `packages/ui` href slot. */
|
|
22
|
+
href?: string;
|
|
14
23
|
icon?: IconName;
|
|
15
24
|
/** Styles the item as a destructive action (e.g. "Delete list"). */
|
|
16
25
|
destructive?: boolean;
|
|
@@ -50,6 +59,15 @@ export interface MenuProps {
|
|
|
50
59
|
/** Forwarded to `Popover` on desktop. Has no effect on mobile, where the
|
|
51
60
|
* menu is always a full-width `Drawer`. */
|
|
52
61
|
align?: 'left' | 'right';
|
|
62
|
+
/** Forwarded to `Popover` on desktop (same default: 288). Has no effect
|
|
63
|
+
* on mobile. Narrow this when the trigger lives inside a column
|
|
64
|
+
* narrower than the default width — e.g. a per-row "⋯" menu in a
|
|
65
|
+
* sidebar list — so the panel fits within that column's own bounds
|
|
66
|
+
* instead of overflowing past its edge, where a scrollable ancestor's
|
|
67
|
+
* `overflow-y: auto` (which the CSS Overflow spec computes the paired
|
|
68
|
+
* `overflow-x` to `auto` as well, not `visible`, once either axis is
|
|
69
|
+
* set) would clip it. */
|
|
70
|
+
width?: number | 'trigger';
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
/**
|
|
@@ -100,26 +118,43 @@ export function MenuEntries({
|
|
|
100
118
|
);
|
|
101
119
|
}
|
|
102
120
|
const isCheckable = entry.checked !== undefined;
|
|
121
|
+
const className = [styles.item, entry.destructive ? styles.itemDestructive : '']
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join(' ');
|
|
124
|
+
const content = (
|
|
125
|
+
<>
|
|
126
|
+
{isCheckable && (
|
|
127
|
+
<span className={styles.check} aria-hidden>
|
|
128
|
+
{entry.checked && <Icon name="check" size="sm" aria-hidden />}
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
{entry.icon && <Icon name={entry.icon} size="sm" aria-hidden />}
|
|
132
|
+
{entry.label}
|
|
133
|
+
</>
|
|
134
|
+
);
|
|
103
135
|
return (
|
|
104
136
|
<li key={index} role="none">
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
{entry.href ? (
|
|
138
|
+
<a
|
|
139
|
+
role="menuitem"
|
|
140
|
+
href={entry.href}
|
|
141
|
+
className={className}
|
|
142
|
+
onClick={() => onSelect(entry)}
|
|
143
|
+
>
|
|
144
|
+
{content}
|
|
145
|
+
</a>
|
|
146
|
+
) : (
|
|
147
|
+
<button
|
|
148
|
+
role={isCheckable ? 'menuitemradio' : 'menuitem'}
|
|
149
|
+
aria-checked={isCheckable ? entry.checked : undefined}
|
|
150
|
+
type="button"
|
|
151
|
+
className={className}
|
|
152
|
+
disabled={entry.disabled}
|
|
153
|
+
onClick={() => onSelect(entry)}
|
|
154
|
+
>
|
|
155
|
+
{content}
|
|
156
|
+
</button>
|
|
157
|
+
)}
|
|
123
158
|
</li>
|
|
124
159
|
);
|
|
125
160
|
})}
|
|
@@ -127,7 +162,15 @@ export function MenuEntries({
|
|
|
127
162
|
);
|
|
128
163
|
}
|
|
129
164
|
|
|
130
|
-
export function Menu({
|
|
165
|
+
export function Menu({
|
|
166
|
+
trigger,
|
|
167
|
+
open,
|
|
168
|
+
onClose,
|
|
169
|
+
items,
|
|
170
|
+
'aria-label': ariaLabel,
|
|
171
|
+
align,
|
|
172
|
+
width,
|
|
173
|
+
}: MenuProps) {
|
|
131
174
|
const isMobile = useIsMobile();
|
|
132
175
|
|
|
133
176
|
const list = (
|
|
@@ -135,7 +178,7 @@ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, a
|
|
|
135
178
|
items={items}
|
|
136
179
|
onSelect={(entry) => {
|
|
137
180
|
onClose();
|
|
138
|
-
entry.onSelect();
|
|
181
|
+
entry.onSelect?.();
|
|
139
182
|
}}
|
|
140
183
|
/>
|
|
141
184
|
);
|
|
@@ -152,7 +195,14 @@ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, a
|
|
|
152
195
|
}
|
|
153
196
|
|
|
154
197
|
return (
|
|
155
|
-
<Popover
|
|
198
|
+
<Popover
|
|
199
|
+
trigger={trigger}
|
|
200
|
+
open={open}
|
|
201
|
+
onClose={onClose}
|
|
202
|
+
aria-label={ariaLabel}
|
|
203
|
+
align={align}
|
|
204
|
+
width={width}
|
|
205
|
+
>
|
|
156
206
|
{list}
|
|
157
207
|
</Popover>
|
|
158
208
|
);
|
|
@@ -194,4 +194,34 @@ describe('Menu', () => {
|
|
|
194
194
|
expect(onSelect).toHaveBeenCalledOnce();
|
|
195
195
|
expect(onClose).toHaveBeenCalledOnce();
|
|
196
196
|
});
|
|
197
|
+
|
|
198
|
+
it('renders an item with href as a link, not a button', () => {
|
|
199
|
+
render(
|
|
200
|
+
<Menu
|
|
201
|
+
trigger={<button type="button">Open menu</button>}
|
|
202
|
+
open
|
|
203
|
+
onClose={() => {}}
|
|
204
|
+
items={[{ label: 'Account', href: '/account' }]}
|
|
205
|
+
aria-label="List actions"
|
|
206
|
+
/>,
|
|
207
|
+
);
|
|
208
|
+
const link = screen.getByRole('menuitem', { name: 'Account' });
|
|
209
|
+
expect(link.tagName).toBe('A');
|
|
210
|
+
expect(link.getAttribute('href')).toBe('/account');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('closes on click of an href item even with no onSelect', () => {
|
|
214
|
+
const onClose = vi.fn();
|
|
215
|
+
render(
|
|
216
|
+
<Menu
|
|
217
|
+
trigger={<button type="button">Open menu</button>}
|
|
218
|
+
open
|
|
219
|
+
onClose={onClose}
|
|
220
|
+
items={[{ label: 'Account', href: '/account' }]}
|
|
221
|
+
aria-label="List actions"
|
|
222
|
+
/>,
|
|
223
|
+
);
|
|
224
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Account' }));
|
|
225
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
226
|
+
});
|
|
197
227
|
});
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
.assistant .bubble {
|
|
34
34
|
background: var(--sv-color-surface-raised);
|
|
35
|
-
border:
|
|
35
|
+
border: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
36
36
|
color: var(--sv-color-text-primary);
|
|
37
37
|
border-bottom-left-radius: var(--sv-radius-sm);
|
|
38
38
|
}
|
|
@@ -25,13 +25,24 @@
|
|
|
25
25
|
border-radius: var(--sv-radius-full);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/* Bottom-anchors a conversation that doesn't fill the container: this
|
|
29
|
+
leading item claims all leftover main-axis space, pushing the real
|
|
30
|
+
messages down to the bottom edge. Once messages overflow, it has no
|
|
31
|
+
free space left to grow into and collapses to 0 — normal top-to-bottom
|
|
32
|
+
scrolling is unaffected either way, since `justify-content` never
|
|
33
|
+
changes from its default `flex-start`. */
|
|
34
|
+
.spacer {
|
|
35
|
+
flex: 1 0 0%;
|
|
36
|
+
min-height: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
.jumpButton {
|
|
29
40
|
position: absolute;
|
|
30
41
|
bottom: var(--sv-space-3);
|
|
31
42
|
left: 50%;
|
|
32
43
|
transform: translateX(-50%);
|
|
33
44
|
padding: var(--sv-space-1) var(--sv-space-3);
|
|
34
|
-
border:
|
|
45
|
+
border: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
35
46
|
border-radius: var(--sv-radius-full);
|
|
36
47
|
background: var(--sv-color-surface-raised);
|
|
37
48
|
box-shadow: var(--sv-shadow-popover);
|
|
@@ -19,6 +19,17 @@ const NEAR_BOTTOM_THRESHOLD_PX = 80;
|
|
|
19
19
|
* content does not yank them back down — a "New messages" button appears
|
|
20
20
|
* instead, matching the pattern every chat product (Slack, Discord,
|
|
21
21
|
* ChatGPT) already uses.
|
|
22
|
+
*
|
|
23
|
+
* A short conversation is bottom-anchored, not left floating at the top of
|
|
24
|
+
* the container with empty space below it — a leading flex-grow spacer
|
|
25
|
+
* absorbs the leftover space and collapses to nothing once messages
|
|
26
|
+
* overflow. Deliberately not `justify-content: flex-end` on the scroll
|
|
27
|
+
* container itself: Chromium miscomputes `scrollHeight` as equal to
|
|
28
|
+
* `clientHeight` for an overflowing flex-end-packed column, which makes the
|
|
29
|
+
* container look fully scrolled to the bottom and permanently hides the
|
|
30
|
+
* start of a long conversation (confirmed live before choosing this
|
|
31
|
+
* approach). The spacer keeps `justify-content` at its default, so overflow
|
|
32
|
+
* scrolling is unaffected.
|
|
22
33
|
*/
|
|
23
34
|
export function MessageScroller({ children, className }: MessageScrollerProps) {
|
|
24
35
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -60,6 +71,7 @@ export function MessageScroller({ children, className }: MessageScrollerProps) {
|
|
|
60
71
|
return (
|
|
61
72
|
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
62
73
|
<div className={styles.scrollContainer} ref={containerRef} onScroll={handleScroll}>
|
|
74
|
+
<div className={styles.spacer} aria-hidden="true" />
|
|
63
75
|
{children}
|
|
64
76
|
</div>
|
|
65
77
|
{hasNewMessages && (
|