@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.
Files changed (91) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +184 -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/panel-section/index.js +2 -1
  36. package/dist/components/panel-section/panel-section.js +15 -0
  37. package/dist/components/panel-section/panel-section.meta.js +32 -0
  38. package/dist/components/scroller/scroller.js +8 -4
  39. package/dist/components/scroller/scroller.meta.js +6 -0
  40. package/dist/components/sheet/sheet.js +82 -9
  41. package/dist/components/sheet/sheet.meta.js +41 -4
  42. package/dist/components/sheet/sheet.variants.js +8 -1
  43. package/dist/components/sidebar/sidebar.js +13 -2
  44. package/dist/components/sidebar/sidebar.meta.js +10 -0
  45. package/dist/components/streaming-text/streaming-text.meta.js +5 -1
  46. package/dist/file-hashes.json +50 -34
  47. package/dist/icon-sets/reicon.js +2 -0
  48. package/dist/index.d.ts +687 -164
  49. package/dist/index.js +12 -1
  50. package/dist/lib/taxonomy.js +5 -0
  51. package/dist/lib/use-caret-trigger.js +187 -0
  52. package/dist/lib/use-copy.js +6 -1
  53. package/dist/lib/use-smooth-wheel.js +164 -0
  54. package/dist/patterns/chat-panel.js +1 -1
  55. package/dist/patterns/chat-panel.meta.js +1 -0
  56. package/dist/styles.css +25 -0
  57. package/llms/components/app-header.md +1 -1
  58. package/llms/components/button-group.md +55 -0
  59. package/llms/components/button.md +1 -1
  60. package/llms/components/caret-menu.md +100 -0
  61. package/llms/components/chat-composer.md +24 -7
  62. package/llms/components/combobox.md +12 -0
  63. package/llms/components/input-group.md +78 -0
  64. package/llms/components/menu.md +16 -1
  65. package/llms/components/menubar.md +110 -0
  66. package/llms/components/message-scroller.md +1 -0
  67. package/llms/components/navigation-menu.md +113 -0
  68. package/llms/components/overflow-menu.md +26 -1
  69. package/llms/components/pagination.md +15 -3
  70. package/llms/components/panel-section.md +28 -0
  71. package/llms/components/scroller.md +1 -0
  72. package/llms/components/sheet.md +18 -3
  73. package/llms/components/sidebar.md +2 -0
  74. package/llms/components/streaming-text.md +2 -1
  75. package/llms/patterns/chat-panel.md +4 -1
  76. package/llms/tasks/building-a-chat.md +158 -0
  77. package/llms/tasks/building-with-sable.md +4 -1
  78. package/llms/tasks/converting-an-app.md +7 -0
  79. package/llms/tasks/extending-a-component.md +170 -0
  80. package/llms/tasks/getting-started.md +22 -4
  81. package/llms/tasks/migrating-from-shadcn.md +351 -0
  82. package/llms-full.txt +1349 -26
  83. package/llms.txt +14 -5
  84. package/package.json +3 -2
  85. package/registry.json +1037 -53
  86. package/scripts/lint-rules.mjs +35 -0
  87. package/scripts/wiring.mjs +541 -0
  88. package/skills/entry/SKILL.md +3 -2
  89. package/skills/sable/SKILL.md +26 -5
  90. package/targets/ios.json +45 -0
  91. package/dist/lib/use-wheel-to-horizontal.js +0 -65
package/dist/index.js CHANGED
@@ -42,11 +42,14 @@ export * from "./layout/index.js";
42
42
  export * from "./icon/index.js";
43
43
  export * from "./motion/index.js";
44
44
  export * from "./components/button/index.js";
45
+ export * from "./components/button-group/index.js";
45
46
  export * from "./components/card/index.js";
47
+ export * from "./components/caret-menu/index.js";
46
48
  export * from "./components/badge/index.js";
47
49
  export * from "./components/dialog/index.js";
48
50
  export * from "./components/separator/index.js";
49
51
  export * from "./components/input/index.js";
