@sableui/react 0.11.0 → 0.13.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.
Files changed (90) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +199 -0
  3. package/DESIGN.md +3 -3
  4. package/README.md +15 -1
  5. package/bin/sable.mjs +254 -10
  6. package/dist/components/app-header/app-header.meta.js +2 -1
  7. package/dist/components/button/button.js +3 -0
  8. package/dist/components/button/button.meta.js +1 -1
  9. package/dist/components/button/button.variants.js +1 -1
  10. package/dist/components/button-group/button-group.js +17 -0
  11. package/dist/components/button-group/button-group.meta.js +72 -0
  12. package/dist/components/button-group/index.js +7 -0
  13. package/dist/components/caret-menu/caret-menu.js +221 -0
  14. package/dist/components/caret-menu/caret-menu.meta.js +119 -0
  15. package/dist/components/caret-menu/index.js +8 -0
  16. package/dist/components/chat-composer/chat-composer.js +30 -7
  17. package/dist/components/chat-composer/chat-composer.meta.js +27 -8
  18. package/dist/components/combobox/combobox.meta.js +26 -0
  19. package/dist/components/input-group/index.js +7 -0
  20. package/dist/components/input-group/input-group.js +66 -0
  21. package/dist/components/input-group/input-group.meta.js +106 -0
  22. package/dist/components/menu/index.js +2 -0
  23. package/dist/components/menu/menu.js +51 -9
  24. package/dist/components/menu/menu.meta.js +11 -2
  25. package/dist/components/menubar/index.js +8 -0
  26. package/dist/components/menubar/menubar.js +42 -0
  27. package/dist/components/menubar/menubar.meta.js +99 -0
  28. package/dist/components/message-scroller/message-scroller.meta.js +4 -0
  29. package/dist/components/navigation-menu/index.js +19 -0
  30. package/dist/components/navigation-menu/navigation-menu.js +108 -0
  31. package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
  32. package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
  33. package/dist/components/pagination/pagination.js +11 -5
  34. package/dist/components/pagination/pagination.meta.js +21 -3
  35. package/dist/components/scroller/scroller.js +8 -4
  36. package/dist/components/scroller/scroller.meta.js +6 -0
  37. package/dist/components/sheet/sheet.js +82 -9
  38. package/dist/components/sheet/sheet.meta.js +41 -4
  39. package/dist/components/sheet/sheet.variants.js +8 -1
  40. package/dist/components/sidebar/sidebar.js +13 -2
  41. package/dist/components/sidebar/sidebar.meta.js +10 -0
  42. package/dist/components/streaming-text/streaming-text.meta.js +5 -1
  43. package/dist/file-hashes.json +49 -33
  44. package/dist/icon-sets/reicon.js +2 -0
  45. package/dist/index.d.ts +660 -164
  46. package/dist/index.js +12 -1
  47. package/dist/lib/palette.js +46 -6
  48. package/dist/lib/taxonomy.js +5 -0
  49. package/dist/lib/use-caret-trigger.js +187 -0
  50. package/dist/lib/use-copy.js +6 -1
  51. package/dist/lib/use-smooth-wheel.js +164 -0
  52. package/dist/palette.css +243 -165
  53. package/dist/patterns/chat-panel.js +1 -1
  54. package/dist/patterns/chat-panel.meta.js +1 -0
  55. package/dist/styles.css +25 -0
  56. package/llms/components/app-header.md +1 -1
  57. package/llms/components/button-group.md +55 -0
  58. package/llms/components/button.md +1 -1
  59. package/llms/components/caret-menu.md +100 -0
  60. package/llms/components/chat-composer.md +24 -7
  61. package/llms/components/combobox.md +12 -0
  62. package/llms/components/input-group.md +78 -0
  63. package/llms/components/menu.md +16 -1
  64. package/llms/components/menubar.md +110 -0
  65. package/llms/components/message-scroller.md +1 -0
  66. package/llms/components/navigation-menu.md +113 -0
  67. package/llms/components/overflow-menu.md +26 -1
  68. package/llms/components/pagination.md +15 -3
  69. package/llms/components/scroller.md +1 -0
  70. package/llms/components/sheet.md +18 -3
  71. package/llms/components/sidebar.md +2 -0
  72. package/llms/components/streaming-text.md +2 -1
  73. package/llms/patterns/chat-panel.md +4 -1
  74. package/llms/reference/token-intent.md +1 -1
  75. package/llms/tasks/building-a-chat.md +158 -0
  76. package/llms/tasks/building-with-sable.md +4 -1
  77. package/llms/tasks/converting-an-app.md +7 -0
  78. package/llms/tasks/extending-a-component.md +170 -0
  79. package/llms/tasks/getting-started.md +22 -4
  80. package/llms/tasks/migrating-from-shadcn.md +351 -0
  81. package/llms-full.txt +1322 -27
  82. package/llms.txt +13 -4
  83. package/package.json +3 -2
  84. package/registry.json +979 -23
  85. package/scripts/lint-rules.mjs +35 -0
  86. package/scripts/wiring.mjs +541 -0
  87. package/skills/entry/SKILL.md +3 -2
  88. package/skills/sable/SKILL.md +26 -5
  89. package/targets/ios.json +45 -0
  90. package/dist/lib/use-wheel-to-horizontal.js +0 -65
