@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.
Files changed (87) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +170 -0
  3. package/DESIGN.md +1 -1
  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 +47 -31
  44. package/dist/icon-sets/reicon.js +2 -0
  45. package/dist/index.d.ts +655 -162
  46. package/dist/index.js +12 -1
  47. package/dist/lib/taxonomy.js +5 -0
  48. package/dist/lib/use-caret-trigger.js +187 -0
  49. package/dist/lib/use-copy.js +6 -1
  50. package/dist/lib/use-smooth-wheel.js +164 -0
  51. package/dist/patterns/chat-panel.js +1 -1
  52. package/dist/patterns/chat-panel.meta.js +1 -0
  53. package/dist/styles.css +25 -0
  54. package/llms/components/app-header.md +1 -1
  55. package/llms/components/button-group.md +55 -0
  56. package/llms/components/button.md +1 -1
  57. package/llms/components/caret-menu.md +100 -0
  58. package/llms/components/chat-composer.md +24 -7
  59. package/llms/components/combobox.md +12 -0
  60. package/llms/components/input-group.md +78 -0
  61. package/llms/components/menu.md +16 -1
  62. package/llms/components/menubar.md +110 -0
  63. package/llms/components/message-scroller.md +1 -0
  64. package/llms/components/navigation-menu.md +113 -0
  65. package/llms/components/overflow-menu.md +26 -1
  66. package/llms/components/pagination.md +15 -3
  67. package/llms/components/scroller.md +1 -0
  68. package/llms/components/sheet.md +18 -3
  69. package/llms/components/sidebar.md +2 -0
  70. package/llms/components/streaming-text.md +2 -1
  71. package/llms/patterns/chat-panel.md +4 -1
  72. package/llms/tasks/building-a-chat.md +158 -0
  73. package/llms/tasks/building-with-sable.md +4 -1
  74. package/llms/tasks/converting-an-app.md +7 -0
  75. package/llms/tasks/extending-a-component.md +170 -0
  76. package/llms/tasks/getting-started.md +22 -4
  77. package/llms/tasks/migrating-from-shadcn.md +351 -0
  78. package/llms-full.txt +1321 -26
  79. package/llms.txt +13 -4
  80. package/package.json +3 -2
  81. package/registry.json +979 -23
  82. package/scripts/lint-rules.mjs +35 -0
  83. package/scripts/wiring.mjs +541 -0
  84. package/skills/entry/SKILL.md +3 -2
  85. package/skills/sable/SKILL.md +26 -5
  86. package/targets/ios.json +45 -0
  87. package/dist/lib/use-wheel-to-horizontal.js +0 -65
@@ -34,7 +34,8 @@ var appHeaderMeta = {
34
34
  components: [
35
35
  "AppShell",
36
36
  "PageHeader",
37
- "BottomNav"
37
+ "BottomNav",
38
+ "NavigationMenu"
38
39
  ]
39
40
  },