52
+ export * from "./components/input-group/index.js";
50
53
  export * from "./components/textarea/index.js";
51
54
  export * from "./components/number-field/index.js";
52
55
  export * from "./components/otp-field/index.js";
@@ -84,6 +87,8 @@ export * from "./components/radio/index.js";
84
87
  export * from "./components/select/index.js";
85
88
  export * from "./components/popover/index.js";
86
89
  export * from "./components/menu/index.js";
90
+ export * from "./components/navigation-menu/index.js";
91
+ export * from "./components/menubar/index.js";
87
92
  export * from "./components/sheet/index.js";
88
93
  export * from "./components/table/index.js";
89
94
  export * from "./components/data-table/index.js";
@@ -161,9 +166,13 @@ import { Density, DensityProvider, useDensity, useAnchoredDensity, useTypeTier }
161
166
  import { PresentationProvider, usePresentation } from "./lib/presentation.js";
162
167
  import { useScrollActivity } from "./lib/use-scroll-activity.js";
163
168
  import { useCopy } from "./lib/use-copy.js";
169
+ import { useSmoothWheel } from "./lib/use-smooth-wheel.js";
170
+ import {
171
+ useCaretTrigger
172
+ } from "./lib/use-caret-trigger.js";
164
173
  import { STATUS_TONES, STATUS_TONE_ICON } from "./lib/status-tone.js";
165
174
  import { PALETTE_COLORS, PALETTE_FAMILIES, PALETTE_SCALES, PALETTE_STEPS, PALETTE_TONES, isPaletteColor } from "./lib/palette.js";