@@ -0,0 +1,221 @@
1
+ "use client";
2
+
3
+ // src/components/caret-menu/caret-menu.tsx
4
+ import {
5
+ Children,
6
+ createContext,
7
+ useCallback,
8
+ useContext,
9
+ useEffect,
10
+ useId,
11
+ useRef,
12
+ useState
13
+ } from "react";
14
+ import { createPortal } from "react-dom";
15
+ import { cn } from "../../lib/cn.js";
16
+ import { useAnchoredDensity, useTypeTier } from "../../lib/density.js";
17
+ import { HighlightGlide } from "../../lib/highlight-glide.js";
18
+ import { applyPortalThemeScope, resolvePortalThemeScope } from "../../lib/portal-theme-scope.js";
19
+ import { useIsomorphicLayoutEffect } from "../../lib/use-isomorphic-layout-effect.js";
20
+ import { MENU_ITEM, useItemContent } from "../menu/menu.js";
21
+ import { jsx, jsxs } from "react/jsx-runtime";
22
+ var EDGE = 8;
23
+ var GAP = 4;
24
+ var CaretMenuContext = createContext(null);
25
+ function CaretMenu({ trigger, label, status, children, className }) {
26
+ const { field, match } = trigger;
27
+ const listId = useId();
28
+ const anchoredDensity = useAnchoredDensity();
29
+ const tier = useTypeTier();
30
+ const popupRef = useRef(null);
31
+ const listRef = useRef(null);
32
+ const [activeId, setActiveId] = useState(null);
33
+ const [count, setCount] = useState(0);
34
+ const triggerRef = useRef(trigger);
35
+ triggerRef.current = trigger;
36
+ const hasItems = Children.toArray(children).length > 0;
37
+ const open = match !== null && field !== null && (hasItems || status != null);
38
+ useIsomorphicLayoutEffect(() => {
39
+ const options = open ? optionsIn(listRef.current) : [];
40
+ setCount(options.length);
41
+ if (!options.some((o) => o.id === activeId)) setActiveId(options[0]?.id ?? null);
42
+ });
43
+ useEffect(() => {
44
+ if (!field) return;
45
+ const isInput = field instanceof HTMLInputElement;
46
+ if (isInput) field.setAttribute("role", "combobox");
47
+ field.setAttribute("aria-autocomplete", "list");
48
+ return () => {
49
+ if (isInput) {
50
+ field.removeAttribute("role");
51
+ field.removeAttribute("aria-expanded");
52
+ }
53
+ field.removeAttribute("aria-autocomplete");
54
+ };
55
+ }, [field]);
56
+ useEffect(() => {
57
+ if (!field) return;
58
+ if (field instanceof HTMLInputElement) field.setAttribute("aria-expanded", String(open));
59
+ if (!open) return;
60
+ field.setAttribute("aria-controls", listId);
61
+ if (activeId) field.setAttribute("aria-activedescendant", activeId);
62
+ else field.removeAttribute("aria-activedescendant");
63
+ return () => {
64
+ field.removeAttribute("aria-activedescendant");
65
+ field.removeAttribute("aria-controls");
66
+ };
67
+ }, [field, open, listId, activeId]);
68
+ const activeRef = useRef(activeId);
69
+ activeRef.current = activeId;
70
+ useEffect(() => {
71
+ if (!field || !open) return;
72
+ const onKeyDown = (event) => {
73
+ if (event.isComposing) return;
74
+ const options = optionsIn(listRef.current);
75
+ const index = options.findIndex((o) => o.id === activeRef.current);
76
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
77
+ if (!options.length) return;
78
+ event.preventDefault();
79
+ const step = event.key === "ArrowDown" ? 1 : -1;
80
+ const next = options[(index + step + options.length) % options.length];
81
+ setActiveId(next.id);
82
+ next.scrollIntoView?.({ block: "nearest" });
83
+ } else if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
84
+ if (index < 0) return;
85
+ event.preventDefault();
86
+ options[index].click();
87
+ } else if (event.key === "Escape") {
88
+ event.preventDefault();
89
+ event.stopPropagation();
90
+ triggerRef.current.dismiss();
91
+ }
92
+ };
93
+ const target = field;
94
+ target.addEventListener("keydown", onKeyDown);
95
+ return () => target.removeEventListener("keydown", onKeyDown);
96
+ }, [field, open]);
97
+ const place = useCallback(() => {
98
+ const popup = popupRef.current;
99
+ const anchor = triggerRef.current.getAnchorRect();
100
+ if (!popup || !anchor) return;
101
+ const view = popup.ownerDocument.defaultView ?? window;
102
+ const natural = popup.scrollHeight;
103
+ const below = view.innerHeight - (anchor.bottom + GAP) - EDGE;
104
+ const above = anchor.top - GAP - EDGE;
105
+ const flip = natural > below && above > below;
106
+ const room = Math.max(0, flip ? above : below);
107
+ popup.style.maxHeight = `${room}px`;
108
+ const height = Math.min(natural, room);
109
+ popup.style.top = `${flip ? anchor.top - GAP - height : anchor.bottom + GAP}px`;
110
+ popup.style.setProperty("--transform-origin", flip ? "left bottom" : "left top");
111
+ const option = optionsIn(listRef.current)[0];
112
+ const inset = Number.parseFloat(view.getComputedStyle(popup).paddingLeft) + (option ? Number.parseFloat(view.getComputedStyle(option).paddingLeft) : 0);
113
+ const width = popup.offsetWidth;
114
+ const left = Math.min(anchor.left - inset, view.innerWidth - EDGE - width);
115
+ popup.style.left = `${Math.max(EDGE, left)}px`;
116
+ }, []);
117
+ useIsomorphicLayoutEffect(() => {
118
+ if (open) place();
119
+ });
120
+ useEffect(() => {
121
+ if (!open || !field) return;
122
+ const view = field.ownerDocument.defaultView ?? window;
123
+ const onScroll = (event) => {
124
+ if (popupRef.current?.contains(event.target)) return;
125
+ place();
126
+ };
127
+ view.addEventListener("resize", place);
128
+ view.addEventListener("scroll", onScroll, true);
129
+ return () => {
130
+ view.removeEventListener("resize", place);
131
+ view.removeEventListener("scroll", onScroll, true);
132
+ };
133
+ }, [open, field, place]);
134
+ const [entered, setEntered] = useState(false);
135
+ useEffect(() => {
136
+ if (!open) {
137
+ setEntered(false);
138
+ return;
139
+ }
140
+ const frame = requestAnimationFrame(() => setEntered(true));
141
+ return () => cancelAnimationFrame(frame);
142
+ }, [open]);
143
+ const setScope = useCallback(
144
+ (node) => {
145
+ if (node && field) applyPortalThemeScope(node, resolvePortalThemeScope(field));
146
+ },
147
+ [field]
148
+ );
149
+ const announcement = !open ? "" : status != null && typeof status !== "boolean" ? status : `${count} ${count === 1 ? "suggestion" : "suggestions"}`;
150
+ return /* @__PURE__ */ jsxs(CaretMenuContext.Provider, { value: { activeId, setActive: setActiveId }, children: [
151
+ /* @__PURE__ */ jsx("span", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement }),
152
+ open && field ? createPortal(
153
+ /* @__PURE__ */ jsx("div", { ref: setScope, "data-slot": "caret-menu-portal", children: /* @__PURE__ */ jsxs(
154
+ "div",
155
+ {
156
+ ref: popupRef,
157
+ ...anchoredDensity,
158
+ "data-slot": "caret-menu",
159
+ "data-starting-style": entered ? void 0 : "",
160
+ className: cn(
161
+ "fixed z-(--z-overlay) min-w-48 max-w-[min(24rem,calc(100vw-1rem))] rounded-md border border-border-floating bg-popover popup-inset text-popover-foreground shadow-md outline-none",
162
+ "overflow-y-auto overscroll-contain scrollbar-quiet motion-pop",
163
+ className
164
+ ),
165
+ children: [
166
+ /* @__PURE__ */ jsx(HighlightGlide, {}),
167
+ /* @__PURE__ */ jsx("div", { ref: listRef, id: listId, role: "listbox", "aria-label": label, children }),
168
+ status != null ? /* @__PURE__ */ jsx("div", { "data-slot": "caret-menu-status", className: cn("px-(--item-px) py-1.5 text-muted-foreground", tier.secondary), children: status }) : null
169
+ ]
170
+ }
171
+ ) }),
172
+ field.ownerDocument.body
173
+ ) : null
174
+ ] });
175
+ }
176
+ function optionsIn(list) {
177
+ if (!list) return [];
178
+ return [...list.querySelectorAll('[role="option"]:not([aria-disabled="true"])')];
179
+ }
180
+ function CaretMenuItem({
181
+ onSelect,
182
+ disabled = false,
183
+ icon,
184
+ description,
185
+ className,
186
+ children,
187
+ id,
188
+ ...props
189
+ }) {
190
+ const context = useContext(CaretMenuContext);
191
+ const tier = useTypeTier();
192
+ const ownId = useId();
193
+ const optionId = id ?? ownId;
194
+ const active = context?.activeId === optionId;
195
+ const content = useItemContent(children, { icon, description });
196
+ return /* @__PURE__ */ jsx(
197
+ "div",
198
+ {
199
+ ...props,
200
+ id: optionId,
201
+ role: "option",
202
+ "aria-selected": active,
203
+ "aria-disabled": disabled || void 0,
204
+ "data-highlighted": active ? "" : void 0,
205
+ "data-disabled": disabled ? "" : void 0,
206
+ className: cn(MENU_ITEM, tier.body, className),
207
+ onMouseDown: (event) => event.preventDefault(),
208
+ onMouseMove: () => {
209
+ if (!disabled && !active) context?.setActive(optionId);
210
+ },
211
+ onClick: () => {
212
+ if (!disabled) onSelect();
213
+ },
214
+ children: content
215
+ }
216
+ );
217
+ }
218
+ export {
219
+ CaretMenu,
220
+ CaretMenuItem
221
+ };
@@ -0,0 +1,119 @@
1
+ // src/components/caret-menu/caret-menu.meta.ts
2
+ var caretMenuMeta = {
3
+ name: "CaretMenu",
4
+ summary: "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`.",
5
+ status: "beta",
6
+ keywords: [
7
+ "mention",
8
+ "mentions",
9
+ "at mention",
10
+ "slash command",
11
+ "slash commands",
12
+ "autocomplete",
13
+ "typeahead",
14
+ "suggestions",
15
+ "emoji picker",
16
+ "hashtag",
17
+ "trigger"
18
+ ],
19
+ usage: {
20
+ description: "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.",
21
+ bestPractices: [
22
+ {
23
+ guidance: "do",
24
+ description: "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."
25
+ },
26
+ {
27
+ guidance: "do",
28
+ description: '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.'
29
+ },
30
+ {
31
+ guidance: "do",
32
+ description: "For `@` names with spaces set `allowSpaces: true` on that trigger; for `/` commands set `atStart: true` so `and/or` mid-sentence stays text."
33
+ },
34
+ {
35
+ guidance: "do",
36
+ description: 'Fetch remote suggestions from `trigger.match?.query` yourself, debounced, and pass `status` while loading or when nothing matches ("Searching\u2026", "No one matches"). The status row is also what the list announces.'
37
+ },
38
+ {
39
+ guidance: "dont",
40
+ description: "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`)."
41
+ },
42
+ {
43
+ guidance: "dont",
44
+ description: "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."
45
+ }
46
+ ],
47
+ anatomy: [
48
+ { name: "useCaretTrigger", required: true, description: "The hook on the field: trigger detection, the query, the caret anchor, replace() and dismiss()." },
49
+ { name: "CaretMenu", required: true, description: "The list at the caret, its keys, and its announcements." },
50
+ { name: "CaretMenuItem", required: true, description: "One suggestion row, with optional icon and description." }
51
+ ]
52
+ },
53
+ related: {
54
+ components: ["ChatComposer", "Textarea", "Input", "Combobox", "CommandMenu", "Menu"]
55
+ },
56
+ props: [
57
+ {
58
+ name: "trigger",
59
+ type: "UseCaretTriggerResult",
60
+ description: "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()`."
61
+ },
62
+ {
63
+ name: "label",
64
+ type: "string",
65
+ description: `The list's accessible name, e.g. "People" or "Commands".`
66
+ },
67
+ {
68
+ name: "status",
69
+ type: "ReactNode",
70
+ description: "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."
71
+ },
72
+ {
73
+ name: "children",
74
+ type: "ReactNode",
75
+ description: "CaretMenuItems, already filtered for the query."
76
+ },
77
+ {
78
+ name: "className",
79
+ type: "string",
80
+ description: "Layout classes for the popup, such as a width."
81
+ }
82
+ ],
83
+ subcomponents: [
84
+ {
85
+ name: "CaretMenuItem",
86
+ type: "div props + onSelect: () => void + disabled?: boolean + icon?: ReactNode + description?: ReactNode",
87
+ description: "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."
88
+ }
89
+ ],
90
+ tokensUsed: ["popover", "popover-foreground", "border-floating", "accent", "accent-foreground", "muted-foreground"],
91
+ motion: [
92
+ {
93
+ slot: "arrival",
94
+ recipe: "motion-pop",
95
+ part: "root",
96
+ why: "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."
97
+ }
98
+ ],
99
+ a11y: [
100
+ '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.',
101
+ '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.',
102
+ "\u2191/\u2193 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.",
103
+ "Rows keep focus in the field on pointer down, so a click does not blur it and close the list first.",
104
+ "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."
105
+ ],
106
+ examples: [
107
+ {
108
+ label: "Mentions and slash commands in a ChatComposer",
109
+ code: 'const trigger = useCaretTrigger({ triggers: [{ char: "@", allowSpaces: true }, { char: "/", atStart: true }] })\nconst q = trigger.match?.query.toLowerCase() ?? ""\nconst commands = trigger.match?.trigger.char === "/"\n\n<ChatComposer fieldRef={trigger.fieldRef} onSend={send} />\n<CaretMenu trigger={trigger} label={commands ? "Commands" : "People"} status={!commands && people.length === 0 ? "No one matches" : undefined}>\n {commands\n ? slashCommands.filter((c) => c.name.startsWith(q)).map((c) => (\n <CaretMenuItem key={c.name} description={c.hint} onSelect={() => { c.run(); trigger.replace("") }}>/{c.name}</CaretMenuItem>\n ))\n : people.filter((p) => p.name.toLowerCase().includes(q)).map((p) => (\n <CaretMenuItem key={p.id} icon={<Avatar size="sm" src={p.avatar} fallback={p.initials} />} onSelect={() => trigger.replace(`@${p.name} `)}>{p.name}</CaretMenuItem>\n ))}\n</CaretMenu>'
110
+ },
111
+ {
112
+ label: "On a Textarea, with remote results",
113
+ code: 'const trigger = useCaretTrigger({ triggers: [{ char: "#" }] })\n// Fetch from trigger.match?.query yourself, debounced; pass the state as status.\n<Textarea ref={trigger.fieldRef} aria-label="Note" />\n<CaretMenu trigger={trigger} label="Channels" status={loading ? "Searching\u2026" : channels.length === 0 ? "No channels match" : undefined}>\n {channels.map((c) => <CaretMenuItem key={c.id} onSelect={() => trigger.replace(`#${c.name} `)}>#{c.name}</CaretMenuItem>)}\n</CaretMenu>'
114
+ }
115
+ ]
116
+ };
117
+ export {
118
+ caretMenuMeta
119
+ };
@@ -0,0 +1,8 @@
1
+ // src/components/caret-menu/index.ts
2
+ import { CaretMenu, CaretMenuItem } from "./caret-menu.js";
3
+ import { caretMenuMeta } from "./caret-menu.meta.js";
4
+ export {
5
+ CaretMenu,
6
+ CaretMenuItem,
7
+ caretMenuMeta
8
+ };
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  // src/components/chat-composer/chat-composer.tsx
4
- import { useCallback, useRef, useState } from "react";
4
+ import { useCallback, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { Icon } from "../../icon/index.js";
6
6
  import { cn } from "../../lib/cn.js";
7
7
  import { useTypeTier } from "../../lib/density.js";
@@ -15,6 +15,7 @@ function ChatComposer({
15
15
  defaultValue = "",
16
16
  onValueChange,
17
17
  onSend,
18
+ allowEmpty = false,
18
19
  placeholder = "Send a message\u2026",
19
20
  sendOn = "enter",
20
21
  busy = false,
@@ -53,7 +54,10 @@ function ChatComposer({
53
54
  },
54
55
  [isControlled, onValueChange]
55
56
  );
56
- const canSend = text.trim().length > 0 && !busy && !disabled;
57
+ const coarse = useCoarsePointer();
58
+ const mode = coarse ? "mod-enter" : sendOn;
59
+ const footerContent = typeof footer === "function" ? footer({ sendOn: mode }) : footer;
60
+ const canSend = (allowEmpty || text.trim().length > 0) && !busy && !disabled;
57
61
  const send = useCallback(() => {
58
62
  if (!canSend) return;
59
63
  onSend?.(text.trim());
@@ -70,11 +74,12 @@ function ChatComposer({
70
74
  (event) => {
71
75
  if (event.key !== "Enter") return;
72
76
  if (event.nativeEvent.isComposing) return;
73
- if (sendOn === "mod-enter" ? !(event.metaKey || event.ctrlKey) : event.shiftKey) return;
77
+ if (event.defaultPrevented) return;
78
+ if (mode === "mod-enter" ? !(event.metaKey || event.ctrlKey) : event.shiftKey) return;
74
79
  event.preventDefault();
75
80
  send();
76
81
  },
77
- [send, sendOn]
82
+ [send, mode]
78
83
  );
79
84
  const showStop = busy && Boolean(onStop);
80
85
  return /* @__PURE__ */ jsxs(
@@ -104,7 +109,7 @@ function ChatComposer({
104
109
  autoResize: true,
105
110
  rows: 1,
106
111
  "aria-label": label,
107
- "aria-keyshortcuts": sendOn === "mod-enter" ? "Meta+Enter Control+Enter" : "Enter",
112
+ "aria-keyshortcuts": mode === "mod-enter" ? "Meta+Enter Control+Enter" : "Enter",
108
113
  "aria-invalid": invalid || void 0,
109
114
  disabled,
110
115
  placeholder,
@@ -145,7 +150,10 @@ function ChatComposer({
145
150
  variant: "secondary",
146
151
  size: "icon-sm",
147
152
  "aria-label": stopLabel,
148
- onClick: onStop,
153
+ onClick: () => {
154
+ onStop?.();
155
+ if (!coarse) field.current?.focus();
156
+ },
149
157
  children: /* @__PURE__ */ jsx(Icon, { name: "stop", variant: "solid", className: "text-[0.75em]" })
150
158
  }
151
159
  ) : (
@@ -164,13 +172,28 @@ function ChatComposer({
164
172
  )
165
173
  )
166
174
  ] }),
167
- footer ? /* @__PURE__ */ jsx("p", { className: cn("px-[calc((var(--control-sm)-1.125rem)/2)] text-muted-foreground", tier.secondary), children: footer }) : null,
175
+ footerContent ? /* @__PURE__ */ jsx("p", { className: cn("px-[calc((var(--control-sm)-1.125rem)/2)] text-muted-foreground", tier.secondary), children: footerContent }) : null,
168
176
  children
169
177
  ]
170
178
  }
171
179
  );
172
180
  }
173
181
  ChatComposer.displayName = "ChatComposer";
182
+ var COARSE = "(pointer: coarse)";
183
+ function subscribeCoarse(onChange) {
184
+ if (typeof window === "undefined" || !window.matchMedia) return () => {
185
+ };
186
+ const query = window.matchMedia(COARSE);
187
+ query.addEventListener("change", onChange);
188
+ return () => query.removeEventListener("change", onChange);
189
+ }
190
+ function useCoarsePointer() {
191
+ return useSyncExternalStore(
192
+ subscribeCoarse,
193
+ () => typeof window !== "undefined" && Boolean(window.matchMedia?.(COARSE).matches),
194
+ () => false
195
+ );
196
+ }
174
197
  export {
175
198
  ChatComposer
176
199
  };
@@ -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, which is the line hand-rolled chat inputs miss."
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: "Called with the trimmed message. An uncontrolled composer clears itself afterwards; a controlled one is the caller's to clear."
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: "Helper text or counter below the controls."
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,7 @@
1
+ // src/components/input-group/index.ts
2
+ import { InputGroup } from "./input-group.js";
3
+ import { inputGroupMeta } from "./input-group.meta.js";
4
+ export {
5
+ InputGroup,
6
+ inputGroupMeta
7
+ };