40
41
  props: [
@@ -109,6 +109,9 @@ function Button({
109
109
  children: content,
110
110
  onClick: handleClick,
111
111
  onKeyDown: handleKeyDown,
112
+ // Read by a ButtonGroup, which draws the seam between a bordered pair
113
+ // differently from a filled one.
114
+ "data-variant": variant ?? "primary",
112
115
  ...resolvedBleed ? { "data-bleed": resolvedBleed } : null,
113
116
  ...busy ? { "aria-busy": true } : null,
114
117
  // A busy native button stays focusable: aria-disabled, with presses
@@ -33,7 +33,7 @@ var buttonMeta = {
33
33
  },
34
34
  {
35
35
  guidance: "do",
36
- description: "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`."
36
+ description: "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`. Text that costs something to produce (a file to fetch) goes in as a function, `useCopy(() => fetch(url).then((r) => r.text()))`, called only on click."
37
37
  },
38
38
  {
39
39
  guidance: "do",
@@ -2,7 +2,7 @@
2
2
  import { cva } from "class-variance-authority";
3
3
  var ON_COLOR_FOCUS = "focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-transparent";
4
4
  var buttonVariants = cva(
5
- "inline-flex items-center justify-center gap-(--control-gap) whitespace-nowrap rounded-[var(--button-radius,9999px)] motion-interactive motion-press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:shrink-0 [&_svg]:size-(--button-icon)",
5
+ "inline-flex items-center justify-center gap-(--control-gap) whitespace-nowrap rounded-s-[var(--button-radius-s,var(--button-radius,9999px))] rounded-e-[var(--button-radius-e,var(--button-radius,9999px))] motion-interactive motion-press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:shrink-0 [&_svg]:size-(--button-icon)",
6
6
  {
7
7
  variants: {
8
8
  variant: {
@@ -0,0 +1,17 @@
1
+ // src/components/button-group/button-group.tsx
2
+ import { cn } from "../../lib/cn.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ function ButtonGroup({ className, role = "group", ...props }) {
5
+ return /* @__PURE__ */ jsx(
6
+ "div",
7
+ {
8
+ role,
9
+ "data-slot": "button-group",
10
+ className: cn("inline-flex items-stretch button-group-join", className),
11
+ ...props
12
+ }
13
+ );
14
+ }
15
+ export {
16
+ ButtonGroup
17
+ };
@@ -0,0 +1,72 @@
1
+ // src/components/button-group/button-group.meta.ts
2
+ var buttonGroupMeta = {
3
+ name: "ButtonGroup",
4
+ summary: "Buttons joined into one control: a split button (an action beside the chevron that opens its menu) or a short run of related actions. The children stay ordinary Buttons; the group squares their inner corners, collapses bordered seams into one hairline, and draws a seam between filled ones.",
5
+ status: "stable",
6
+ keywords: ["split button", "button group", "segmented actions", "dropdown button", "joined buttons", "toolbar"],
7
+ usage: {
8
+ description: "Use ButtonGroup when one action has close alternatives (Copy page, with Open in\u2026 behind a chevron) or a few actions belong together as one object (zoom out, reset, zoom in). Use ToggleGroup for a choice that stays selected, and Tabs to switch views.",
9
+ bestPractices: [
10
+ {
11
+ guidance: "do",
12
+ description: 'Give the children one height: `size="sm"` with `size="icon-sm"`, `md` with `icon`. The group aligns them but cannot reconcile two heights into one edge.'
13
+ },
14
+ {
15
+ guidance: "do",
16
+ description: 'For a split button, put the main action first and a Menu whose trigger is the chevron Button second, with an aria-label on the trigger ("More page actions").'
17
+ },
18
+ {
19
+ guidance: "do",
20
+ description: "Name the group with aria-label when the buttons alone do not say what they act on."
21
+ },
22
+ {
23
+ guidance: "dont",
24
+ description: "Do not round or border the children yourself to make them meet; that is the group's job, and an override breaks under a theme's button shape."
25
+ },
26
+ {
27
+ guidance: "dont",
28
+ description: "Do not use it for a selection that stays on. That is ToggleGroup, which carries the pressed state."
29
+ }
30
+ ]
31
+ },
32
+ related: {
33
+ components: ["Button", "Menu", "Toggle"]
34
+ },
35
+ props: [
36
+ {
37
+ name: "role",
38
+ type: "string",
39
+ default: '"group"',
40
+ description: "Group semantics, so a screen reader announces the buttons as one set. Pass `toolbar` when it carries arrow-key navigation of your own."
41
+ },
42
+ {
43
+ name: "className",
44
+ type: "string",
45
+ description: "Layout classes for the group (margins, alignment). The children's look comes from their own variants."
46
+ },
47
+ {
48
+ name: "...props",
49
+ type: 'ComponentPropsWithRef<"div">',
50
+ description: "All native <div> attributes, including aria-label."
51
+ }
52
+ ],
53
+ tokensUsed: [],
54
+ a11y: [
55
+ 'Renders role="group", so assistive tech announces the buttons as one set; name it with aria-label.',
56
+ "Each child keeps its own focus stop and label; a focused child is raised above its neighbour so its ring is drawn whole.",
57
+ 'An icon-only chevron that opens a menu needs its own aria-label, such as "More page actions".'
58
+ ],
59
+ examples: [
60
+ {
61
+ label: "Split button with a menu",
62
+ code: '<ButtonGroup aria-label="Page actions"><Button variant="outline" size="sm" iconStart={<Icon name="copy" />} onClick={copy}>Copy page</Button><Menu><MenuTrigger render={<Button variant="outline" size="icon-sm" aria-label="More page actions" />}><Icon name="chevron-down" /></MenuTrigger><MenuContent align="end"><MenuItem icon={<Icon name="file" />} description="View this page as plain text">View as Markdown</MenuItem></MenuContent></Menu></ButtonGroup>'
63
+ },
64
+ {
65
+ label: "Related actions",
66
+ code: '<ButtonGroup aria-label="Zoom"><Button variant="secondary" size="icon-sm" aria-label="Zoom out"><Icon name="minus" /></Button><Button variant="secondary" size="sm">100%</Button><Button variant="secondary" size="icon-sm" aria-label="Zoom in"><Icon name="plus" /></Button></ButtonGroup>'
67
+ }
68
+ ]
69
+ };
70
+ export {
71
+ buttonGroupMeta
72
+ };
@@ -0,0 +1,7 @@
1
+ // src/components/button-group/index.ts
2
+ import { ButtonGroup } from "./button-group.js";
3
+ import { buttonGroupMeta } from "./button-group.meta.js";
4
+ export {
5
+ ButtonGroup,
6
+ buttonGroupMeta
7
+ };
@@ -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
  };