166
- var SABLE_UI_VERSION = "0.10.0";
175
+ var SABLE_UI_VERSION = "0.12.0";
167
176
  export {
168
177
  ART_TOKENS,
169
178
  CHART_SERIES_COUNT,
@@ -214,10 +223,12 @@ export {
214
223
  themeManifestById,
215
224
  themeManifests,
216
225
  useAnchoredDensity,
226
+ useCaretTrigger,
217
227
  useCopy,
218
228
  useDensity,
219
229
  usePresentation,
220
230
  useSableTheme,
221
231
  useScrollActivity,
232
+ useSmoothWheel,
222
233
  useTypeTier
223
234
  };
@@ -24,6 +24,8 @@ var COMPONENT_TAXONOMY = {
24
24
  options" pattern) — that its list opens in a layer is how it works, not
25
25
  what it is. Copying is `useCopy` on a Button, not a component. */
26
26
  Button: ["Actions", "either"],
27
+ ButtonGroup: ["Actions", "either"],
28
+ Menubar: ["Actions", "either"],
27
29
  OverflowMenu: ["Actions", "either"],
28
30
  Toolbar: ["Actions", "either"],
29
31
  /* ── Forms ────────────────────────────────────────────────────────────────
@@ -38,6 +40,7 @@ var COMPONENT_TAXONOMY = {
38
40
  FileDropzone: ["Forms", "page"],
39
41
  Form: ["Forms", "either"],
40
42
  Input: ["Forms", "either"],
43
+ InputGroup: ["Forms", "either"],
41
44
  Label: ["Forms", "either"],
42
45
  NumberField: ["Forms", "either"],
43
46
  OtpField: ["Forms", "either"],
@@ -64,6 +67,7 @@ var COMPONENT_TAXONOMY = {
64
67
  Toggle: ["Selection", "either"],
65
68
  /* ── Overlays ─────────────────────────────────────────────────────────────
66
69
  Anything that renders above the page in its own layer. */
70
+ CaretMenu: ["Overlays", "either"],
67
71
  CommandMenu: ["Overlays", "either"],
68
72
  ContextCard: ["Overlays", "page"],
69
73
  ContextMenu: ["Overlays", "either"],
@@ -81,6 +85,7 @@ var COMPONENT_TAXONOMY = {
81
85
  stay together; `Tree` is the hierarchy browser. */
82
86
  Breadcrumbs: ["Navigation", "either"],
83
87
  BottomNav: ["Navigation", "page"],
88
+ NavigationMenu: ["Navigation", "page"],
84
89
  Pagination: ["Navigation", "either"],
85
90
  ShowMore: ["Navigation", "page"],
86
91
  SideNav: ["Navigation", "page"],
@@ -0,0 +1,187 @@
1
+ "use client";
2
+
3
+ // src/lib/use-caret-trigger.ts
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ function useCaretTrigger({ triggers }) {
6
+ const [field, setField] = useState(null);
7
+ const [match, setMatch] = useState(null);
8
+ const dismissedAt = useRef(null);
9
+ const composing = useRef(false);
10
+ const triggersRef = useRef(triggers);
11
+ triggersRef.current = triggers;
12
+ const evaluate = useCallback(() => {
13
+ if (!field || composing.current) return;
14
+ const next = field.ownerDocument.activeElement === field ? findMatch(field, triggersRef.current) : null;
15
+ if (!next || next.start !== dismissedAt.current) dismissedAt.current = null;
16
+ const visible = next && dismissedAt.current === null ? next : null;
17
+ setMatch((prev) => sameMatch(prev, visible) ? prev : visible);
18
+ }, [field]);
19
+ useEffect(() => {
20
+ if (!field) return;
21
+ const doc = field.ownerDocument;
22
+ const onSelection = () => {
23
+ if (doc.activeElement === field) evaluate();
24
+ };
25
+ const onCompositionStart = () => {
26
+ composing.current = true;
27
+ };
28
+ const onCompositionEnd = () => {
29
+ composing.current = false;
30
+ setTimeout(evaluate, 0);
31
+ };
32
+ const onBlur = () => {
33
+ dismissedAt.current = null;
34
+ setMatch(null);
35
+ };
36
+ const onInput = (event) => {
37
+ if (event.target === field) evaluate();
38
+ };
39
+ doc.addEventListener("input", onInput);
40
+ field.addEventListener("focus", evaluate);
41
+ field.addEventListener("click", evaluate);
42
+ field.addEventListener("keyup", evaluate);
43
+ field.addEventListener("compositionstart", onCompositionStart);
44
+ field.addEventListener("compositionend", onCompositionEnd);
45
+ field.addEventListener("blur", onBlur);
46
+ doc.addEventListener("selectionchange", onSelection);
47
+ return () => {
48
+ doc.removeEventListener("input", onInput);
49
+ field.removeEventListener("focus", evaluate);
50
+ field.removeEventListener("click", evaluate);
51
+ field.removeEventListener("keyup", evaluate);
52
+ field.removeEventListener("compositionstart", onCompositionStart);
53
+ field.removeEventListener("compositionend", onCompositionEnd);
54
+ field.removeEventListener("blur", onBlur);
55
+ doc.removeEventListener("selectionchange", onSelection);
56
+ };
57
+ }, [field, evaluate]);
58
+ const dismiss = useCallback(() => {
59
+ setMatch((current) => {
60
+ if (current) dismissedAt.current = current.start;
61
+ return null;
62
+ });
63
+ }, []);
64
+ const replace = useCallback(
65
+ (text) => {
66
+ if (!field || !match) return;
67
+ insertText(field, match.start, match.end, text);
68
+ dismissedAt.current = match.start;
69
+ evaluate();
70
+ },
71
+ [field, match, evaluate]
72
+ );
73
+ const getAnchorRect = useCallback(() => {
74
+ if (!field || !match) return null;
75
+ return caretRect(field, match.start);
76
+ }, [field, match]);
77
+ return { fieldRef: setField, field, match, getAnchorRect, replace, dismiss };
78
+ }
79
+ var BOUNDARY = /[\s([{"'“‘]/;
80
+ function findMatch(field, triggers) {
81
+ const { value, selectionStart, selectionEnd } = field;
82
+ if (selectionStart === null || selectionStart !== selectionEnd) return null;
83
+ return matchAt(value, selectionStart, triggers);
84
+ }
85
+ function matchAt(value, caret, triggers) {
86
+ for (let i = caret - 1; i >= 0; i--) {
87
+ const ch = value[i];
88
+ if (ch === "\n") return null;
89
+ const trigger = triggers.find((t) => t.char === ch);
90
+ if (trigger && opens(value, i, caret, trigger)) {
91
+ return { trigger, query: value.slice(i + 1, caret), start: i, end: caret };
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ function opens(value, i, caret, trigger) {
97
+ const query = value.slice(i + 1, caret);
98
+ if (!trigger.allowSpaces && /\s/.test(query)) return false;
99
+ if (query.length > (trigger.maxQueryLength ?? 32)) return false;
100
+ if (/^\s/.test(query)) return false;
101
+ const before = i === 0 ? "" : value[i - 1];
102
+ if (before !== "" && !BOUNDARY.test(before)) return false;
103
+ if (trigger.atStart && value.slice(0, i).trim() !== "") return false;
104
+ return true;
105
+ }
106
+ function sameMatch(a, b) {
107
+ if (a === b) return true;
108
+ if (!a || !b) return false;
109
+ return a.trigger === b.trigger && a.query === b.query && a.start === b.start && a.end === b.end;
110
+ }
111
+ function insertText(field, start, end, text) {
112
+ field.focus();
113
+ field.setSelectionRange(start, end);
114
+ const doc = field.ownerDocument;
115
+ const inserted = text === "" ? typeof doc.execCommand === "function" && doc.execCommand("delete", false) : typeof doc.execCommand === "function" && doc.execCommand("insertText", false, text);
116
+ if (!inserted || field.value.slice(start, start + text.length) !== text) {
117
+ const next = field.value.slice(0, start) + text + field.value.slice(end);
118
+ const proto = field instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
119
+ Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(field, next);
120
+ field.dispatchEvent(new Event("input", { bubbles: true }));
121
+ }
122
+ const caret = start + text.length;
123
+ field.setSelectionRange(caret, caret);
124
+ }
125
+ var MIRRORED = [
126
+ "boxSizing",
127
+ "width",
128
+ "borderTopWidth",
129
+ "borderRightWidth",
130
+ "borderBottomWidth",
131
+ "borderLeftWidth",
132
+ "paddingTop",
133
+ "paddingRight",
134
+ "paddingBottom",
135
+ "paddingLeft",
136
+ "fontFamily",
137
+ "fontSize",
138
+ "fontStyle",
139
+ "fontVariant",
140
+ "fontWeight",
141
+ "fontStretch",
142
+ "fontFeatureSettings",
143
+ "lineHeight",
144
+ "letterSpacing",
145
+ "wordSpacing",
146
+ "textIndent",
147
+ "textTransform",
148
+ "tabSize",
149
+ "direction"
150
+ ];
151
+ function caretRect(field, index) {
152
+ const doc = field.ownerDocument;
153
+ const view = doc.defaultView ?? window;
154
+ const style = view.getComputedStyle(field);
155
+ const mirror = doc.createElement("div");
156
+ const s = mirror.style;
157
+ for (const prop of MIRRORED) s[prop] = style[prop];
158
+ s.position = "absolute";
159
+ s.visibility = "hidden";
160
+ s.top = "0";
161
+ s.left = "-9999px";
162
+ s.overflow = "hidden";
163
+ const multiline = field instanceof HTMLTextAreaElement;
164
+ s.whiteSpace = multiline ? "pre-wrap" : "pre";
165
+ s.overflowWrap = multiline ? "break-word" : "normal";
166
+ mirror.textContent = field.value.slice(0, index);
167
+ const marker = doc.createElement("span");
168
+ marker.textContent = field.value.slice(index) || "\u200B";
169
+ mirror.appendChild(marker);
170
+ doc.body.appendChild(mirror);
171
+ const box = field.getBoundingClientRect();
172
+ const lineHeight = Number.parseFloat(style.lineHeight) || marker.offsetHeight;
173
+ const rect = new DOMRect(
174
+ box.left + marker.offsetLeft - field.scrollLeft,
175
+ box.top + marker.offsetTop - field.scrollTop,
176
+ 1,
177
+ lineHeight
178
+ );
179
+ mirror.remove();
180
+ return rect;
181
+ }
182
+ export {
183
+ caretRect,
184
+ findMatch,
185
+ matchAt,
186
+ useCaretTrigger
187
+ };
@@ -11,7 +11,12 @@ function useCopy(text, { timeout = 1500 } = {}) {
11
11
  const copy = useCallback(async () => {
12
12
  if (typeof navigator === "undefined" || !navigator.clipboard) return false;
13
13
  try {
14
- await navigator.clipboard.writeText(text);
14
+ const value = typeof text === "function" ? text() : text;
15
+ if (typeof value === "string") await navigator.clipboard.writeText(value);
16
+ else if (typeof ClipboardItem !== "undefined" && navigator.clipboard.write) {
17
+ const blob = value.then((t) => new Blob([t], { type: "text/plain" }));
18
+ await navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })]);
19
+ } else await navigator.clipboard.writeText(await value);
15
20
  } catch {
16
21
  return false;
17
22
  }
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ // src/lib/use-smooth-wheel.ts
4
+ import { useCallback, useEffect, useRef } from "react";
5
+ import { useMotion } from "../motion/MotionProvider.js";
6
+ var LINE_HEIGHT = 16;
7
+ var PAGE_HEIGHT = 400;
8
+ function toPixels(delta, mode) {
9
+ if (mode === 1) return delta * LINE_HEIGHT;
10
+ if (mode === 2) return delta * PAGE_HEIGHT;
11
+ return delta;
12
+ }
13
+ function isNotch(event) {
14
+ if (event.deltaMode !== 0) return true;
15
+ const legacy = event.wheelDeltaY;
16
+ if (legacy) return event.deltaX === 0 && legacy % 120 === 0;
17
+ return event.deltaX === 0 && Number.isInteger(event.deltaY) && Math.abs(event.deltaY) >= 50;
18
+ }
19
+ function innerScrolls(target, region, deltaY) {
20
+ for (let el = target instanceof Element ? target : null; el && el !== region; el = el.parentElement) {
21
+ if (!(el instanceof HTMLElement) || el.scrollHeight <= el.clientHeight) continue;
22
+ const overflow = getComputedStyle(el).overflowY;
23
+ if (overflow !== "auto" && overflow !== "scroll") continue;
24
+ const max = el.scrollHeight - el.clientHeight;
25
+ if (deltaY < 0 ? Math.round(el.scrollTop) > 0 : Math.round(el.scrollTop) < max) return true;
26
+ }
27
+ return false;
28
+ }
29
+ function cssOwnsMotion(el) {
30
+ const style = getComputedStyle(el);
31
+ return style.scrollBehavior === "smooth" || style.scrollSnapType !== "" && style.scrollSnapType !== "none";
32
+ }
33
+ var EASE_PER_FRAME = 0.14;
34
+ var FRAME_MS = 1e3 / 60;
35
+ var SETTLE_PX = 0.5;
36
+ function useSmoothWheel({
37
+ axis = "vertical",
38
+ mapVertical = true,
39
+ smooth = true
40
+ } = {}) {
41
+ const { enabled: motion } = useMotion();
42
+ const settings = useRef({ axis, mapVertical, smooth: smooth && motion });
43
+ settings.current = { axis, mapVertical, smooth: smooth && motion };
44
+ const horizontal = axis === "horizontal";
45
+ const active = smooth && motion || horizontal && mapVertical;
46
+ const nodeRef = useRef(null);
47
+ const anim = useRef(null);
48
+ const stop = useCallback(() => {
49
+ if (anim.current) cancelAnimationFrame(anim.current.frame);
50
+ anim.current = null;
51
+ }, []);
52
+ const step = useCallback((now) => {
53
+ const el = nodeRef.current;
54
+ const a = anim.current;
55
+ if (!el || !a) return;
56
+ const key = settings.current.axis === "horizontal" ? "scrollLeft" : "scrollTop";
57
+ const dt = Math.min(64, now - a.last);
58
+ a.last = now;
59
+ a.pos += (a.target - a.pos) * (1 - (1 - EASE_PER_FRAME) ** (dt / FRAME_MS));
60
+ if (Math.abs(a.target - a.pos) < SETTLE_PX) a.pos = a.target;
61
+ el[key] = a.pos;
62
+ a.written = el[key];
63
+ if (a.pos === a.target) anim.current = null;
64
+ else a.frame = requestAnimationFrame(step);
65
+ }, []);
66
+ const onWheel = useCallback(
67
+ (event) => {
68
+ const el = nodeRef.current;
69
+ if (!el || event.ctrlKey) return;
70
+ const { axis: current, mapVertical: map, smooth: ease } = settings.current;
71
+ const sideways = current === "horizontal";
72
+ if (event.shiftKey) return;
73
+ if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.deltaY === 0) return;
74
+ if (sideways && !map) return;
75
+ if (innerScrolls(event.target, el, event.deltaY)) return;
76
+ const notch = ease && isNotch(event) && !cssOwnsMotion(el);
77
+ if (!sideways && !notch) return;
78
+ const key = sideways ? "scrollLeft" : "scrollTop";
79
+ const max = sideways ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
80
+ if (max <= 0) return;
81
+ const delta = toPixels(event.deltaY, event.deltaMode);
82
+ const from = notch && anim.current ? anim.current.target : el[key];
83
+ if (delta < 0 && Math.round(from) <= 0 || delta > 0 && Math.round(from) >= max) return;
84
+ event.preventDefault();
85
+ const target = Math.max(0, Math.min(max, from + delta));
86
+ if (!notch) {
87
+ stop();
88
+ el[key] = target;
89
+ return;
90
+ }
91
+ if (anim.current) {
92
+ anim.current.target = target;
93
+ return;
94
+ }
95
+ anim.current = {
96
+ target,
97
+ pos: el[key],
98
+ written: el[key],
99
+ last: performance.now(),
100
+ frame: requestAnimationFrame(step)
101
+ };
102
+ },
103
+ [step, stop]
104
+ );
105
+ const onScroll = useCallback(() => {
106
+ const el = nodeRef.current;
107
+ const a = anim.current;
108
+ if (!el || !a) return;
109
+ const key = settings.current.axis === "horizontal" ? "scrollLeft" : "scrollTop";
110
+ if (Math.abs(el[key] - a.written) > 1) stop();
111
+ }, [stop]);
112
+ const activeRef = useRef(active);
113
+ activeRef.current = active;
114
+ const attached = useRef(null);
115
+ const sync = useCallback(() => {
116
+ const want = activeRef.current ? nodeRef.current : null;
117
+ if (attached.current === want) return;
118
+ const was = attached.current;
119
+ if (was) {
120
+ was.removeEventListener("wheel", onWheel);
121
+ was.removeEventListener("scroll", onScroll);
122
+ was.removeEventListener("pointerdown", stop);
123
+ stop();
124
+ }
125
+ attached.current = want;
126
+ if (want) {
127
+ want.addEventListener("wheel", onWheel, { passive: false });
128
+ want.addEventListener("scroll", onScroll, { passive: true });
129
+ want.addEventListener("pointerdown", stop, { passive: true });
130
+ }
131
+ }, [onWheel, onScroll, stop]);
132
+ useEffect(sync, [active, sync]);
133
+ return useCallback(
134
+ (node) => {
135
+ nodeRef.current = node;
136
+ sync();
137
+ },
138
+ [sync]
139
+ );
140
+ }
141
+ function scrollRegionKeyDown(event, orientation) {
142
+ const el = event.currentTarget;
143
+ const horizontal = orientation === "horizontal";
144
+ const page = horizontal ? el.clientWidth : el.clientHeight;
145
+ const step = Math.max(40, page * 0.2);
146
+ const back = horizontal ? "ArrowLeft" : "ArrowUp";
147
+ const forward = horizontal ? "ArrowRight" : "ArrowDown";
148
+ const axis = horizontal ? "scrollLeft" : "scrollTop";
149
+ const max = horizontal ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
150
+ let next = null;
151
+ if (event.key === back) next = el[axis] - step;
152
+ else if (event.key === forward) next = el[axis] + step;
153
+ else if (event.key === "Home") next = 0;
154
+ else if (event.key === "End") next = max;
155
+ else if (event.key === "PageUp") next = el[axis] - page;
156
+ else if (event.key === "PageDown") next = el[axis] + page;
157
+ if (next === null) return;
158
+ event.preventDefault();
159
+ el[axis] = Math.max(0, Math.min(max, next));
160
+ }
161
+ export {
162
+ scrollRegionKeyDown,
163
+ useSmoothWheel
164
+ };
@@ -145,7 +145,7 @@ function ChatPanel() {
145
145
  onStop: () => void 0,
146
146
  stopLabel: "Stop answering",
147
147
  onAttach: () => void 0,
148
- footer: "Enter to send \xB7 Shift+Enter for a new line",
148
+ footer: ({ sendOn }) => sendOn === "enter" ? "Enter to send \xB7 Shift+Enter for a new line" : null,
149
149
  actions: /* @__PURE__ */ jsxs(
150
150
  SettingsMenu,
151
151
  {
@@ -4,6 +4,7 @@ var chatPanelMeta = {
4
4
  summary: "A full-height conversation surface: titled header, scrolling transcript, and a composer pinned to the bottom \u2014 the assembled shape of an AI assistant, a support thread, or a DM.",
5
5
  intent: "Reach for this for any conversation that owns its screen \u2014 an assistant, a support inbox thread, a DM. Not for a chat docked beside other work (compose the same three parts inside a Sheet or a PanelGroup instead), and not for a comment thread under an article, which is a FeedPage concern.",
6
6
  notes: [
7
+ "This is the assembled screen with sample data. To wire it to a real streaming source (a hook such as useChat, or your own transport), read `sable docs building-a-chat`: who owns what, the turn lifecycle mapped to props, and every failure state.",
7
8
  "The panel is a fixed-height column, not a growing page. `h-[calc(100dvh_-_--spacing(12))]` on the Card (the viewport minus the Page's py-6, in spacing units so density moves both together \u2014 a fixed `3rem` drifts the moment density moves the padding) and `flex-1 min-h-0` on the MessageScroller are what make the transcript scroll instead of the document \u2014 the single most common thing to get wrong here, and it fails by pushing the composer off the bottom of the screen.",
8
9
  "The panel is a Card, not a hand-built bordered section: CardHeader (title, description, CardAction), the MessageScroller and a CardFooter all inset by the card's own `--card-p`, so the three regions share one edge at any card density.",
9
10
  "`dvh` rather than `vh`: on mobile the URL bar collapses, and a `vh`-sized panel puts the composer under it exactly when someone is trying to type.",
package/dist/styles.css CHANGED
@@ -1006,6 +1006,31 @@
1006
1006
  margin-inline: calc(-1 * max(--spacing(1), calc(var(--radius-md) / 3)));
1007
1007
  }
1008
1008
 
1009
+ /* ButtonGroup's joins. Keyed to sibling Buttons (`data-variant`), never to
1010
+ :first/:last-child: an open Menu inserts focus-guard spans beside its
1011
+ trigger, and a position rule then read the chevron as "not last" and
1012
+ squared its outer corner for as long as the menu was open. Inner corners
1013
+ square through the per-side radius vars Button reads; bordered buttons
1014
+ overlap by their hairline; filled and ghost ones get a seam in their own
1015
+ ink, except while focused, where the shadow is the focus ring. */
1016
+ @utility button-group-join {
1017
+ & > [data-variant] ~ [data-variant] {
1018
+ --button-radius-s: 0px;
1019
+ }
1020
+ & > [data-variant]:has(~ [data-variant]) {
1021
+ --button-radius-e: 0px;
1022
+ }
1023
+ & > [data-variant] ~ [data-variant="outline"] {
1024
+ margin-inline-start: -1px;
1025
+ }
1026
+ & > [data-variant] ~ [data-variant]:not([data-variant="outline"], :focus-visible) {
1027
+ box-shadow: inset 1px 0 0 color-mix(in oklab, currentColor 20%, transparent);
1028
+ }
1029
+ & > :focus-visible {
1030
+ z-index: 1;
1031
+ }
1032
+ }
1033
+
1009
1034
  @utility sable-frame {
1010
1035
  --sable-frame-radius: var(--radius-lg);
1011
1036
  --sable-frame-padding: --spacing(4);
@@ -36,7 +36,7 @@ Use AppHeader for compact application chrome at the top of a screen, usually wit
36
36
 
37
37
  ## Related
38
38
 
39
- - Components: AppShell, PageHeader, BottomNav
39
+ - Components: AppShell, PageHeader, BottomNav, NavigationMenu
40
40
 
41
41
  ## Examples
42
42
 
@@ -0,0 +1,55 @@
1
+ # ButtonGroup
2
+
3
+ **Status:** stable
4
+
5
+ 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.
6
+
7
+ **Keywords:** split button, button group, segmented actions, dropdown button, joined buttons, toolbar
8
+
9
+ ## Usage
10
+
11
+ Use ButtonGroup when one action has close alternatives (Copy page, with Open in… 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.
12
+
13
+ ### Best practices
14
+
15
+ - Do: 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.
16
+ - Do: 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
+ - Do: Name the group with aria-label when the buttons alone do not say what they act on.
18
+ - Don't: 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.
19
+ - Don't: Do not use it for a selection that stays on. That is ToggleGroup, which carries the pressed state.
20
+
21
+ ## Props
22
+
23
+ | Name | Type | Default | Description |
24
+ | --- | --- | --- | --- |
25
+ | role | string | "group" | Group semantics, so a screen reader announces the buttons as one set. Pass `toolbar` when it carries arrow-key navigation of your own. |
26
+ | className | string | — | Layout classes for the group (margins, alignment). The children's look comes from their own variants. |
27
+ | ...props | ComponentPropsWithRef<"div"> | — | All native <div> attributes, including aria-label. |
28
+
29
+ ## Accessibility
30
+
31
+ - Renders role="group", so assistive tech announces the buttons as one set; name it with aria-label.
32
+ - Each child keeps its own focus stop and label; a focused child is raised above its neighbour so its ring is drawn whole.
33
+ - An icon-only chevron that opens a menu needs its own aria-label, such as "More page actions".
34
+
35
+ ## Related
36
+
37
+ - Components: Button, Menu, Toggle
38
+
39
+ ## Examples
40
+
41
+ ### Split button with a menu
42
+
43
+ ```tsx
44
+ import { Button, ButtonGroup, Icon, Menu, MenuContent, MenuItem, MenuTrigger } from "@sableui/react"
45
+
46
+ <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>
47
+ ```
48
+
49
+ ### Related actions
50
+
51
+ ```tsx
52
+ import { Button, ButtonGroup, Icon } from "@sableui/react"
53
+
54
+ <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>
55
+ ```
@@ -13,7 +13,7 @@ Use Button when the user commits an action, submits a form, opens UI, or confirm
13
13
  ### Best practices
14
14
 
15
15
  - Do: Use a single primary button in a view or dense panel so the action hierarchy stays obvious.
16
- - Do: 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`.
16
+ - Do: 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.
17
17
  - Do: Write labels as actions, such as "Save changes" or "Delete draft", instead of vague labels like "OK".
18
18
  - Do: Use loading with loadingLabel for async work so the action cannot double-fire and the state is announced.
19
19
  - Do: Put icons in iconStart / iconEnd rather than as children, so the button applies its optical padding. Start icons say what the action is (plus, share); end icons say where it goes (chevron-right, external).