@sableui/react 0.11.0 → 0.12.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/AGENTS.md +10 -4
- package/CHANGELOG.md +170 -0
- package/DESIGN.md +1 -1
- package/README.md +15 -1
- package/bin/sable.mjs +254 -10
- package/dist/components/app-header/app-header.meta.js +2 -1
- package/dist/components/button/button.js +3 -0
- package/dist/components/button/button.meta.js +1 -1
- package/dist/components/button/button.variants.js +1 -1
- package/dist/components/button-group/button-group.js +17 -0
- package/dist/components/button-group/button-group.meta.js +72 -0
- package/dist/components/button-group/index.js +7 -0
- package/dist/components/caret-menu/caret-menu.js +221 -0
- package/dist/components/caret-menu/caret-menu.meta.js +119 -0
- package/dist/components/caret-menu/index.js +8 -0
- package/dist/components/chat-composer/chat-composer.js +30 -7
- package/dist/components/chat-composer/chat-composer.meta.js +27 -8
- package/dist/components/combobox/combobox.meta.js +26 -0
- package/dist/components/input-group/index.js +7 -0
- package/dist/components/input-group/input-group.js +66 -0
- package/dist/components/input-group/input-group.meta.js +106 -0
- package/dist/components/menu/index.js +2 -0
- package/dist/components/menu/menu.js +51 -9
- package/dist/components/menu/menu.meta.js +11 -2
- package/dist/components/menubar/index.js +8 -0
- package/dist/components/menubar/menubar.js +42 -0
- package/dist/components/menubar/menubar.meta.js +99 -0
- package/dist/components/message-scroller/message-scroller.meta.js +4 -0
- package/dist/components/navigation-menu/index.js +19 -0
- package/dist/components/navigation-menu/navigation-menu.js +108 -0
- package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
- package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
- package/dist/components/pagination/pagination.js +11 -5
- package/dist/components/pagination/pagination.meta.js +21 -3
- package/dist/components/scroller/scroller.js +8 -4
- package/dist/components/scroller/scroller.meta.js +6 -0
- package/dist/components/sheet/sheet.js +82 -9
- package/dist/components/sheet/sheet.meta.js +41 -4
- package/dist/components/sheet/sheet.variants.js +8 -1
- package/dist/components/sidebar/sidebar.js +13 -2
- package/dist/components/sidebar/sidebar.meta.js +10 -0
- package/dist/components/streaming-text/streaming-text.meta.js +5 -1
- package/dist/file-hashes.json +47 -31
- package/dist/icon-sets/reicon.js +2 -0
- package/dist/index.d.ts +655 -162
- package/dist/index.js +12 -1
- package/dist/lib/taxonomy.js +5 -0
- package/dist/lib/use-caret-trigger.js +187 -0
- package/dist/lib/use-copy.js +6 -1
- package/dist/lib/use-smooth-wheel.js +164 -0
- package/dist/patterns/chat-panel.js +1 -1
- package/dist/patterns/chat-panel.meta.js +1 -0
- package/dist/styles.css +25 -0
- package/llms/components/app-header.md +1 -1
- package/llms/components/button-group.md +55 -0
- package/llms/components/button.md +1 -1
- package/llms/components/caret-menu.md +100 -0
- package/llms/components/chat-composer.md +24 -7
- package/llms/components/combobox.md +12 -0
- package/llms/components/input-group.md +78 -0
- package/llms/components/menu.md +16 -1
- package/llms/components/menubar.md +110 -0
- package/llms/components/message-scroller.md +1 -0
- package/llms/components/navigation-menu.md +113 -0
- package/llms/components/overflow-menu.md +26 -1
- package/llms/components/pagination.md +15 -3
- package/llms/components/scroller.md +1 -0
- package/llms/components/sheet.md +18 -3
- package/llms/components/sidebar.md +2 -0
- package/llms/components/streaming-text.md +2 -1
- package/llms/patterns/chat-panel.md +4 -1
- package/llms/tasks/building-a-chat.md +158 -0
- package/llms/tasks/building-with-sable.md +4 -1
- package/llms/tasks/converting-an-app.md +7 -0
- package/llms/tasks/extending-a-component.md +170 -0
- package/llms/tasks/getting-started.md +22 -4
- package/llms/tasks/migrating-from-shadcn.md +351 -0
- package/llms-full.txt +1321 -26
- package/llms.txt +13 -4
- package/package.json +3 -2
- package/registry.json +979 -23
- package/scripts/lint-rules.mjs +35 -0
- package/scripts/wiring.mjs +541 -0
- package/skills/entry/SKILL.md +3 -2
- package/skills/sable/SKILL.md +26 -5
- package/targets/ios.json +45 -0
- package/dist/lib/use-wheel-to-horizontal.js +0 -65
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/components/chat-composer/chat-composer.meta.ts
|
|
2
2
|
var chatComposerMeta = {
|
|
3
3
|
name: "ChatComposer",
|
|
4
|
-
summary: 'The input half of a conversation: one surface holding attachments, an auto-growing field, and its controls. Enter sends, Shift+Enter breaks the line (sendOn="mod-enter" swaps them for long-form replies), and send becomes stop while a reply streams.',
|
|
4
|
+
summary: 'The input half of a conversation: one surface holding attachments, an auto-growing field, and its controls. Enter sends, Shift+Enter breaks the line (sendOn="mod-enter" swaps them for long-form replies; on touch, Return always breaks the line), and send becomes stop while a reply streams. allowEmpty sends attachments without text.',
|
|
5
5
|
status: "stable",
|
|
6
6
|
keywords: [
|
|
7
7
|
"chat input",
|
|
@@ -16,6 +16,10 @@ var chatComposerMeta = {
|
|
|
16
16
|
usage: {
|
|
17
17
|
description: "Pairs with MessageScroller to make a working chat. A single surface rather than an input with buttons parked beside it: attachments above, text in the middle, affordances along the bottom. Works controlled or uncontrolled \u2014 uncontrolled is the default because it is what the common case wants, and what lets a hook-free page shell ship a composer that actually types.",
|
|
18
18
|
bestPractices: [
|
|
19
|
+
{
|
|
20
|
+
guidance: "do",
|
|
21
|
+
description: "Wiring it to a streaming source? `sable docs building-a-chat` maps a turn lifecycle (submitted, streaming, done, stopped, error) onto `busy`, `onStop` and `disabled`, and says where each failure goes."
|
|
22
|
+
},
|
|
19
23
|
{
|
|
20
24
|
guidance: "do",
|
|
21
25
|
description: "Pass `busy` while a reply streams, and `onStop` alongside it. Interrupting a long generation is the most common thing a reader wants during one, and the control belongs where their cursor already is."
|
|
@@ -28,6 +32,10 @@ var chatComposerMeta = {
|
|
|
28
32
|
guidance: "do",
|
|
29
33
|
description: "Render pending uploads as `<Attachment>` chips in `attachments`, so the file and the message it belongs to stay in one box."
|
|
30
34
|
},
|
|
35
|
+
{
|
|
36
|
+
guidance: "do",
|
|
37
|
+
description: 'Pass `allowEmpty={files.length > 0}` when a file can go without a caption. Send enables on the attachment alone and `onSend` receives `""`. Tie it to the pending files: a constant `allowEmpty` leaves send enabled with nothing to send.'
|
|
38
|
+
},
|
|
31
39
|
{
|
|
32
40
|
guidance: "do",
|
|
33
41
|
description: "Put model pickers and tool toggles in `leading`, beside the attach button \u2014 they are composer state, not message content, and belong on the chrome row rather than in the field."
|
|
@@ -38,7 +46,7 @@ var chatComposerMeta = {
|
|
|
38
46
|
},
|
|
39
47
|
{
|
|
40
48
|
guidance: "dont",
|
|
41
|
-
description: "Do not add your own Enter-to-send handler on top. The built-in one already guards against firing mid-IME-composition,
|
|
49
|
+
description: "Do not add your own Enter-to-send handler on top. The built-in one already guards against firing mid-IME-composition, and on a touch device lets Return break the line (a soft keyboard has no Shift+Return), which are the lines hand-rolled chat inputs miss."
|
|
42
50
|
},
|
|
43
51
|
{
|
|
44
52
|
guidance: "do",
|
|
@@ -110,7 +118,13 @@ var chatComposerMeta = {
|
|
|
110
118
|
{
|
|
111
119
|
name: "onSend",
|
|
112
120
|
type: "(value: string) => void",
|
|
113
|
-
description:
|
|
121
|
+
description: 'Called with the trimmed message. An uncontrolled composer clears itself afterwards; a controlled one is the caller\'s to clear. With `allowEmpty` the message can be `""`.'
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "allowEmpty",
|
|
125
|
+
type: "boolean",
|
|
126
|
+
default: "false",
|
|
127
|
+
description: 'Let the reader send with no text, because the app has something else to send (pending attachments). Pass it only while that is true, `allowEmpty={files.length > 0}`; `onSend` then receives `""`.'
|
|
114
128
|
},
|
|
115
129
|
{
|
|
116
130
|
name: "placeholder",
|
|
@@ -122,7 +136,7 @@ var chatComposerMeta = {
|
|
|
122
136
|
name: "sendOn",
|
|
123
137
|
type: '"enter" | "mod-enter"',
|
|
124
138
|
default: '"enter"',
|
|
125
|
-
description: 'Which key sends. enter for chat (Shift+Enter breaks the line). mod-enter for long-form replies such as an email or a support answer: Enter breaks the line and Cmd/Ctrl+Enter sends, so a paragraph break never mails a draft. Say so in footer ("Cmd+Enter to send").'
|
|
139
|
+
description: 'Which key sends. enter for chat (Shift+Enter breaks the line). mod-enter for long-form replies such as an email or a support answer: Enter breaks the line and Cmd/Ctrl+Enter sends, so a paragraph break never mails a draft. Say so in footer ("Cmd+Enter to send"). On a touch device (pointer: coarse) enter behaves as mod-enter: a soft keyboard has no Shift+Return, so Return breaks the line and the send button sends.'
|
|
126
140
|
},
|
|
127
141
|
{
|
|
128
142
|
name: "busy",
|
|
@@ -187,13 +201,13 @@ var chatComposerMeta = {
|
|
|
187
201
|
},
|
|
188
202
|
{
|
|
189
203
|
name: "footer",
|
|
190
|
-
type: "ReactNode
|
|
191
|
-
description:
|
|
204
|
+
type: 'ReactNode | ((state: { sendOn: "enter" | "mod-enter" }) => ReactNode)',
|
|
205
|
+
description: 'Helper text or counter below the controls. For a keyboard hint pass a function: it receives the send key in effect (touch turns `enter` into `mod-enter`), and returning null removes the row, e.g. `footer={({ sendOn }) => sendOn === "enter" ? "Enter to send" : null}`.'
|
|
192
206
|
},
|
|
193
207
|
{
|
|
194
208
|
name: "fieldRef",
|
|
195
209
|
type: "Ref<HTMLTextAreaElement>",
|
|
196
|
-
description: "Ref to the underlying textarea \u2014 for focusing after a send, or on mount."
|
|
210
|
+
description: "Ref to the underlying textarea \u2014 for focusing after a send, or on mount, and the extension point for anything that listens to the field. Pass `useCaretTrigger`'s `fieldRef` here for mentions and slash commands (CaretMenu). An Enter another listener already handled (`preventDefault()`) does not send."
|
|
197
211
|
},
|
|
198
212
|
{
|
|
199
213
|
name: "...form props",
|
|
@@ -220,7 +234,8 @@ var chatComposerMeta = {
|
|
|
220
234
|
"A real `<form>`, so the send button is a submit button and the whole thing behaves as one control to assistive tech and to password managers alike.",
|
|
221
235
|
"Enter never submits while an IME composition is open (`isComposing`), so a CJK reader confirming a candidate word does not mail a fragment of their first sentence.",
|
|
222
236
|
"The focus ring is drawn on the surface via `focus-within`, not on the textarea, so the visible focus indicator matches the box a sighted user perceives as the input.",
|
|
223
|
-
"Send is disabled \u2014 not merely styled down \u2014 until there is non-whitespace to send, so the affordance and the behaviour agree for keyboard users.",
|
|
237
|
+
"Send is disabled \u2014 not merely styled down \u2014 until there is non-whitespace to send (or `allowEmpty` says an attachment is enough), so the affordance and the behaviour agree for keyboard users.",
|
|
238
|
+
"On a touch device Return breaks the line and the send button sends; `aria-keyshortcuts` reports the shortcut actually in effect (\u2318/Ctrl+Enter there, Enter on desktop).",
|
|
224
239
|
"Icon-only attach, send and stop buttons all carry accessible labels, each overridable for localisation."
|
|
225
240
|
],
|
|
226
241
|
examples: [
|
|
@@ -236,6 +251,10 @@ var chatComposerMeta = {
|
|
|
236
251
|
label: "Controlled, with pending attachments",
|
|
237
252
|
code: '<ChatComposer\n value={draft}\n onValueChange={setDraft}\n onSend={(text) => { post(text, files); setDraft("") }}\n onAttach={pickFile}\n attachments={files.map((f) => (<Attachment key={f.id} variant="media" name={f.name} thumbnail={f.url} actions={<Button variant="floating" size="icon-xs" aria-label="Remove" onClick={() => drop(f.id)}><Icon name="xmark" /></Button>} />))}\n footer={`${draft.length} / 4000`}\n invalid={draft.length > 4000}\n/>'
|
|
238
253
|
},
|
|
254
|
+
{
|
|
255
|
+
label: "Attachments sent without text",
|
|
256
|
+
code: "<ChatComposer\n onSend={(text) => post(text, files)}\n allowEmpty={files.length > 0}\n onAttach={pickFile}\n attachments={files.map((f) => <Attachment key={f.id} name={f.name} meta={f.size} />)}\n/>"
|
|
257
|
+
},
|
|
239
258
|
{
|
|
240
259
|
label: "A working chat, end to end",
|
|
241
260
|
code: '<div className="flex h-128 flex-col gap-3">\n <MessageScroller className="flex-1 min-h-0">\n {messages.map((m) => (<Message key={m.id} messageId={m.id} align={m.mine ? "end" : "start"}><Bubble variant={m.mine ? "primary" : "muted"} align={m.mine ? "end" : "start"}>{m.body}</Bubble></Message>))}\n {streaming ? <Marker icon="spinner" spin shimmer>Thinking\u2026</Marker> : null}\n </MessageScroller>\n <ChatComposer onSend={send} busy={streaming} onStop={stop} />\n</div>'
|
|
@@ -22,6 +22,10 @@ var comboboxMeta = {
|
|
|
22
22
|
guidance: "do",
|
|
23
23
|
description: "Use ComboboxEmpty with helpful copy for zero matches."
|
|
24
24
|
},
|
|
25
|
+
{
|
|
26
|
+
guidance: "do",
|
|
27
|
+
description: "For results from a server, pass `filter={null}` so Base UI shows the items as the server returned them, fetch from `onInputValueChange`, and make ComboboxEmpty say which state it is in (searching, no results, type to search). Object items take `itemToStringLabel`."
|
|
28
|
+
},
|
|
25
29
|
{
|
|
26
30
|
guidance: "do",
|
|
27
31
|
description: "Wrap in Field when the combobox is part of a form."
|
|
@@ -109,6 +113,24 @@ var comboboxMeta = {
|
|
|
109
113
|
default: "false",
|
|
110
114
|
description: "Select several items instead of one. `value` / `defaultValue` / `onValueChange` become arrays, choosing an item toggles its membership, and the popup stays open between picks. Pair it with ComboboxChips in place of ComboboxInput; every other part is unchanged."
|
|
111
115
|
},
|
|
116
|
+
{
|
|
117
|
+
name: "filter",
|
|
118
|
+
type: "null | ((item, query, itemToString?) => boolean)",
|
|
119
|
+
default: "Base UI's locale-aware contains",
|
|
120
|
+
description: "How items match the query. `null` turns matching off, for items a server already filtered: pair it with `onInputValueChange` to fetch."
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "onInputValueChange",
|
|
124
|
+
type: "(inputValue: string, eventDetails) => void",
|
|
125
|
+
default: "\u2014",
|
|
126
|
+
description: "Fires as the reader types. The place to fetch results for a server-backed combobox."
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "itemToStringLabel",
|
|
130
|
+
type: "(item) => string",
|
|
131
|
+
default: "item.label for { value, label } items",
|
|
132
|
+
description: "The text an object item shows in the input once chosen, and is matched by. Needed for object items without a `label` field."
|
|
133
|
+
},
|
|
112
134
|
{
|
|
113
135
|
name: "virtualized",
|
|
114
136
|
type: "boolean",
|
|
@@ -202,6 +224,10 @@ var comboboxMeta = {
|
|
|
202
224
|
label: "Multiple selection",
|
|
203
225
|
code: '<Combobox multiple items={tags} value={value} onValueChange={setValue}><ComboboxChips placeholder="Add tags\u2026" /><ComboboxContent><ComboboxEmpty>No matching tags.</ComboboxEmpty><ComboboxList>{(item) => (<ComboboxItem key={item} value={item}>{item}</ComboboxItem>)}</ComboboxList></ComboboxContent></Combobox>'
|
|
204
226
|
},
|
|
227
|
+
{
|
|
228
|
+
label: "Async search (server results)",
|
|
229
|
+
code: '<Combobox items={results} filter={null} itemToStringLabel={(person) => person.name} value={person} onValueChange={setPerson} onInputValueChange={setQuery}><ComboboxInput placeholder="Search people\u2026" /><ComboboxContent><ComboboxEmpty>{searching ? "Searching\u2026" : query ? "No one matches." : "Type a name to search."}</ComboboxEmpty><ComboboxList>{(item) => (<ComboboxItem key={item.id} value={item}>{item.name}</ComboboxItem>)}</ComboboxList></ComboboxContent></Combobox>'
|
|
230
|
+
},
|
|
205
231
|
{
|
|
206
232
|
label: "Virtualized (long list)",
|
|
207
233
|
code: '<Combobox virtualized items={districts} value={district} onValueChange={setDistrict}><ComboboxInput placeholder="Search districts\u2026" /><ComboboxContent scroll={false}><ComboboxEmpty>No district matches.</ComboboxEmpty><ComboboxVirtualList itemHeight={32}>{(item) => item.label}</ComboboxVirtualList></ComboboxContent></Combobox>'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/components/input-group/input-group.tsx
|
|
2
|
+
import {
|
|
3
|
+
Children,
|
|
4
|
+
cloneElement
|
|
5
|
+
} from "react";
|
|
6
|
+
import { cn } from "../../lib/cn.js";
|
|
7
|
+
import { useDensity, useTypeTier } from "../../lib/density.js";
|
|
8
|
+
import { MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
function InputGroup({
|
|
11
|
+
leading,
|
|
12
|
+
trailing,
|
|
13
|
+
children,
|
|
14
|
+
className,
|
|
15
|
+
id,
|
|
16
|
+
required,
|
|
17
|
+
disabled,
|
|
18
|
+
"aria-describedby": describedBy,
|
|
19
|
+
"aria-invalid": invalid,
|
|
20
|
+
...props
|
|
21
|
+
}) {
|
|
22
|
+
const tier = useTypeTier();
|
|
23
|
+
const density = useDensity();
|
|
24
|
+
const input = Children.only(children);
|
|
25
|
+
const control = cloneElement(input, {
|
|
26
|
+
...id != null ? { id } : null,
|
|
27
|
+
...required != null ? { required } : null,
|
|
28
|
+
...disabled ? { disabled: true } : null,
|
|
29
|
+
...describedBy ? { "aria-describedby": describedBy } : null,
|
|
30
|
+
...invalid ? { "aria-invalid": invalid } : null
|
|
31
|
+
});
|
|
32
|
+
const addon = cn(
|
|
33
|
+
"flex shrink-0 items-center gap-1.5 text-muted-foreground whitespace-nowrap",
|
|
34
|
+
tier.body,
|
|
35
|
+
density === "comfortable" && MOBILE_FIELD_TIER
|
|
36
|
+
);
|
|
37
|
+
return /* @__PURE__ */ jsxs(
|
|
38
|
+
"div",
|
|
39
|
+
{
|
|
40
|
+
"data-slot": "input-group",
|
|
41
|
+
className: cn(
|
|
42
|
+
"flex h-(--control-md) w-full items-center",
|
|
43
|
+
// The field shell, moved from the input to the group: focus is the
|
|
44
|
+
// ring on the whole field, and so are the error and disabled states.
|
|
45
|
+
"rounded-sm border border-input bg-card motion-interactive focus-within:ring-2 focus-within:ring-ring",
|
|
46
|
+
"has-[>input[aria-invalid=true]]:border-destructive has-[>input[aria-invalid=true]]:ring-destructive/30",
|
|
47
|
+
"has-[>input:disabled]:cursor-not-allowed has-[>input:disabled]:opacity-50",
|
|
48
|
+
// The Input keeps its type, padding and behaviour; the box is the group's.
|
|
49
|
+
"[&>input]:h-full [&>input]:min-w-0 [&>input]:flex-1 [&>input]:rounded-none [&>input]:border-0 [&>input]:bg-transparent [&>input]:focus-visible:ring-0 [&>input]:disabled:opacity-100",
|
|
50
|
+
leading != null && "[&>input]:ps-1.5",
|
|
51
|
+
trailing != null && "[&>input]:pe-1.5",
|
|
52
|
+
className
|
|
53
|
+
),
|
|
54
|
+
...props,
|
|
55
|
+
children: [
|
|
56
|
+
leading != null ? /* @__PURE__ */ jsx("span", { "data-slot": "input-group-leading", className: cn(addon, "ps-3 has-[>[data-variant]]:ps-1"), children: leading }) : null,
|
|
57
|
+
control,
|
|
58
|
+
trailing != null ? /* @__PURE__ */ jsx("span", { "data-slot": "input-group-trailing", className: cn(addon, "pe-3 has-[>[data-variant]]:pe-1"), children: trailing }) : null
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
InputGroup.displayName = "InputGroup";
|
|
64
|
+
export {
|
|
65
|
+
InputGroup
|
|
66
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/components/input-group/input-group.meta.ts
|
|
2
|
+
var inputGroupMeta = {
|
|
3
|
+
name: "InputGroup",
|
|
4
|
+
summary: `An Input with addons inside the same field: a prefix or suffix ("https://", ".com", "$"), an icon, or a borderless Button such as copy or reveal. One shell, one focus ring, one error state; a Field's label and error wiring reaches the Input inside.`,
|
|
5
|
+
keywords: [
|
|
6
|
+
"input group",
|
|
7
|
+
"input addon",
|
|
8
|
+
"prefix",
|
|
9
|
+
"suffix",
|
|
10
|
+
"adornment",
|
|
11
|
+
"url input",
|
|
12
|
+
"currency input",
|
|
13
|
+
"password reveal",
|
|
14
|
+
"copy field",
|
|
15
|
+
"icon in input"
|
|
16
|
+
],
|
|
17
|
+
usage: {
|
|
18
|
+
description: "Use InputGroup when something belongs inside the field with the text: a unit, a fixed prefix, an icon, or a small action on the value itself (copy, reveal, clear). Use Field's `trailing` for an action beside the field (Apply next to a promo code), SearchInput for search, and NumberField for numbers with steppers.",
|
|
19
|
+
bestPractices: [
|
|
20
|
+
{
|
|
21
|
+
guidance: "do",
|
|
22
|
+
description: 'Keep text addons short and fixed ("https://", "kg", "USD"). They are part of the field, not its label.'
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
guidance: "do",
|
|
26
|
+
description: 'For a button addon, use a borderless Button (`variant="ghost"`, `size="icon-sm"`) with an aria-label. The addon tightens around it, so it sits as far from the edge as from the top and bottom.'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
guidance: "do",
|
|
30
|
+
description: 'Wrap the group in Field for a label and errors: `<Field label="Website"><InputGroup leading="https://"><Input /></InputGroup></Field>`.'
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
guidance: "dont",
|
|
34
|
+
description: `Do not put the value's meaning only in an addon. A screen reader hears the label and the text, so "Price (USD)" belongs in the label too.`
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
guidance: "dont",
|
|
38
|
+
description: "Do not restyle an Input to fake an addon; the group owns the border, the fill and the ring."
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
related: {
|
|
43
|
+
components: ["Input", "Field", "SearchInput", "NumberField", "Button"]
|
|
44
|
+
},
|
|
45
|
+
status: "stable",
|
|
46
|
+
props: [
|
|
47
|
+
{
|
|
48
|
+
name: "leading",
|
|
49
|
+
type: "ReactNode",
|
|
50
|
+
default: "\u2014",
|
|
51
|
+
description: 'Inside the field, before the text: a prefix ("https://", "$"), an icon, or a borderless Button.'
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "trailing",
|
|
55
|
+
type: "ReactNode",
|
|
56
|
+
default: "\u2014",
|
|
57
|
+
description: 'Inside the field, after the text: a suffix (".com", "kg"), an icon, or a borderless Button (copy, reveal, clear).'
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "children",
|
|
61
|
+
type: "ReactElement",
|
|
62
|
+
default: "\u2014",
|
|
63
|
+
description: "The single `<Input />`. It keeps its own props and type; the group takes over its border, fill and focus ring."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "id / required / disabled / aria-describedby / aria-invalid",
|
|
67
|
+
type: "control props",
|
|
68
|
+
default: "\u2014",
|
|
69
|
+
description: "Forwarded to the Input, not set on the group. This is how a Field's wiring reaches the text box when the group is its child."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "className",
|
|
73
|
+
type: "string",
|
|
74
|
+
default: "\u2014",
|
|
75
|
+
description: "Layout classes for the group (width, margins)."
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
tokensUsed: ["input", "card", "ring", "destructive", "muted-foreground", "foreground"],
|
|
79
|
+
a11y: [
|
|
80
|
+
"The Input stays the focusable control; the group draws the focus ring around the whole field while it is focused.",
|
|
81
|
+
"Inside a Field, the label, description and error are wired to the Input itself, not the group.",
|
|
82
|
+
"Text addons are plain text beside the input, read in order; put a unit or currency in the label as well so it is not only visual.",
|
|
83
|
+
"A button addon is a real Button with its own focus stop and needs an aria-label."
|
|
84
|
+
],
|
|
85
|
+
examples: [
|
|
86
|
+
{
|
|
87
|
+
label: "Prefix and suffix",
|
|
88
|
+
code: '<Field label="Website"><InputGroup leading="https://" trailing=".com"><Input placeholder="example" /></InputGroup></Field>'
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
label: "Leading icon",
|
|
92
|
+
code: '<Field label="Email"><InputGroup leading={<Icon name="mail" />}><Input type="email" placeholder="you@example.com" /></InputGroup></Field>'
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: "Copy button",
|
|
96
|
+
code: '<Field label="API key"><InputGroup trailing={<Button variant="ghost" size="icon-sm" aria-label="Copy key" onClick={copy}><Icon name={copied ? "check" : "copy"} /></Button>}><Input readOnly value={key} /></InputGroup></Field>'
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
label: "Password with reveal",
|
|
100
|
+
code: '<Field label="Password"><InputGroup trailing={<Button variant="ghost" size="icon-sm" aria-label={shown ? "Hide password" : "Show password"} aria-pressed={shown} onClick={() => setShown(!shown)}><Icon name={shown ? "eye-off" : "eye"} /></Button>}><Input type={shown ? "text" : "password"} autoComplete="current-password" /></InputGroup></Field>'
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
};
|
|
104
|
+
export {
|
|
105
|
+
inputGroupMeta
|
|
106
|
+
};
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
MenuPortal,
|
|
6
6
|
MenuContent,
|
|
7
7
|
MenuItem,
|
|
8
|
+
MenuLinkItem,
|
|
8
9
|
MenuSeparator,
|
|
9
10
|
MenuGroup,
|
|
10
11
|
MenuGroupLabel,
|
|
@@ -22,6 +23,7 @@ export {
|
|
|
22
23
|
MenuGroup,
|
|
23
24
|
MenuGroupLabel,
|
|
24
25
|
MenuItem,
|
|
26
|
+
MenuLinkItem,
|
|
25
27
|
MenuPortal,
|
|
26
28
|
MenuRadioGroup,
|
|
27
29
|
MenuRadioItem,
|
|
@@ -6,7 +6,7 @@ import { cn } from "../../lib/cn.js";
|
|
|
6
6
|
import { withPortalThemeScope } from "../../lib/portal-theme-scope.js";
|
|
7
7
|
import { useAnchoredDensity, useTypeTier } from "../../lib/density.js";
|
|
8
8
|
import { HighlightGlide } from "../../lib/highlight-glide.js";
|
|
9
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
10
|
var Menu = Base.Root;
|
|
11
11
|
var MenuTrigger = Base.Trigger;
|
|
12
12
|
var MenuPortal = withPortalThemeScope(Base.Portal);
|
|
@@ -40,6 +40,28 @@ function MenuContent({
|
|
|
40
40
|
}
|
|
41
41
|
) }) });
|
|
42
42
|
}
|
|
43
|
+
var MENU_ITEM = "flex cursor-default select-none items-center gap-(--item-gap) rounded-popup-item px-(--item-px) py-1.5 outline-none motion-interactive data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:shrink-0";
|
|
44
|
+
function useItemContent(children, { icon, description }) {
|
|
45
|
+
const tier = useTypeTier();
|
|
46
|
+
if (!description) return icon ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
47
|
+
icon,
|
|
48
|
+
children
|
|
49
|
+
] }) : children;
|
|
50
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
51
|
+
icon ? /* @__PURE__ */ jsx(
|
|
52
|
+
"span",
|
|
53
|
+
{
|
|
54
|
+
"data-slot": "menu-item-icon",
|
|
55
|
+
className: "flex size-(--control-sm) shrink-0 items-center justify-center rounded-sm border border-border-strong bg-popover text-muted-foreground",
|
|
56
|
+
children: icon
|
|
57
|
+
}
|
|
58
|
+
) : null,
|
|
59
|
+
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-col", children: [
|
|
60
|
+
/* @__PURE__ */ jsx("span", { children }),
|
|
61
|
+
/* @__PURE__ */ jsx("span", { "data-slot": "menu-item-description", className: cn("text-muted-foreground", tier.secondary), children: description })
|
|
62
|
+
] })
|
|
63
|
+
] });
|
|
64
|
+
}
|
|
43
65
|
var MENU_ITEM_DESTRUCTIVE = (
|
|
44
66
|
// The ink is pulled a quarter toward --foreground (as Badge's soft tones
|
|
45
67
|
// are), from --destructive-text rather than the fill: dark fills sit low
|
|
@@ -48,17 +70,34 @@ var MENU_ITEM_DESTRUCTIVE = (
|
|
|
48
70
|
// 5.4:1, Prism dark, on the 10% red wash).
|
|
49
71
|
"text-[color-mix(in_oklch,var(--destructive-text)_75%,var(--foreground))] data-[highlighted]:bg-destructive/10 data-[highlighted]:text-[color-mix(in_oklch,var(--destructive-text)_75%,var(--foreground))]"
|
|
50
72
|
);
|
|
51
|
-
function MenuItem({ className, variant = "default", ...props }) {
|
|
73
|
+
function MenuItem({ className, variant = "default", icon, description, children, ...props }) {
|
|
52
74
|
const tier = useTypeTier();
|
|
75
|
+
const content = useItemContent(children, { icon, description });
|
|
53
76
|
return /* @__PURE__ */ jsx(
|
|
54
77
|
Base.Item,
|
|
55
78
|
{
|
|
56
|
-
className: cn(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
className: cn(MENU_ITEM, tier.body, variant === "destructive" && MENU_ITEM_DESTRUCTIVE, className),
|
|
80
|
+
...props,
|
|
81
|
+
children: content
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
function MenuLinkItem({ className, icon, description, children, target, ...props }) {
|
|
86
|
+
const tier = useTypeTier();
|
|
87
|
+
const external = target === "_blank";
|
|
88
|
+
const label = external ? /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
|
|
89
|
+
children,
|
|
90
|
+
/* @__PURE__ */ jsx(Icon, { name: "arrow-up-right", className: "text-[0.875em] text-muted-foreground" })
|
|
91
|
+
] }) : children;
|
|
92
|
+
const content = useItemContent(label, { icon, description });
|
|
93
|
+
return /* @__PURE__ */ jsx(
|
|
94
|
+
Base.LinkItem,
|
|
95
|
+
{
|
|
96
|
+
className: cn(MENU_ITEM, tier.body, className),
|
|
97
|
+
target,
|
|
98
|
+
rel: external ? "noopener noreferrer" : void 0,
|
|
99
|
+
...props,
|
|
100
|
+
children: content
|
|
62
101
|
}
|
|
63
102
|
);
|
|
64
103
|
}
|
|
@@ -151,6 +190,7 @@ function MenuCheckboxItem({
|
|
|
151
190
|
);
|
|
152
191
|
}
|
|
153
192
|
export {
|
|
193
|
+
MENU_ITEM,
|
|
154
194
|
MENU_ITEM_DESTRUCTIVE,
|
|
155
195
|
Menu,
|
|
156
196
|
MenuCheckboxItem,
|
|
@@ -158,11 +198,13 @@ export {
|
|
|
158
198
|
MenuGroup,
|
|
159
199
|
MenuGroupLabel,
|
|
160
200
|
MenuItem,
|
|
201
|
+
MenuLinkItem,
|
|
161
202
|
MenuPortal,
|
|
162
203
|
MenuRadioGroup,
|
|
163
204
|
MenuRadioItem,
|
|
164
205
|
MenuSeparator,
|
|
165
206
|
MenuSubmenu,
|
|
166
207
|
MenuSubmenuTrigger,
|
|
167
|
-
MenuTrigger
|
|
208
|
+
MenuTrigger,
|
|
209
|
+
useItemContent
|
|
168
210
|
};
|
|
@@ -93,8 +93,13 @@ var menuMeta = {
|
|
|
93
93
|
},
|
|
94
94
|
{
|
|
95
95
|
name: "MenuItem",
|
|
96
|
-
type: 'Base UI Menu.Item props + className (disabled, closeOnClick, onClick) + variant?: "default" | "destructive"',
|
|
97
|
-
description: 'An interactive row. Highlights via data-[highlighted]; dims via data-[disabled]. variant="destructive" for a delete or irreversible action: it keeps the destructive ink when highlighted (never className="text-destructive", which the highlight repaints).'
|
|
96
|
+
type: 'Base UI Menu.Item props + className (disabled, closeOnClick, onClick) + variant?: "default" | "destructive" + icon?: ReactNode + description?: ReactNode',
|
|
97
|
+
description: 'An interactive row. Highlights via data-[highlighted]; dims via data-[disabled]. variant="destructive" for a delete or irreversible action: it keeps the destructive ink when highlighted (never className="text-destructive", which the highlight repaints). `icon` leads the row; `description` adds a quieter second line saying what the action does, and the icon then sits in a small tile spanning both lines.'
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "MenuLinkItem",
|
|
101
|
+
type: "Base UI Menu.LinkItem props (href, target, closeOnClick) + className + icon?: ReactNode + description?: ReactNode",
|
|
102
|
+
description: 'A row that navigates: a real <a>, so it opens in a new tab, middle-clicks and copies as a link. With target="_blank" it adds rel="noopener noreferrer" and a trailing \u2197. Takes icon and description like MenuItem.'
|
|
98
103
|
},
|
|
99
104
|
{
|
|
100
105
|
name: "MenuSeparator",
|
|
@@ -165,6 +170,10 @@ var menuMeta = {
|
|
|
165
170
|
label: "Nested, with a single-choice submenu",
|
|
166
171
|
code: '<Menu>\n <MenuTrigger render={<Button variant="secondary">View</Button>} />\n <MenuContent>\n <MenuItem>Reload</MenuItem>\n <MenuSubmenu>\n <MenuSubmenuTrigger value={density}>Density</MenuSubmenuTrigger>\n <MenuContent side="inline-end" align="start" sideOffset={4}>\n <MenuRadioGroup value={density} onValueChange={setDensity}>\n <MenuRadioItem value="Comfortable">Comfortable</MenuRadioItem>\n <MenuRadioItem value="Compact">Compact</MenuRadioItem>\n </MenuRadioGroup>\n </MenuContent>\n </MenuSubmenu>\n </MenuContent>\n</Menu>'
|
|
167
172
|
},
|
|
173
|
+
{
|
|
174
|
+
label: "Rows with icons and descriptions, and links",
|
|
175
|
+
code: '<Menu>\n <MenuTrigger render={<Button variant="outline" size="sm">Share</Button>} />\n <MenuContent align="end">\n <MenuItem icon={<Icon name="copy" />} description="Copy page as Markdown for LLMs" onClick={copy}>Copy page</MenuItem>\n <MenuLinkItem icon={<Icon name="file" />} description="View this page as plain text" href={markdownUrl} target="_blank">View as Markdown</MenuLinkItem>\n </MenuContent>\n</Menu>'
|
|
176
|
+
},
|
|
168
177
|
{
|
|
169
178
|
label: "Toggling rows",
|
|
170
179
|
code: '<Menu>\n <MenuTrigger render={<Button variant="secondary">Columns</Button>} />\n <MenuContent>\n <MenuCheckboxItem checked={cols.status} onCheckedChange={(v) => setCol("status", v)}>Status</MenuCheckboxItem>\n <MenuCheckboxItem checked={cols.updated} onCheckedChange={(v) => setCol("updated", v)}>Updated</MenuCheckboxItem>\n </MenuContent>\n</Menu>'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/components/menubar/menubar.tsx
|
|
2
|
+
import { Menubar as Base } from "@base-ui/react/menubar";
|
|
3
|
+
import { cn } from "../../lib/cn.js";
|
|
4
|
+
import { useTypeTier } from "../../lib/density.js";
|
|
5
|
+
import { trimLabel } from "../../lib/trim-label.js";
|
|
6
|
+
import { buttonVariants } from "../button/button.variants.js";
|
|
7
|
+
import { MenuTrigger } from "../menu/index.js";
|
|
8
|
+
import { jsx } from "react/jsx-runtime";
|
|
9
|
+
function Menubar({ className, ...props }) {
|
|
10
|
+
return /* @__PURE__ */ jsx(
|
|
11
|
+
Base,
|
|
12
|
+
{
|
|
13
|
+
className: cn(
|
|
14
|
+
"inline-flex items-center gap-0.5 data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
|
|
15
|
+
className
|
|
16
|
+
),
|
|
17
|
+
...props
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
Menubar.displayName = "Menubar";
|
|
22
|
+
function MenubarTrigger({ className, children, ...props }) {
|
|
23
|
+
const tier = useTypeTier();
|
|
24
|
+
return /* @__PURE__ */ jsx(
|
|
25
|
+
MenuTrigger,
|
|
26
|
+
{
|
|
27
|
+
className: cn(
|
|
28
|
+
buttonVariants({ variant: "ghost", size: "sm" }),
|
|
29
|
+
tier.control,
|
|
30
|
+
"sable-open:bg-accent sable-open:text-accent-foreground",
|
|
31
|
+
className
|
|
32
|
+
),
|
|
33
|
+
...props,
|
|
34
|
+
children: trimLabel(children)
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
MenubarTrigger.displayName = "MenubarTrigger";
|
|
39
|
+
export {
|
|
40
|
+
Menubar,
|
|
41
|
+
MenubarTrigger
|
|
42
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/components/menubar/menubar.meta.ts
|
|
2
|
+
var menubarMeta = {
|
|
3
|
+
name: "Menubar",
|
|
4
|
+
summary: "An application's row of menus \u2014 File, Edit, View \u2014 over Base UI's Menubar. Each menu is an ordinary Sable Menu with a MenubarTrigger as its title; the bar joins them into one tab stop with \u2190/\u2192 between titles, and once one menu is open, pointing at a neighbour opens it.",
|
|
5
|
+
status: "stable",
|
|
6
|
+
keywords: ["menubar", "menu bar", "app menu", "file menu", "edit menu", "application menu", "desktop menu"],
|
|
7
|
+
usage: {
|
|
8
|
+
description: "Use Menubar for a tool or editor whose commands are too many for a toolbar and belong under named menus (File, Edit, View, Help). Use Toolbar for a row of direct actions, a single Menu or OverflowMenu for one set of options, and NavigationMenu-style links for site navigation.",
|
|
9
|
+
bestPractices: [
|
|
10
|
+
{
|
|
11
|
+
guidance: "do",
|
|
12
|
+
description: "Compose each menu from the Menu parts: `<Menu><MenubarTrigger>File</MenubarTrigger><MenuContent>\u2026</MenuContent></Menu>`. Rows, groups, submenus, checkbox and radio items are the same parts a lone Menu uses."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
guidance: "do",
|
|
16
|
+
description: `Name the bar with aria-label ("Editor") so it is announced as the application's menus, not a list of buttons.`
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
guidance: "do",
|
|
20
|
+
description: "Keep titles to one short word each. A reader scans a menubar left to right; a phrase in the bar is a toolbar button."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
guidance: "dont",
|
|
24
|
+
description: "Do not use MenubarTrigger outside a Menubar, or a plain MenuTrigger inside one. The bar's arrow keys and hover-to-switch work on the triggers it owns; a Button trigger in the bar looks like a menu title and behaves like a stray button."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
guidance: "dont",
|
|
28
|
+
description: "Do not use a menubar for site navigation. Its items are commands in menus (role=menuitem), not links between pages."
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
anatomy: [
|
|
32
|
+
{ name: "Menubar", required: true, description: "The bar: role=menubar, one tab stop, arrow keys between titles." },
|
|
33
|
+
{ name: "Menu", required: true, description: "One per title; the ordinary Menu root." },
|
|
34
|
+
{ name: "MenubarTrigger", required: true, description: "The title in the bar that opens its menu." },
|
|
35
|
+
{ name: "MenuContent", required: true, description: "The menu's popup, with MenuItem / MenuSubmenu / MenuCheckboxItem rows inside." }
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
related: {
|
|
39
|
+
components: ["Menu", "Toolbar", "OverflowMenu", "ContextMenu"]
|
|
40
|
+
},
|
|
41
|
+
props: [
|
|
42
|
+
{
|
|
43
|
+
name: "orientation",
|
|
44
|
+
type: '"horizontal" | "vertical"',
|
|
45
|
+
default: '"horizontal"',
|
|
46
|
+
description: "The bar's axis. Arrow keys follow it: \u2190/\u2192 along a horizontal bar, \u2191/\u2193 down a vertical one."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "loopFocus",
|
|
50
|
+
type: "boolean",
|
|
51
|
+
default: "true",
|
|
52
|
+
description: "Wrap from the last title back to the first with the arrow keys."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "modal",
|
|
56
|
+
type: "boolean",
|
|
57
|
+
default: "true",
|
|
58
|
+
description: "While a menu is open, block pointer interaction outside it and lock page scroll, like a lone Menu."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "disabled",
|
|
62
|
+
type: "boolean",
|
|
63
|
+
default: "false",
|
|
64
|
+
description: "Disable every menu in the bar."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "className",
|
|
68
|
+
type: "string",
|
|
69
|
+
description: "Layout classes for the bar. Titles take their look from MenubarTrigger."
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
subcomponents: [
|
|
73
|
+
{
|
|
74
|
+
name: "MenubarTrigger",
|
|
75
|
+
type: "Base UI Menu.Trigger props + className",
|
|
76
|
+
description: "A menu's title in the bar. A small ghost Button in the control type tier, held in the accent wash while its menu is open (`sable-open:`), so the reader sees which title the popup belongs to."
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
tokensUsed: ["accent", "accent-foreground", "foreground", "ring"],
|
|
80
|
+
a11y: [
|
|
81
|
+
'Base UI renders role="menubar" with aria-orientation; each title is a menuitem with aria-haspopup and aria-expanded.',
|
|
82
|
+
"The bar is one tab stop. \u2190/\u2192 move between titles (\u2191/\u2193 when vertical), \u2193 or Enter opens a menu and moves into it, and Esc closes it and returns focus to its title.",
|
|
83
|
+
"With one menu open, pointing at another title opens that menu instead, so a reader can sweep across the bar, as in a desktop application.",
|
|
84
|
+
"Name the bar with aria-label; titles are their own text."
|
|
85
|
+
],
|
|
86
|
+
examples: [
|
|
87
|
+
{
|
|
88
|
+
label: "Editor menus",
|
|
89
|
+
code: '<Menubar aria-label="Editor">\n <Menu>\n <MenubarTrigger>File</MenubarTrigger>\n <MenuContent>\n <MenuItem>New</MenuItem>\n <MenuItem>Open\u2026</MenuItem>\n <MenuSeparator />\n <MenuItem>Save</MenuItem>\n </MenuContent>\n </Menu>\n <Menu>\n <MenubarTrigger>Edit</MenubarTrigger>\n <MenuContent>\n <MenuItem>Undo</MenuItem>\n <MenuItem>Redo</MenuItem>\n </MenuContent>\n </Menu>\n</Menubar>'
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
label: "With a submenu and toggling rows",
|
|
93
|
+
code: '<Menubar aria-label="Editor">\n <Menu>\n <MenubarTrigger>View</MenubarTrigger>\n <MenuContent>\n <MenuCheckboxItem checked={ruler} onCheckedChange={setRuler}>Show ruler</MenuCheckboxItem>\n <MenuSubmenu>\n <MenuSubmenuTrigger value={zoom}>Zoom</MenuSubmenuTrigger>\n <MenuContent side="inline-end" align="start" sideOffset={4}>\n <MenuRadioGroup value={zoom} onValueChange={setZoom}>\n <MenuRadioItem value="100%">100%</MenuRadioItem>\n <MenuRadioItem value="Fit">Fit</MenuRadioItem>\n </MenuRadioGroup>\n </MenuContent>\n </MenuSubmenu>\n </MenuContent>\n </Menu>\n</Menubar>'
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
export {
|
|
98
|
+
menubarMeta
|
|
99
|
+
};
|
|
@@ -20,6 +20,10 @@ var messageScrollerMeta = {
|
|
|
20
20
|
usage: {
|
|
21
21
|
description: "The scroll region a conversation lives in. It owns the scrolling and nothing else \u2014 not your messages, not your AI state, not your transport or persistence \u2014 so you bring the content renderer and it handles the behaviors that make streaming chat feel right: pinning to the newest message, releasing when the reader scrolls up, keeping the view still when older history loads above, jumping to a specific message, restoring a saved read position, and reporting what is actually on screen.",
|
|
22
22
|
bestPractices: [
|
|
23
|
+
{
|
|
24
|
+
guidance: "do",
|
|
25
|
+
description: "Building a whole chat? `sable docs building-a-chat` covers what the scroller does during a stream, what the app owns, and the failure states around it."
|
|
26
|
+
},
|
|
23
27
|
{
|
|
24
28
|
guidance: "do",
|
|
25
29
|
description: "Bound it the way you would bound any scroll box \u2014 `flex-1 min-h-0` inside a flex column, or `max-h-96`. `className` lands on the element that actually scrolls, so the obvious bound is the correct one (same contract as Scroller)."
|