@sableui/react 0.10.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 +184 -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/panel-section/index.js +2 -1
- package/dist/components/panel-section/panel-section.js +15 -0
- package/dist/components/panel-section/panel-section.meta.js +32 -0
- 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 +50 -34
- package/dist/icon-sets/reicon.js +2 -0
- package/dist/index.d.ts +687 -164
- 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/panel-section.md +28 -0
- 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 +1349 -26
- package/llms.txt +14 -5
- package/package.json +3 -2
- package/registry.json +1037 -53
- 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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# CaretMenu
|
|
2
|
+
|
|
3
|
+
**Status:** beta
|
|
4
|
+
|
|
5
|
+
Suggestions at the caret of a text field, for @mentions and /slash commands. `useCaretTrigger` watches a Textarea, Input or ChatComposer (through `fieldRef`) for a trigger character and reports the query; CaretMenu draws the list beside the trigger and drives it from the field's keys, so focus never leaves the field. The value stays plain text: a mention is inserted as `@Sam Rivera`, a command runs and clears its `/query`.
|
|
6
|
+
|
|
7
|
+
**Keywords:** mention, mentions, at mention, slash command, slash commands, autocomplete, typeahead, suggestions, emoji picker, hashtag, trigger
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Use CaretMenu with `useCaretTrigger` when typing a character in running text should offer choices: `@` for people, `/` for commands, `#` for channels, `:` for emoji. The app owns the items (filter or fetch them from `trigger.match.query`) and what choosing one does; Sable owns detecting the trigger, placing the list at the caret, the keys and the accessibility. Use Combobox when the whole field is a search for one value, and CommandMenu for a global palette.
|
|
12
|
+
|
|
13
|
+
### Best practices
|
|
14
|
+
|
|
15
|
+
- Do: Attach the hook's `fieldRef` to the field: `ref` on Textarea or Input, `fieldRef` on ChatComposer. On ChatComposer, Enter picks the highlighted suggestion instead of sending, because CaretMenu takes the Enter first.
|
|
16
|
+
- Do: Insert a mention with a trailing space, `trigger.replace(`@${person.name} `)`, so the reader keeps typing; run a slash command and then `trigger.replace("")` to clear `/query` from the field.
|
|
17
|
+
- Do: For `@` names with spaces set `allowSpaces: true` on that trigger; for `/` commands set `atStart: true` so `and/or` mid-sentence stays text.
|
|
18
|
+
- Do: Fetch remote suggestions from `trigger.match?.query` yourself, debounced, and pass `status` while loading or when nothing matches ("Searching…", "No one matches"). The status row is also what the list announces.
|
|
19
|
+
- Don't: Do not expect chips inside the field. A textarea holds text, so a mention is text. Rendering tokens in the field needs a rich-text editor, which Sable does not own (`sable docs extending-a-component`).
|
|
20
|
+
- Don't: Do not move focus into the list or wrap the field in a Popover. Focus stays in the field; the list is pointed at with aria-activedescendant.
|
|
21
|
+
|
|
22
|
+
### Anatomy
|
|
23
|
+
|
|
24
|
+
| Part | Required | Description |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| useCaretTrigger | yes | The hook on the field: trigger detection, the query, the caret anchor, replace() and dismiss(). |
|
|
27
|
+
| CaretMenu | yes | The list at the caret, its keys, and its announcements. |
|
|
28
|
+
| CaretMenuItem | yes | One suggestion row, with optional icon and description. |
|
|
29
|
+
|
|
30
|
+
## Subcomponents
|
|
31
|
+
|
|
32
|
+
| Name | Type | Description |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| CaretMenuItem | div props + onSelect: () => void + disabled?: boolean + icon?: ReactNode + description?: ReactNode | One suggestion, styled as a Menu row. `onSelect` runs on Enter, Tab or click; `icon` and `description` work as on MenuItem. A disabled row shows but the arrow keys skip it. |
|
|
35
|
+
|
|
36
|
+
## Props
|
|
37
|
+
|
|
38
|
+
| Name | Type | Default | Description |
|
|
39
|
+
| --- | --- | --- | --- |
|
|
40
|
+
| trigger | UseCaretTriggerResult | — | The result of `useCaretTrigger({ triggers })`, whose `fieldRef` is attached to the field. Each trigger is `{ char, atStart?, allowSpaces?, maxQueryLength? }`; the result carries `match` (`{ trigger, query, start, end }` or null), `replace(text)`, `dismiss()` and `getAnchorRect()`. |
|
|
41
|
+
| label | string | — | The list's accessible name, e.g. "People" or "Commands". |
|
|
42
|
+
| status | ReactNode | — | A row under the items for what the list is doing: loading, nothing found, an error. Announced politely in place of the suggestion count. With no items and no status the list stays shut. |
|
|
43
|
+
| children | ReactNode | — | CaretMenuItems, already filtered for the query. |
|
|
44
|
+
| className | string | — | Layout classes for the popup, such as a width. |
|
|
45
|
+
|
|
46
|
+
## Tokens used
|
|
47
|
+
|
|
48
|
+
`popover`, `popover-foreground`, `border-floating`, `accent`, `accent-foreground`, `muted-foreground`
|
|
49
|
+
|
|
50
|
+
## Motion
|
|
51
|
+
|
|
52
|
+
- **arrival** · root · `motion-pop` — Scales in from the caret's side, so the list reads as coming from the character just typed. It leaves instantly: it answers a keystroke and should be gone by the next one.
|
|
53
|
+
|
|
54
|
+
## Accessibility
|
|
55
|
+
|
|
56
|
+
- Focus stays in the field. The field carries aria-autocomplete="list", aria-controls (the listbox) and aria-activedescendant (the highlighted option), so a screen reader follows the arrow keys without focus moving.
|
|
57
|
+
- An <input> becomes role="combobox" with aria-expanded. A <textarea> keeps its textbox role (combobox would drop multiline), and since a textbox cannot carry aria-expanded, opening is announced through a polite status, mounted from the start: "3 suggestions", or the `status` text.
|
|
58
|
+
- ↑/↓ move the highlight and wrap; Enter or Tab choose it; Escape closes the list without reaching an enclosing Dialog or Sheet. Shift+Enter is left alone, so a line break still breaks the line.
|
|
59
|
+
- Rows keep focus in the field on pointer down, so a click does not blur it and close the list first.
|
|
60
|
+
- Triggers follow a word boundary (`ana@example.com` and `and/or` stay text), wait for IME composition to end, and stay closed after Escape until the caret leaves that trigger.
|
|
61
|
+
|
|
62
|
+
## Related
|
|
63
|
+
|
|
64
|
+
- Components: ChatComposer, Textarea, Input, Combobox, CommandMenu, Menu
|
|
65
|
+
|
|
66
|
+
## Examples
|
|
67
|
+
|
|
68
|
+
### Mentions and slash commands in a ChatComposer
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
import { Avatar, CaretMenu, CaretMenuItem, ChatComposer, useCaretTrigger } from "@sableui/react"
|
|
72
|
+
|
|
73
|
+
const trigger = useCaretTrigger({ triggers: [{ char: "@", allowSpaces: true }, { char: "/", atStart: true }] })
|
|
74
|
+
const q = trigger.match?.query.toLowerCase() ?? ""
|
|
75
|
+
const commands = trigger.match?.trigger.char === "/"
|
|
76
|
+
|
|
77
|
+
<ChatComposer fieldRef={trigger.fieldRef} onSend={send} />
|
|
78
|
+
<CaretMenu trigger={trigger} label={commands ? "Commands" : "People"} status={!commands && people.length === 0 ? "No one matches" : undefined}>
|
|
79
|
+
{commands
|
|
80
|
+
? slashCommands.filter((c) => c.name.startsWith(q)).map((c) => (
|
|
81
|
+
<CaretMenuItem key={c.name} description={c.hint} onSelect={() => { c.run(); trigger.replace("") }}>/{c.name}</CaretMenuItem>
|
|
82
|
+
))
|
|
83
|
+
: people.filter((p) => p.name.toLowerCase().includes(q)).map((p) => (
|
|
84
|
+
<CaretMenuItem key={p.id} icon={<Avatar size="sm" src={p.avatar} fallback={p.initials} />} onSelect={() => trigger.replace(`@${p.name} `)}>{p.name}</CaretMenuItem>
|
|
85
|
+
))}
|
|
86
|
+
</CaretMenu>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### On a Textarea, with remote results
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
import { CaretMenu, CaretMenuItem, Textarea, useCaretTrigger } from "@sableui/react"
|
|
93
|
+
|
|
94
|
+
const trigger = useCaretTrigger({ triggers: [{ char: "#" }] })
|
|
95
|
+
// Fetch from trigger.match?.query yourself, debounced; pass the state as status.
|
|
96
|
+
<Textarea ref={trigger.fieldRef} aria-label="Note" />
|
|
97
|
+
<CaretMenu trigger={trigger} label="Channels" status={loading ? "Searching…" : channels.length === 0 ? "No channels match" : undefined}>
|
|
98
|
+
{channels.map((c) => <CaretMenuItem key={c.id} onSelect={() => trigger.replace(`#${c.name} `)}>#{c.name}</CaretMenuItem>)}
|
|
99
|
+
</CaretMenu>
|
|
100
|
+
```
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Status:** stable
|
|
4
4
|
|
|
5
|
-
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.
|
|
5
|
+
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.
|
|
6
6
|
|
|
7
7
|
**Keywords:** chat input, composer, message input, prompt input, send message, ai chat, support chat, textarea
|
|
8
8
|
|
|
@@ -12,13 +12,15 @@ Pairs with MessageScroller to make a working chat. A single surface rather than
|
|
|
12
12
|
|
|
13
13
|
### Best practices
|
|
14
14
|
|
|
15
|
+
- Do: 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.
|
|
15
16
|
- Do: 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.
|
|
16
17
|
- Do: Leave it uncontrolled unless you need the text elsewhere — a slash-command menu, a draft you persist. Uncontrolled clears itself on send; controlled does not.
|
|
17
18
|
- Do: Render pending uploads as `<Attachment>` chips in `attachments`, so the file and the message it belongs to stay in one box.
|
|
19
|
+
- Do: 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.
|
|
18
20
|
- Do: Put model pickers and tool toggles in `leading`, beside the attach button — they are composer state, not message content, and belong on the chrome row rather than in the field.
|
|
19
21
|
- Do: Building a docked agent panel inside a tool? Wrap the whole conversation — scroller, messages and composer — in `<Density value="compact">`. The entire set follows the workspace register from there: prose drops a tier, the rhythm tightens, and the controls come off the compact ladder. Do not hand-size it at the call site.
|
|
20
22
|
- Don't: Do not clear a controlled composer for the user on send. Only the caller knows whether the send succeeded, and clearing on their behalf loses the message when it did not.
|
|
21
|
-
- Don't: Do not add your own Enter-to-send handler on top. The built-in one already guards against firing mid-IME-composition,
|
|
23
|
+
- Don't: 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.
|
|
22
24
|
- Don't: Do not use it as a general multi-line input inside a larger form. That is `Textarea`. A standalone reply box that sends multi-paragraph messages (email, support replies) is this one with sendOn="mod-enter".
|
|
23
25
|
- Don't: Do not override the field's type utility to make it match your transcript. The field and `Bubble` both read the same tier already, which is what stops a message changing size the moment it is sent — overriding one breaks that pairing.
|
|
24
26
|
- Don't: Do not remove the height cap to let the field grow without limit. A pasted essay would push the conversation off the screen; past the cap it scrolls.
|
|
@@ -39,9 +41,10 @@ Pairs with MessageScroller to make a working chat. A single surface rather than
|
|
|
39
41
|
| value | string | — | Controlled value. Omit for an uncontrolled composer that owns its own text. |
|
|
40
42
|
| defaultValue | string | "" | Initial value when uncontrolled. |
|
|
41
43
|
| onValueChange | (value: string) => void | — | Notified on every keystroke. Required when `value` is supplied. |
|
|
42
|
-
| onSend | (value: string) => void | — | Called with the trimmed message. An uncontrolled composer clears itself afterwards; a controlled one is the caller's to clear. |
|
|
44
|
+
| onSend | (value: string) => void | — | 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 `""`. |
|
|
45
|
+
| allowEmpty | boolean | false | 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 `""`. |
|
|
43
46
|
| placeholder | string | "Send a message…" | Placeholder for the field. |
|
|
44
|
-
| sendOn | "enter" \| "mod-enter" | "enter" | 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"). |
|
|
47
|
+
| sendOn | "enter" \| "mod-enter" | "enter" | 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. |
|
|
45
48
|
| busy | boolean | false | A reply is streaming. Disables send, and swaps it for a stop control when `onStop` is supplied. |
|
|
46
49
|
| onStop | () => void | — | Interrupt the in-flight reply. Supplying it turns send into stop while busy. |
|
|
47
50
|
| disabled | boolean | false | Block sending entirely — over a limit, disconnected, read-only thread. Distinct from `busy`, which is temporary and offers a way out (stop). |
|
|
@@ -53,8 +56,8 @@ Pairs with MessageScroller to make a working chat. A single surface rather than
|
|
|
53
56
|
| label | string | "Message" | Accessible label for the field. |
|
|
54
57
|
| sendLabel | string | "Send message" | Accessible label for the send button. |
|
|
55
58
|
| stopLabel | string | "Stop generating" | Accessible label for the stop button. |
|
|
56
|
-
| footer | ReactNode | — | Helper text or counter below the controls. |
|
|
57
|
-
| fieldRef | Ref<HTMLTextAreaElement> | — | Ref to the underlying textarea — for focusing after a send, or on mount. |
|
|
59
|
+
| footer | ReactNode \| ((state: { sendOn: "enter" \| "mod-enter" }) => ReactNode) | — | 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}`. |
|
|
60
|
+
| fieldRef | Ref<HTMLTextAreaElement> | — | Ref to the underlying textarea — 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. |
|
|
58
61
|
| ...form props | Omit<ComponentPropsWithRef<"form">, "onSubmit" \| "defaultValue"> | — | Spread onto the form element. Use `onSend` rather than `onSubmit`. |
|
|
59
62
|
|
|
60
63
|
## Variants
|
|
@@ -70,7 +73,8 @@ Pairs with MessageScroller to make a working chat. A single surface rather than
|
|
|
70
73
|
- 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.
|
|
71
74
|
- 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.
|
|
72
75
|
- 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.
|
|
73
|
-
- Send is disabled — not merely styled down — until there is non-whitespace to send, so the affordance and the behaviour agree for keyboard users.
|
|
76
|
+
- Send is disabled — not merely styled down — until there is non-whitespace to send (or `allowEmpty` says an attachment is enough), so the affordance and the behaviour agree for keyboard users.
|
|
77
|
+
- On a touch device Return breaks the line and the send button sends; `aria-keyshortcuts` reports the shortcut actually in effect (⌘/Ctrl+Enter there, Enter on desktop).
|
|
74
78
|
- Icon-only attach, send and stop buttons all carry accessible labels, each overridable for localisation.
|
|
75
79
|
|
|
76
80
|
## Related
|
|
@@ -112,6 +116,19 @@ import { Attachment, Button, ChatComposer, Icon } from "@sableui/react"
|
|
|
112
116
|
/>
|
|
113
117
|
```
|
|
114
118
|
|
|
119
|
+
### Attachments sent without text
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { Attachment, ChatComposer } from "@sableui/react"
|
|
123
|
+
|
|
124
|
+
<ChatComposer
|
|
125
|
+
onSend={(text) => post(text, files)}
|
|
126
|
+
allowEmpty={files.length > 0}
|
|
127
|
+
onAttach={pickFile}
|
|
128
|
+
attachments={files.map((f) => <Attachment key={f.id} name={f.name} meta={f.size} />)}
|
|
129
|
+
/>
|
|
130
|
+
```
|
|
131
|
+
|
|
115
132
|
### A working chat, end to end
|
|
116
133
|
|
|
117
134
|
```tsx
|
|
@@ -14,6 +14,7 @@ Use Combobox when users choose from a list that benefits from typing, filtering,
|
|
|
14
14
|
|
|
15
15
|
- Do: Pass items so internal filtering, empty state, and accessibility wiring have the full option set.
|
|
16
16
|
- Do: Use ComboboxEmpty with helpful copy for zero matches.
|
|
17
|
+
- Do: 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`.
|
|
17
18
|
- Do: Wrap in Field when the combobox is part of a form.
|
|
18
19
|
- Do: Reach for ComboboxVirtualList past roughly a thousand rows, and leave filtering to the root. It reads the filtered list through useFilteredItems(), so the rendered rows and the rows Base UI navigates cannot drift apart.
|
|
19
20
|
- Do: Use `multiple` with ComboboxChips for several values from a filterable set, and keep chip labels short so the field does not turn into a paragraph.
|
|
@@ -54,6 +55,9 @@ Use Combobox when users choose from a list that benefits from typing, filtering,
|
|
|
54
55
|
| Combobox (Root) | Base UI Combobox.Root props (items, value, defaultValue, onValueChange, inputValue, onInputValueChange, open, onOpenChange, multiple, autoHighlight, filter, disabled) | — | Groups all parts and owns selection + filter state. Pass `items` (flat array or groups) to enable internal filtering and ComboboxEmpty. |
|
|
55
56
|
| items | readonly any[] \| readonly ComboboxItemGroup[] | — | The pool of options, flat or grouped ({ value, items }). Drives type-to-filter and is required for ComboboxEmpty to render its fallback. |
|
|
56
57
|
| multiple | boolean | false | 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. |
|
|
58
|
+
| filter | null \| ((item, query, itemToString?) => boolean) | Base UI's locale-aware contains | How items match the query. `null` turns matching off, for items a server already filtered: pair it with `onInputValueChange` to fetch. |
|
|
59
|
+
| onInputValueChange | (inputValue: string, eventDetails) => void | — | Fires as the reader types. The place to fetch results for a server-backed combobox. |
|
|
60
|
+
| itemToStringLabel | (item) => string | item.label for { value, label } items | The text an object item shows in the input once chosen, and is matched by. Needed for object items without a `label` field. |
|
|
57
61
|
| virtualized | boolean | false | Tells Base UI the DOM holds only part of the list. Required when using ComboboxVirtualList. |
|
|
58
62
|
|
|
59
63
|
## Tokens used
|
|
@@ -102,6 +106,14 @@ import { Combobox, ComboboxChips, ComboboxContent, ComboboxEmpty, ComboboxItem,
|
|
|
102
106
|
<Combobox multiple items={tags} value={value} onValueChange={setValue}><ComboboxChips placeholder="Add tags…" /><ComboboxContent><ComboboxEmpty>No matching tags.</ComboboxEmpty><ComboboxList>{(item) => (<ComboboxItem key={item} value={item}>{item}</ComboboxItem>)}</ComboboxList></ComboboxContent></Combobox>
|
|
103
107
|
```
|
|
104
108
|
|
|
109
|
+
### Async search (server results)
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@sableui/react"
|
|
113
|
+
|
|
114
|
+
<Combobox items={results} filter={null} itemToStringLabel={(person) => person.name} value={person} onValueChange={setPerson} onInputValueChange={setQuery}><ComboboxInput placeholder="Search people…" /><ComboboxContent><ComboboxEmpty>{searching ? "Searching…" : query ? "No one matches." : "Type a name to search."}</ComboboxEmpty><ComboboxList>{(item) => (<ComboboxItem key={item.id} value={item}>{item.name}</ComboboxItem>)}</ComboboxList></ComboboxContent></Combobox>
|
|
115
|
+
```
|
|
116
|
+
|
|
105
117
|
### Virtualized (long list)
|
|
106
118
|
|
|
107
119
|
```tsx
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# InputGroup
|
|
2
|
+
|
|
3
|
+
**Status:** stable
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
**Keywords:** input group, input addon, prefix, suffix, adornment, url input, currency input, password reveal, copy field, icon in input
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
### Best practices
|
|
14
|
+
|
|
15
|
+
- Do: Keep text addons short and fixed ("https://", "kg", "USD"). They are part of the field, not its label.
|
|
16
|
+
- Do: 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.
|
|
17
|
+
- Do: Wrap the group in Field for a label and errors: `<Field label="Website"><InputGroup leading="https://"><Input /></InputGroup></Field>`.
|
|
18
|
+
- Don't: 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.
|
|
19
|
+
- Don't: Do not restyle an Input to fake an addon; the group owns the border, the fill and the ring.
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
| Name | Type | Default | Description |
|
|
24
|
+
| --- | --- | --- | --- |
|
|
25
|
+
| leading | ReactNode | — | Inside the field, before the text: a prefix ("https://", "$"), an icon, or a borderless Button. |
|
|
26
|
+
| trailing | ReactNode | — | Inside the field, after the text: a suffix (".com", "kg"), an icon, or a borderless Button (copy, reveal, clear). |
|
|
27
|
+
| children | ReactElement | — | The single `<Input />`. It keeps its own props and type; the group takes over its border, fill and focus ring. |
|
|
28
|
+
| id / required / disabled / aria-describedby / aria-invalid | control props | — | 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. |
|
|
29
|
+
| className | string | — | Layout classes for the group (width, margins). |
|
|
30
|
+
|
|
31
|
+
## Tokens used
|
|
32
|
+
|
|
33
|
+
`input`, `card`, `ring`, `destructive`, `muted-foreground`, `foreground`
|
|
34
|
+
|
|
35
|
+
## Accessibility
|
|
36
|
+
|
|
37
|
+
- The Input stays the focusable control; the group draws the focus ring around the whole field while it is focused.
|
|
38
|
+
- Inside a Field, the label, description and error are wired to the Input itself, not the group.
|
|
39
|
+
- 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.
|
|
40
|
+
- A button addon is a real Button with its own focus stop and needs an aria-label.
|
|
41
|
+
|
|
42
|
+
## Related
|
|
43
|
+
|
|
44
|
+
- Components: Input, Field, SearchInput, NumberField, Button
|
|
45
|
+
|
|
46
|
+
## Examples
|
|
47
|
+
|
|
48
|
+
### Prefix and suffix
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { Field, Input, InputGroup } from "@sableui/react"
|
|
52
|
+
|
|
53
|
+
<Field label="Website"><InputGroup leading="https://" trailing=".com"><Input placeholder="example" /></InputGroup></Field>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Leading icon
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { Field, Icon, Input, InputGroup } from "@sableui/react"
|
|
60
|
+
|
|
61
|
+
<Field label="Email"><InputGroup leading={<Icon name="mail" />}><Input type="email" placeholder="you@example.com" /></InputGroup></Field>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Copy button
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { Button, Field, Icon, Input, InputGroup } from "@sableui/react"
|
|
68
|
+
|
|
69
|
+
<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>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Password with reveal
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
import { Button, Field, Icon, Input, InputGroup } from "@sableui/react"
|
|
76
|
+
|
|
77
|
+
<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>
|
|
78
|
+
```
|
package/llms/components/menu.md
CHANGED
|
@@ -35,7 +35,8 @@ Use Menu for a compact list of actions opened from a visible trigger.
|
|
|
35
35
|
| --- | --- | --- |
|
|
36
36
|
| MenuTrigger | Base UI Menu.Trigger props + render | The button that opens the menu. Compose with render={<Button … />}. |
|
|
37
37
|
| MenuContent | Base UI Popup props + side: 'top' \| 'right' \| 'bottom' \| 'left' + align: 'start' \| 'center' \| 'end' + sideOffset: number + className | Portal + Positioner + Popup. side picks which side of the trigger the popup renders against (auto-flips on collision; defaults to 'bottom'); align sets alignment along that side (defaults to 'start'); sideOffset is the gap in px between trigger and popup (defaults to 6). |
|
|
38
|
-
| MenuItem | Base UI Menu.Item props + className (disabled, closeOnClick, onClick) + variant?: "default" \| "destructive" | 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). |
|
|
38
|
+
| MenuItem | Base UI Menu.Item props + className (disabled, closeOnClick, onClick) + variant?: "default" \| "destructive" + icon?: ReactNode + description?: ReactNode | 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. |
|
|
39
|
+
| MenuLinkItem | Base UI Menu.LinkItem props (href, target, closeOnClick) + className + icon?: ReactNode + description?: ReactNode | 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 ↗. Takes icon and description like MenuItem. |
|
|
39
40
|
| MenuSeparator | Base UI Separator props + className | Hairline rule between item runs. |
|
|
40
41
|
| MenuSubmenu | Base UI Menu.SubmenuRoot props | A nested menu. Wraps a MenuSubmenuTrigger and its own MenuContent — pass side="inline-end" to that content so the submenu opens away from its parent in RTL as well as LTR. |
|
|
41
42
|
| MenuSubmenuTrigger | Base UI Menu.SubmenuTrigger props + value: ReactNode + className | The row that opens a submenu: item styling, a trailing chevron, and an optional `value` showing the current choice so the row reads "Effort High ›" rather than making the reader open it to find out. |
|
|
@@ -107,6 +108,20 @@ import { Button, Menu, MenuContent, MenuItem, MenuRadioGroup, MenuRadioItem, Men
|
|
|
107
108
|
</Menu>
|
|
108
109
|
```
|
|
109
110
|
|
|
111
|
+
### Rows with icons and descriptions, and links
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { Button, Icon, Menu, MenuContent, MenuItem, MenuLinkItem, MenuTrigger } from "@sableui/react"
|
|
115
|
+
|
|
116
|
+
<Menu>
|
|
117
|
+
<MenuTrigger render={<Button variant="outline" size="sm">Share</Button>} />
|
|
118
|
+
<MenuContent align="end">
|
|
119
|
+
<MenuItem icon={<Icon name="copy" />} description="Copy page as Markdown for LLMs" onClick={copy}>Copy page</MenuItem>
|
|
120
|
+
<MenuLinkItem icon={<Icon name="file" />} description="View this page as plain text" href={markdownUrl} target="_blank">View as Markdown</MenuLinkItem>
|
|
121
|
+
</MenuContent>
|
|
122
|
+
</Menu>
|
|
123
|
+
```
|
|
124
|
+
|
|
110
125
|
### Toggling rows
|
|
111
126
|
|
|
112
127
|
```tsx
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Menubar
|
|
2
|
+
|
|
3
|
+
**Status:** stable
|
|
4
|
+
|
|
5
|
+
An application's row of menus — File, Edit, View — 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 ←/→ between titles, and once one menu is open, pointing at a neighbour opens it.
|
|
6
|
+
|
|
7
|
+
**Keywords:** menubar, menu bar, app menu, file menu, edit menu, application menu, desktop menu
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
### Best practices
|
|
14
|
+
|
|
15
|
+
- Do: Compose each menu from the Menu parts: `<Menu><MenubarTrigger>File</MenubarTrigger><MenuContent>…</MenuContent></Menu>`. Rows, groups, submenus, checkbox and radio items are the same parts a lone Menu uses.
|
|
16
|
+
- Do: Name the bar with aria-label ("Editor") so it is announced as the application's menus, not a list of buttons.
|
|
17
|
+
- Do: Keep titles to one short word each. A reader scans a menubar left to right; a phrase in the bar is a toolbar button.
|
|
18
|
+
- Don't: 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.
|
|
19
|
+
- Don't: Do not use a menubar for site navigation. Its items are commands in menus (role=menuitem), not links between pages.
|
|
20
|
+
|
|
21
|
+
### Anatomy
|
|
22
|
+
|
|
23
|
+
| Part | Required | Description |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| Menubar | yes | The bar: role=menubar, one tab stop, arrow keys between titles. |
|
|
26
|
+
| Menu | yes | One per title; the ordinary Menu root. |
|
|
27
|
+
| MenubarTrigger | yes | The title in the bar that opens its menu. |
|
|
28
|
+
| MenuContent | yes | The menu's popup, with MenuItem / MenuSubmenu / MenuCheckboxItem rows inside. |
|
|
29
|
+
|
|
30
|
+
## Subcomponents
|
|
31
|
+
|
|
32
|
+
| Name | Type | Description |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| MenubarTrigger | Base UI Menu.Trigger props + className | 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. |
|
|
35
|
+
|
|
36
|
+
## Props
|
|
37
|
+
|
|
38
|
+
| Name | Type | Default | Description |
|
|
39
|
+
| --- | --- | --- | --- |
|
|
40
|
+
| orientation | "horizontal" \| "vertical" | "horizontal" | The bar's axis. Arrow keys follow it: ←/→ along a horizontal bar, ↑/↓ down a vertical one. |
|
|
41
|
+
| loopFocus | boolean | true | Wrap from the last title back to the first with the arrow keys. |
|
|
42
|
+
| modal | boolean | true | While a menu is open, block pointer interaction outside it and lock page scroll, like a lone Menu. |
|
|
43
|
+
| disabled | boolean | false | Disable every menu in the bar. |
|
|
44
|
+
| className | string | — | Layout classes for the bar. Titles take their look from MenubarTrigger. |
|
|
45
|
+
|
|
46
|
+
## Tokens used
|
|
47
|
+
|
|
48
|
+
`accent`, `accent-foreground`, `foreground`, `ring`
|
|
49
|
+
|
|
50
|
+
## Accessibility
|
|
51
|
+
|
|
52
|
+
- Base UI renders role="menubar" with aria-orientation; each title is a menuitem with aria-haspopup and aria-expanded.
|
|
53
|
+
- The bar is one tab stop. ←/→ move between titles (↑/↓ when vertical), ↓ or Enter opens a menu and moves into it, and Esc closes it and returns focus to its title.
|
|
54
|
+
- 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.
|
|
55
|
+
- Name the bar with aria-label; titles are their own text.
|
|
56
|
+
|
|
57
|
+
## Related
|
|
58
|
+
|
|
59
|
+
- Components: Menu, Toolbar, OverflowMenu, ContextMenu
|
|
60
|
+
|
|
61
|
+
## Examples
|
|
62
|
+
|
|
63
|
+
### Editor menus
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { Menu, Menubar, MenubarTrigger, MenuContent, MenuItem, MenuSeparator } from "@sableui/react"
|
|
67
|
+
|
|
68
|
+
<Menubar aria-label="Editor">
|
|
69
|
+
<Menu>
|
|
70
|
+
<MenubarTrigger>File</MenubarTrigger>
|
|
71
|
+
<MenuContent>
|
|
72
|
+
<MenuItem>New</MenuItem>
|
|
73
|
+
<MenuItem>Open…</MenuItem>
|
|
74
|
+
<MenuSeparator />
|
|
75
|
+
<MenuItem>Save</MenuItem>
|
|
76
|
+
</MenuContent>
|
|
77
|
+
</Menu>
|
|
78
|
+
<Menu>
|
|
79
|
+
<MenubarTrigger>Edit</MenubarTrigger>
|
|
80
|
+
<MenuContent>
|
|
81
|
+
<MenuItem>Undo</MenuItem>
|
|
82
|
+
<MenuItem>Redo</MenuItem>
|
|
83
|
+
</MenuContent>
|
|
84
|
+
</Menu>
|
|
85
|
+
</Menubar>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### With a submenu and toggling rows
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import { Menu, Menubar, MenubarTrigger, MenuCheckboxItem, MenuContent, MenuRadioGroup, MenuRadioItem, MenuSubmenu, MenuSubmenuTrigger } from "@sableui/react"
|
|
92
|
+
|
|
93
|
+
<Menubar aria-label="Editor">
|
|
94
|
+
<Menu>
|
|
95
|
+
<MenubarTrigger>View</MenubarTrigger>
|
|
96
|
+
<MenuContent>
|
|
97
|
+
<MenuCheckboxItem checked={ruler} onCheckedChange={setRuler}>Show ruler</MenuCheckboxItem>
|
|
98
|
+
<MenuSubmenu>
|
|
99
|
+
<MenuSubmenuTrigger value={zoom}>Zoom</MenuSubmenuTrigger>
|
|
100
|
+
<MenuContent side="inline-end" align="start" sideOffset={4}>
|
|
101
|
+
<MenuRadioGroup value={zoom} onValueChange={setZoom}>
|
|
102
|
+
<MenuRadioItem value="100%">100%</MenuRadioItem>
|
|
103
|
+
<MenuRadioItem value="Fit">Fit</MenuRadioItem>
|
|
104
|
+
</MenuRadioGroup>
|
|
105
|
+
</MenuContent>
|
|
106
|
+
</MenuSubmenu>
|
|
107
|
+
</MenuContent>
|
|
108
|
+
</Menu>
|
|
109
|
+
</Menubar>
|
|
110
|
+
```
|
|
@@ -12,6 +12,7 @@ The scroll region a conversation lives in. It owns the scrolling and nothing els
|
|
|
12
12
|
|
|
13
13
|
### Best practices
|
|
14
14
|
|
|
15
|
+
- Do: 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.
|
|
15
16
|
- Do: Bound it the way you would bound any scroll box — `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).
|
|
16
17
|
- Do: Give every Message a `messageId`. It is the handle for `scrollToMessage`, `initialScroll`, and `onVisibleMessagesChange` — a message without one is invisible to all three.
|
|
17
18
|
- Do: Prepend older history directly into `children` when the reader reaches the top. The scroller anchors on the first rendered message and re-adds the height that appeared above it, so the view does not jump.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# NavigationMenu
|
|
2
|
+
|
|
3
|
+
**Status:** stable
|
|
4
|
+
|
|
5
|
+
A site header's primary nav over Base UI NavigationMenu: a bar of links and triggers, where a trigger opens a panel of links on hover, click or Enter. One shared panel glides between triggers and resizes to each one's content. Links take any router's link through `render` and mark the current page with `active`.
|
|
6
|
+
|
|
7
|
+
**Keywords:** navigation, nav, header nav, site nav, mega menu, top nav, navbar, dropdown nav
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Use NavigationMenu for a marketing or docs site's header: a few top-level destinations, some of which open a panel of related pages. Use Menu for actions rather than destinations, Tabs to switch views inside a page, Sidebar or SideNav for an app's persistent navigation, and AppHeader for an app's mobile top bar.
|
|
12
|
+
|
|
13
|
+
### Best practices
|
|
14
|
+
|
|
15
|
+
- Do: Keep the bar to a handful of entries. A trigger groups related pages; a destination with nothing beneath it is a plain NavigationMenuLink in its own item.
|
|
16
|
+
- Do: Give panel links a `description` when titles alone are ambiguous ("Sync" → "Offline first, conflict free"), and lay the Content out with layout classes (`grid w-[30rem] grid-cols-2`); the shared panel sizes itself to it.
|
|
17
|
+
- Do: Pass your router's link through `render` (`<NavigationMenuLink render={<Link to="/pricing" />}>`) and set `active` on the current page's link, so the bar shows where the reader is and announces it as `aria-current="page"`.
|
|
18
|
+
- Do: Below `md`, hide the bar and offer the same destinations in a Sheet opened from a menu Button: SheetContent side="left" holding a SideNav, its groups mirroring the panels. A hover panel has nothing to hover on a phone, and a bar of five entries does not fit one.
|
|
19
|
+
- Don't: Do not put actions in it (Sign in, New project). Those are Buttons beside the nav; the nav holds places to go.
|
|
20
|
+
- Don't: Do not assemble the panel yourself. The root renders the one Portal, Positioner, Popup and Viewport every trigger shares; a second one breaks the glide between triggers.
|
|
21
|
+
|
|
22
|
+
### Anatomy
|
|
23
|
+
|
|
24
|
+
| Part | Required | Description |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| NavigationMenu | yes | The `<nav>` root; also renders the shared floating panel. |
|
|
27
|
+
| NavigationMenuList | yes | The bar: a `<ul>` the arrow keys rove across. |
|
|
28
|
+
| NavigationMenuItem | yes | One `<li>`: a link, or a trigger with its content. |
|
|
29
|
+
| NavigationMenuTrigger | no | Opens its item's content; carries a chevron. |
|
|
30
|
+
| NavigationMenuContent | no | The links a trigger reveals, rendered into the shared panel. |
|
|
31
|
+
| NavigationMenuLink | yes | A destination: a bar entry in an item, a two-line row in content. |
|
|
32
|
+
|
|
33
|
+
## Subcomponents
|
|
34
|
+
|
|
35
|
+
| Name | Type | Description |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| NavigationMenuList | Base UI NavigationMenu.List props + className | The bar of items, a `<ul>`. Arrow keys move between entries. |
|
|
38
|
+
| NavigationMenuItem | Base UI NavigationMenu.Item props + className + value?: string | One `<li>` holding either a NavigationMenuLink or a NavigationMenuTrigger with its NavigationMenuContent. |
|
|
39
|
+
| NavigationMenuTrigger | Base UI NavigationMenu.Trigger props + className | Opens its item's content on hover, click or Enter, with a chevron that turns while open. Styled like the bar's links: muted at rest, full ink when hovered or open. |
|
|
40
|
+
| NavigationMenuContent | Base UI NavigationMenu.Content props + className + keepMounted?: boolean | The item's panel content. Size and lay it out with layout classes; the shared panel follows its size and slides it in from the side it was reached from. |
|
|
41
|
+
| NavigationMenuLink | Base UI NavigationMenu.Link props + className + href?: string + active?: boolean + closeOnClick?: boolean + render?: ReactElement + description?: ReactNode | A destination. In an item it is a bar entry; in content it is a row with an optional `description` line. `active` sets `aria-current="page"`; `render={<Link to="…" />}` keeps the roving focus on your router's link. |
|
|
42
|
+
|
|
43
|
+
## Props
|
|
44
|
+
|
|
45
|
+
| Name | Type | Default | Description |
|
|
46
|
+
| --- | --- | --- | --- |
|
|
47
|
+
| NavigationMenu (Root) | Base UI NavigationMenu.Root props (value, defaultValue, onValueChange, delay, closeDelay, orientation) + className | — | The `<nav>`. `value` is the open item's value (null when closed); `delay` / `closeDelay` tune hover-to-open. |
|
|
48
|
+
| align | "start" \| "center" \| "end" | "start" | Alignment of the shared panel along its trigger. |
|
|
49
|
+
| sideOffset | number | 8 | Gap between the bar and the panel, in px. The pointer crosses it on a bridge, so the panel does not close on the way in. |
|
|
50
|
+
|
|
51
|
+
## Tokens used
|
|
52
|
+
|
|
53
|
+
`popover`, `popover-foreground`, `border-floating`, `accent`, `accent-foreground`, `muted-foreground`, `foreground`, `ring`
|
|
54
|
+
|
|
55
|
+
## Motion
|
|
56
|
+
|
|
57
|
+
- **arrival** · Panel · _custom (outside the recipe layer)_ — The panel fades and scales in from its trigger like a popover, but it also resizes as the reader moves between triggers, which motion-pop cannot express: Base UI measures each content into --popup-width/--popup-height and the popup transitions to them on the motion tokens.
|
|
58
|
+
- **response** · Trigger, Link · `motion-interactive` — Bar entries and panel rows fill on hover and focus like the other interactive surfaces, so the bar reads as live before anything opens.
|
|
59
|
+
- **transition** · Panel · _custom (outside the recipe layer)_ — Moving from one trigger to the next keeps one panel open: it glides along the bar and the new content slides in from the direction of travel, so the reader sees one surface changing rather than two popups swapping. Snaps on first open and on resize (data-instant), where a glide would mean nothing.
|
|
60
|
+
- **activity** · root · _no motion_ — Nothing in a nav moves on its own.
|
|
61
|
+
|
|
62
|
+
## Accessibility
|
|
63
|
+
|
|
64
|
+
- The root is a <nav>; give it an aria-label when the page has more than one nav ("Main").
|
|
65
|
+
- Arrow keys move across the bar; Enter, Space or ArrowDown opens a trigger's content and moves focus into it; Esc closes and returns focus to the trigger.
|
|
66
|
+
- A trigger is a button with aria-expanded; its content is reachable by Tab in reading order once open.
|
|
67
|
+
- `active` on a link sets aria-current="page" and gives it full ink, so the current page is announced as well as shown.
|
|
68
|
+
- Panel motion runs on the motion tokens, so reduced motion (or MotionProvider "reduced") collapses the glide and resize to instant.
|
|
69
|
+
|
|
70
|
+
## Related
|
|
71
|
+
|
|
72
|
+
- Components: Menu, SideNav, Sheet, AppHeader, Tabs
|
|
73
|
+
|
|
74
|
+
## Examples
|
|
75
|
+
|
|
76
|
+
### Bar with a panel
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from "@sableui/react"
|
|
80
|
+
|
|
81
|
+
<NavigationMenu aria-label="Main">
|
|
82
|
+
<NavigationMenuList>
|
|
83
|
+
<NavigationMenuItem>
|
|
84
|
+
<NavigationMenuTrigger>Product</NavigationMenuTrigger>
|
|
85
|
+
<NavigationMenuContent className="grid w-[30rem] grid-cols-2">
|
|
86
|
+
<NavigationMenuLink href="/editor" description="Write and review together.">Editor</NavigationMenuLink>
|
|
87
|
+
<NavigationMenuLink href="/sync" description="Offline first, conflict free.">Sync</NavigationMenuLink>
|
|
88
|
+
</NavigationMenuContent>
|
|
89
|
+
</NavigationMenuItem>
|
|
90
|
+
<NavigationMenuItem>
|
|
91
|
+
<NavigationMenuLink href="/pricing">Pricing</NavigationMenuLink>
|
|
92
|
+
</NavigationMenuItem>
|
|
93
|
+
</NavigationMenuList>
|
|
94
|
+
</NavigationMenu>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### With a router, marking the current page
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
import { NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList } from "@sableui/react"
|
|
101
|
+
// <Link> = your router's link (react-router, next/link, @tanstack/router, …)
|
|
102
|
+
|
|
103
|
+
<NavigationMenu aria-label="Main">
|
|
104
|
+
<NavigationMenuList>
|
|
105
|
+
<NavigationMenuItem>
|
|
106
|
+
<NavigationMenuLink active={path === "/docs"} render={<Link to="/docs" />}>Docs</NavigationMenuLink>
|
|
107
|
+
</NavigationMenuItem>
|
|
108
|
+
<NavigationMenuItem>
|
|
109
|
+
<NavigationMenuLink active={path === "/blog"} render={<Link to="/blog" />}>Blog</NavigationMenuLink>
|
|
110
|
+
</NavigationMenuItem>
|
|
111
|
+
</NavigationMenuList>
|
|
112
|
+
</NavigationMenu>
|
|
113
|
+
```
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
An overflow ellipsis icon button that opens a Menu. Composes our Menu + Button + Icon — pass MenuItems as children and it wires the ghost icon trigger, accessible label, and portal-rendered content. All keyboard, focus, and dismissal behavior comes through Menu.
|
|
6
6
|
|
|
7
|
-
**Keywords:** dots menu, more actions, ellipsis, kebab
|
|
7
|
+
**Keywords:** dots menu, more actions, ellipsis, kebab, responsive actions, responsive action menu, collapse actions, actions overflow on mobile
|
|
8
8
|
|
|
9
9
|
## Usage
|
|
10
10
|
|
|
@@ -14,6 +14,7 @@ Use OverflowMenu for compact overflow actions when the primary action is already
|
|
|
14
14
|
|
|
15
15
|
- Do: Use it for secondary commands that belong to a row, card, or toolbar item.
|
|
16
16
|
- Do: Keep labels explicit so the icon-only trigger does not obscure the command meaning.
|
|
17
|
+
- Do: For actions that fit inline on a wide screen but not a narrow one, render them twice: inline (a Row inside a wrapper shown from a container width; Row sets its own display, so the wrapper does the hiding), and as MenuItems in an OverflowMenu hidden from that width (`@container` + `@md:`). display:none takes the hidden copy out of the accessibility tree, so only one set is ever announced. Keep the primary action inline at every width.
|
|
17
18
|
- Don't: Do not hide the most important or most frequent action inside OverflowMenu.
|
|
18
19
|
- Don't: Do not use it as the only affordance on a touch surface. It is revealed on hover in most layouts, and hover does not exist on touch — pair it with a visible action or make it always-visible there.
|
|
19
20
|
|
|
@@ -62,3 +63,27 @@ import { Icon, MenuItem, MenuSeparator, OverflowMenu } from "@sableui/react"
|
|
|
62
63
|
|
|
63
64
|
<OverflowMenu><MenuItem><Icon name="check" className="text-sm" />Mark as read</MenuItem><MenuSeparator /><MenuItem variant="destructive"><Icon name="xmark" className="text-sm" />Delete</MenuItem></OverflowMenu>
|
|
64
65
|
```
|
|
66
|
+
|
|
67
|
+
### Responsive actions: inline when wide, in a menu when narrow
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { Button, Icon, MenuItem, OverflowMenu, Row } from "@sableui/react"
|
|
71
|
+
|
|
72
|
+
<div className="@container/actions flex w-full justify-end">
|
|
73
|
+
<Row gap={1} align="center">
|
|
74
|
+
<div className="hidden @md/actions:block">
|
|
75
|
+
<Row gap={1}>
|
|
76
|
+
<Button variant="ghost" size="sm" iconStart={<Icon name="share" />}>Share</Button>
|
|
77
|
+
<Button variant="ghost" size="sm" iconStart={<Icon name="download" />}>Export</Button>
|
|
78
|
+
</Row>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="@md/actions:hidden">
|
|
81
|
+
<OverflowMenu label="Report actions" size="icon-sm">
|
|
82
|
+
<MenuItem icon={<Icon name="share" />}>Share</MenuItem>
|
|
83
|
+
<MenuItem icon={<Icon name="download" />}>Export</MenuItem>
|
|
84
|
+
</OverflowMenu>
|
|
85
|
+
</div>
|
|
86
|
+
<Button size="sm" iconStart={<Icon name="edit" />}>Edit report</Button>
|
|
87
|
+
</Row>
|
|
88
|
+
</div>
|
|
89
|
+
```
|