@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
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.11.0";
175
+ var SABLE_UI_VERSION = "0.13.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
  };
@@ -4,7 +4,9 @@ var PALETTE_FAMILIES = [
4
4
  { name: "red", hue: 25, light: { l: 0.6, c: 0.21 }, dark: { l: 0.64, c: 0.2 } },
5
5
  { name: "orange", hue: 50, light: { l: 0.69, c: 0.18 }, dark: { l: 0.71, c: 0.17 } },
6
6
  // Yellow is only yellow when it is light; at 0.6 it is olive.
7
- { name: "yellow", hue: 92, light: { l: 0.83, c: 0.16 }, dark: { l: 0.82, c: 0.15 } },
7
+ // In dark mode it sits a little deeper, or 800–950 crowd into the room
8
+ // left between it and the ink.
9
+ { name: "yellow", hue: 92, light: { l: 0.83, c: 0.16 }, dark: { l: 0.78, c: 0.15 } },
8
10
  { name: "green", hue: 145, light: { l: 0.64, c: 0.17 }, dark: { l: 0.68, c: 0.16 } },
9
11
  { name: "teal", hue: 190, light: { l: 0.7, c: 0.12 }, dark: { l: 0.74, c: 0.12 } },
10
12
  { name: "blue", hue: 262, light: { l: 0.6, c: 0.19 }, dark: { l: 0.64, c: 0.17 } },
@@ -69,10 +71,15 @@ var NEUTRAL_MIX = {
69
71
  900: 90,
70
72
  950: 95
71
73
  };
72
- var TONE_CHROMA_CAP = {
73
- light: { 50: 0.02, 100: 0.04, 200: 0.07, 300: 0.11 },
74
- dark: { 50: 0.035, 100: 0.05, 200: 0.07, 300: 0.1 }
74
+ var TONE_LADDER = {
75
+ light: { 50: 0.975, 100: 0.945, 200: 0.895, 300: 0.82, 400: 0.725, 500: 0.63, 600: 0.55, 700: 0.47, 800: 0.4, 900: 0.33, 950: 0.27 },
76
+ dark: { 50: 0.215, 100: 0.25, 200: 0.31, 300: 0.39, 400: 0.5, 500: 0.6, 600: 0.69, 700: 0.77, 800: 0.84, 900: 0.895, 950: 0.94 }
75
77
  };
78
+ var TONE_CHROMA_ENDS = {
79
+ light: { page: 0.12, ink: 0.52 },
80
+ dark: { page: 0.25, ink: 0.3 }
81
+ };
82
+ var TONE_SNAP = 1e4;
76
83
  var LEAN = { amount: 0.5, cap: 15, fullAt: 0.06, maxHarmony: 2 };
77
84
  var LEAN_REACH = LEAN.cap * LEAN.maxHarmony;
78
85
  var clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
@@ -85,6 +92,34 @@ function stepFrom(anchor, step, mode) {
85
92
  const curve = STEP_CURVES[mode][step];
86
93
  return { l: anchor.l + (curve.target - anchor.l) * curve.pull, c: anchor.c * curve.chroma };
87
94
  }
95
+ function ladderDepth(l, mode) {
96
+ const top = TONE_LADDER[mode][50];
97
+ return mode === "light" ? top - l : l - top;
98
+ }
99
+ function toneAnchor(l, mode) {
100
+ const ladder = TONE_LADDER[mode];
101
+ let depth = 0;
102
+ for (let i = 1; i < PALETTE_STEPS.length; i++) {
103
+ const [before, at] = [ladder[PALETTE_STEPS[i - 1]], ladder[PALETTE_STEPS[i]]];
104
+ const mid = (before + at) / 2;
105
+ depth += Math.abs(at - before) * clamp((mode === "light" ? mid - l : l - mid) * TONE_SNAP, 0, 1);
106
+ }
107
+ return depth;
108
+ }
109
+ function toneStep(tone, step, mode) {
110
+ const end = ladderDepth(TONE_LADDER[mode][950], mode);
111
+ const at = ladderDepth(TONE_LADDER[mode][step], mode);
112
+ const anchor = toneAnchor(tone.l, mode);
113
+ const own = ladderDepth(tone.l, mode);
114
+ const pageSide = clamp((anchor - at) * TONE_SNAP, 0, 1);
115
+ const towardPage = at * own / Math.max(anchor, 1e-3);
116
+ const towardInk = own + (at - anchor) * (end - own) / Math.max(end - anchor, 1e-3);
117
+ const depth = pageSide * towardPage + (1 - pageSide) * towardInk;
118
+ const ends = TONE_CHROMA_ENDS[mode];
119
+ const share = pageSide * (1 - (1 - ends.page) * ((anchor - at) / Math.max(anchor, 1e-3))) + (1 - pageSide) * (1 - (1 - ends.ink) * ((at - anchor) / Math.max(end - anchor, 1e-3)));
120
+ const top = TONE_LADDER[mode][50];
121
+ return { l: mode === "light" ? top - depth : top + depth, c: tone.c * share, anchor: Math.abs(at - anchor) * TONE_SNAP < 1 };
122
+ }
88
123
  export {
89
124
  LEAN,
90
125
  LEAN_REACH,
@@ -96,8 +131,13 @@ export {
96
131
  PALETTE_STEPS,
97
132
  PALETTE_TONES,
98
133
  STEP_CURVES,
99
- TONE_CHROMA_CAP,
134
+ TONE_CHROMA_ENDS,
135
+ TONE_LADDER,
136
+ TONE_SNAP,
100
137
  familyHue,
101
138
  isPaletteColor,
102
- stepFrom
139
+ ladderDepth,
140
+ stepFrom,
141
+ toneAnchor,
142
+ toneStep
103
143
  };
@@ -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
+ };