@sableui/react 0.13.2 → 0.15.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 (203) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +172 -0
  3. package/CONTRIBUTING.md +6 -4
  4. package/DESIGN.md +10 -3
  5. package/dist/blocks/approval-block.js +93 -0
  6. package/dist/blocks/approval-block.meta.js +23 -0
  7. package/dist/blocks/index.d.ts +27 -1
  8. package/dist/blocks/index.js +8 -0
  9. package/dist/blocks/message-reactions-block.js +87 -0
  10. package/dist/blocks/message-reactions-block.meta.js +23 -0
  11. package/dist/components/accordion/accordion.meta.js +8 -2
  12. package/dist/components/area-chart/area-chart.meta.js +10 -3
  13. package/dist/components/attachment/attachment.meta.js +26 -0
  14. package/dist/components/bar-chart/bar-chart.meta.js +11 -4
  15. package/dist/components/bottom-nav/bottom-nav.js +3 -1
  16. package/dist/components/bottom-nav/bottom-nav.meta.js +32 -0
  17. package/dist/components/breadcrumbs/breadcrumbs.meta.js +26 -0
  18. package/dist/components/bubble/bubble.js +105 -5
  19. package/dist/components/bubble/bubble.meta.js +42 -6
  20. package/dist/components/bubble/bubble.variants.js +1 -1
  21. package/dist/components/bubble/index.js +2 -0
  22. package/dist/components/button/button.js +21 -4
  23. package/dist/components/button/button.meta.js +7 -7
  24. package/dist/components/calendar/calendar.js +18 -1
  25. package/dist/components/calendar/calendar.meta.js +38 -0
  26. package/dist/components/carousel/carousel.js +6 -4
  27. package/dist/components/carousel/carousel.meta.js +42 -0
  28. package/dist/components/chat-composer/chat-composer.js +21 -21
  29. package/dist/components/chat-composer/chat-composer.meta.js +38 -0
  30. package/dist/components/chat-composer/use-eased-growth.js +70 -0
  31. package/dist/components/checkbox/checkbox-mark.js +8 -1
  32. package/dist/components/checkbox/checkbox.js +3 -1
  33. package/dist/components/checkbox/checkbox.meta.js +32 -0
  34. package/dist/components/checkbox-card/checkbox-card.js +3 -0
  35. package/dist/components/checkbox-card/checkbox-card.meta.js +32 -0
  36. package/dist/components/code-block/code-block.js +14 -3
  37. package/dist/components/code-block/code-block.meta.js +28 -1
  38. package/dist/components/collapsible/collapsible.meta.js +32 -0
  39. package/dist/components/color-field/color-field.meta.js +26 -0
  40. package/dist/components/combobox/combobox.meta.js +44 -0
  41. package/dist/components/command-menu/command-menu.js +8 -3
  42. package/dist/components/command-menu/command-menu.meta.js +38 -0
  43. package/dist/components/confirm-button/confirm-button.js +179 -0
  44. package/dist/components/confirm-button/confirm-button.meta.js +143 -0
  45. package/dist/components/confirm-button/index.js +7 -0
  46. package/dist/components/data-table/data-table.js +6 -2
  47. package/dist/components/data-table/data-table.meta.js +32 -0
  48. package/dist/components/entity/entity.meta.js +26 -0
  49. package/dist/components/feedback/feedback.js +120 -47
  50. package/dist/components/feedback/feedback.meta.js +55 -4
  51. package/dist/components/input-group/input-group.meta.js +27 -1
  52. package/dist/components/like-button/index.js +7 -0
  53. package/dist/components/like-button/like-button.js +89 -0
  54. package/dist/components/like-button/like-button.meta.js +132 -0
  55. package/dist/components/line-chart/line-chart.meta.js +10 -3
  56. package/dist/components/middle-truncate/middle-truncate.meta.js +1 -1
  57. package/dist/components/note/note.meta.js +32 -0
  58. package/dist/components/notification-badge/notification-badge.js +17 -5
  59. package/dist/components/notification-badge/notification-badge.meta.js +34 -2
  60. package/dist/components/number-ticker/index.js +7 -0
  61. package/dist/components/number-ticker/number-ticker.js +246 -0
  62. package/dist/components/number-ticker/number-ticker.meta.js +159 -0
  63. package/dist/components/otp-field/otp-field.js +23 -3
  64. package/dist/components/otp-field/otp-field.meta.js +23 -2
  65. package/dist/components/panel-group/panel-group.meta.js +26 -0
  66. package/dist/components/panel-section/panel-section.js +37 -10
  67. package/dist/components/panel-section/panel-section.meta.js +32 -0
  68. package/dist/components/pie-chart/pie-chart.js +20 -11
  69. package/dist/components/pie-chart/pie-chart.meta.js +17 -4
  70. package/dist/components/pill/pill.meta.js +32 -0
  71. package/dist/components/radar-chart/radar-chart.js +4 -3
  72. package/dist/components/radar-chart/radar-chart.meta.js +10 -3
  73. package/dist/components/radial-chart/radial-chart.js +22 -22
  74. package/dist/components/radial-chart/radial-chart.meta.js +17 -4
  75. package/dist/components/radio/radio.meta.js +26 -0
  76. package/dist/components/radio-card/radio-card.meta.js +38 -0
  77. package/dist/components/reaction-picker/index.js +13 -0
  78. package/dist/components/reaction-picker/reaction-picker.js +104 -0
  79. package/dist/components/reaction-picker/reaction-picker.meta.js +156 -0
  80. package/dist/components/relative-time/relative-time.meta.js +32 -0
  81. package/dist/components/scroller/scroller.meta.js +32 -0
  82. package/dist/components/scrub-field/scrub-field.meta.js +32 -0
  83. package/dist/components/search-input/search-input.js +9 -7
  84. package/dist/components/search-input/search-input.meta.js +26 -0
  85. package/dist/components/show-more/show-more.js +1 -4
  86. package/dist/components/show-more/show-more.meta.js +38 -0
  87. package/dist/components/side-nav/side-nav.js +21 -1
  88. package/dist/components/side-nav/side-nav.meta.js +32 -0
  89. package/dist/components/side-nav/side-nav.variants.js +3 -1
  90. package/dist/components/sidebar/sidebar.js +31 -8
  91. package/dist/components/sidebar/sidebar.meta.js +38 -0
  92. package/dist/components/slider/slider.meta.js +26 -0
  93. package/dist/components/sparkline/sparkline.js +7 -4
  94. package/dist/components/sparkline/sparkline.meta.js +9 -2
  95. package/dist/components/table/table.meta.js +26 -0
  96. package/dist/components/tabs/tabs.js +27 -8
  97. package/dist/components/tabs/tabs.meta.js +46 -3
  98. package/dist/components/theme-selector/theme-selector.meta.js +32 -0
  99. package/dist/components/theme-switcher/theme-switcher.js +4 -1
  100. package/dist/components/theme-switcher/theme-switcher.meta.js +42 -0
  101. package/dist/components/toast/toast.js +25 -13
  102. package/dist/components/toast/toast.meta.js +7 -1
  103. package/dist/components/toggle/toggle.js +28 -5
  104. package/dist/components/toggle/toggle.meta.js +44 -2
  105. package/dist/components/tool-call/index.js +8 -0
  106. package/dist/components/tool-call/tool-call.js +117 -0
  107. package/dist/components/tool-call/tool-call.meta.js +147 -0
  108. package/dist/components/tool-call/tool-call.variants.js +33 -0
  109. package/dist/components/tooltip/index.js +3 -1
  110. package/dist/components/tooltip/tooltip.js +84 -0
  111. package/dist/components/tooltip/tooltip.meta.js +16 -1
  112. package/dist/components/tree/tree.js +71 -6
  113. package/dist/components/tree/tree.meta.js +32 -0
  114. package/dist/components/video/video.meta.js +32 -0
  115. package/dist/file-hashes.json +130 -104
  116. package/dist/foundations/motion.js +69 -3
  117. package/dist/icon-sets/reicon.js +11 -1
  118. package/dist/index.d.ts +907 -104
  119. package/dist/index.js +6 -1
  120. package/dist/lib/chart/cartesian.js +9 -10
  121. package/dist/lib/chart/chart-center.js +30 -0
  122. package/dist/lib/chart/chart-frame.js +15 -8
  123. package/dist/lib/chart/dim.js +13 -0
  124. package/dist/lib/chart/format.js +7 -1
  125. package/dist/lib/chart/motion.js +6 -4
  126. package/dist/lib/highlight-glide.js +68 -10
  127. package/dist/lib/taxonomy.js +5 -0
  128. package/dist/motion/Burst.js +79 -0
  129. package/dist/motion/MotionProvider.js +45 -3
  130. package/dist/motion/Reveal.js +1 -1
  131. package/dist/motion/TextReveal.js +1 -1
  132. package/dist/motion/index.js +17 -2
  133. package/dist/motion/useAutoSize.js +42 -0
  134. package/dist/motion/useShake.js +25 -0
  135. package/dist/styles.css +439 -5
  136. package/llms/blocks/approval-block.md +128 -0
  137. package/llms/blocks/message-reactions-block.md +116 -0
  138. package/llms/components/accordion.md +3 -2
  139. package/llms/components/area-chart.md +4 -3
  140. package/llms/components/attachment.md +7 -0
  141. package/llms/components/bar-chart.md +5 -4
  142. package/llms/components/bottom-nav.md +8 -0
  143. package/llms/components/breadcrumbs.md +7 -0
  144. package/llms/components/bubble.md +17 -7
  145. package/llms/components/button.md +6 -6
  146. package/llms/components/calendar.md +9 -0
  147. package/llms/components/carousel.md +17 -0
  148. package/llms/components/chat-composer.md +9 -0
  149. package/llms/components/checkbox-card.md +8 -0
  150. package/llms/components/checkbox.md +8 -0
  151. package/llms/components/code-block.md +8 -0
  152. package/llms/components/collapsible.md +8 -0
  153. package/llms/components/color-field.md +7 -0
  154. package/llms/components/combobox.md +10 -0
  155. package/llms/components/command-menu.md +9 -0
  156. package/llms/components/confirm-button.md +92 -0
  157. package/llms/components/data-table.md +8 -0
  158. package/llms/components/entity.md +7 -0
  159. package/llms/components/feedback.md +23 -3
  160. package/llms/components/input-group.md +8 -1
  161. package/llms/components/like-button.md +91 -0
  162. package/llms/components/line-chart.md +4 -3
  163. package/llms/components/middle-truncate.md +1 -1
  164. package/llms/components/note.md +8 -0
  165. package/llms/components/notification-badge.md +10 -2
  166. package/llms/components/number-ticker.md +86 -0
  167. package/llms/components/otp-field.md +15 -1
  168. package/llms/components/panel-group.md +7 -0
  169. package/llms/components/panel-section.md +8 -0
  170. package/llms/components/pie-chart.md +6 -4
  171. package/llms/components/pill.md +8 -0
  172. package/llms/components/radar-chart.md +4 -3
  173. package/llms/components/radial-chart.md +6 -4
  174. package/llms/components/radio-card.md +9 -0
  175. package/llms/components/radio.md +7 -0
  176. package/llms/components/reaction-picker.md +115 -0
  177. package/llms/components/relative-time.md +8 -0
  178. package/llms/components/scroller.md +8 -0
  179. package/llms/components/scrub-field.md +8 -0
  180. package/llms/components/search-input.md +7 -0
  181. package/llms/components/show-more.md +9 -0
  182. package/llms/components/side-nav.md +8 -0
  183. package/llms/components/sidebar.md +9 -0
  184. package/llms/components/slider.md +7 -0
  185. package/llms/components/sparkline.md +3 -2
  186. package/llms/components/table.md +7 -0
  187. package/llms/components/tabs.md +20 -2
  188. package/llms/components/theme-selector.md +8 -0
  189. package/llms/components/theme-switcher.md +17 -0
  190. package/llms/components/toast.md +2 -1
  191. package/llms/components/toggle.md +19 -2
  192. package/llms/components/tool-call.md +115 -0
  193. package/llms/components/tooltip.md +11 -1
  194. package/llms/components/tree.md +8 -0
  195. package/llms/components/video.md +8 -0
  196. package/llms/foundations/motion.md +64 -3
  197. package/llms/reference/utilities.md +26 -1
  198. package/llms/tasks/reviewing-motion.md +14 -0
  199. package/llms-full.txt +1337 -69
  200. package/llms.txt +19 -11
  201. package/package.json +1 -1
  202. package/registry.json +3040 -124
  203. package/targets/ios.json +45 -0
@@ -85,6 +85,38 @@ var noteMeta = {
85
85
  "success",
86
86
  "destructive"
87
87
  ],
88
+ motion: [
89
+ {
90
+ slot: "arrival",
91
+ recipe: "none",
92
+ part: "Root",
93
+ why: "A note is part of the content it annotates and renders with it. A callout that animated in would pull the eye before the reader has reached it in the flow."
94
+ },
95
+ {
96
+ slot: "response",
97
+ recipe: "motion-interactive",
98
+ part: "Action",
99
+ why: "The action's focus ring eases in, and its underline thickens on hover. Inside a block of guidance, that is what separates the one thing to do from the prose around it."
100
+ },
101
+ {
102
+ slot: "response",
103
+ recipe: "motion-press",
104
+ part: "Action",
105
+ why: "A note's action usually navigates or opens something; the small settle on press is the only confirmation before the next surface paints."
106
+ },
107
+ {
108
+ slot: "transition",
109
+ recipe: "none",
110
+ part: "Root",
111
+ why: "A note has no states to move between: its tone is fixed when it renders. A note that changes tone is a new message, and should arrive as one."
112
+ },
113
+ {
114
+ slot: "activity",
115
+ recipe: "none",
116
+ part: "Root",
117
+ why: "Static guidance. Work in progress belongs in a Progress or a Spinner, not in a callout."
118
+ }
119
+ ],
88
120
  a11y: [
89
121
  'Rendered with role="note" so assistive tech can announce it as an aside.',
90
122
  "Color is never the sole signal \u2014 the leading icon and optional title reinforce severity."
@@ -1,6 +1,10 @@
1
+ "use client";
2
+
1
3
  // src/components/notification-badge/notification-badge.tsx
4
+ import { useState } from "react";
2
5
  import { cn } from "../../lib/cn.js";
3
- import { trimLabel } from "../../lib/trim-label.js";
6
+ import { motionAttribute } from "../../motion/MotionProvider.js";
7
+ import { NumberTicker } from "../number-ticker/number-ticker.js";
4
8
  import {
5
9
  notificationCountVariants,
6
10
  notificationDotVariants
@@ -13,16 +17,22 @@ function NotificationBadge({
13
17
  tone,
14
18
  size,
15
19
  overlay = false,
20
+ motion,
16
21
  className,
17
22
  ...props
18
23
  }) {
24
+ const empty = !dot && (count === void 0 || count <= 0);
25
+ const [wasEmpty, setWasEmpty] = useState(empty);
26
+ if (empty && !wasEmpty) setWasEmpty(true);
27
+ const appear = wasEmpty && "motion-appear";
19
28
  if (dot) {
20
29
  return /* @__PURE__ */ jsx(
21
30
  "span",
22
31
  {
23
32
  "data-slot": "notification-badge",
33
+ "data-motion": motionAttribute(motion),
24
34
  "aria-hidden": true,
25
- className: cn(notificationDotVariants({ tone, size, overlay }), className),
35
+ className: cn(notificationDotVariants({ tone, size, overlay }), appear, className),
26
36
  ...props
27
37
  }
28
38
  );
@@ -30,18 +40,20 @@ function NotificationBadge({
30
40
  if (count === void 0 || count <= 0) {
31
41
  return null;
32
42
  }
33
- const label = count > max ? `${max}+` : String(count);
43
+ const over = count > max;
34
44
  return /* @__PURE__ */ jsx(
35
45
  "span",
36
46
  {
37
47
  "data-slot": "notification-badge",
48
+ "data-motion": motionAttribute(motion),
38
49
  "aria-hidden": true,
39
- className: cn(notificationCountVariants({ tone, size, overlay }), className),
50
+ className: cn(notificationCountVariants({ tone, size, overlay }), appear, className),
40
51
  ...props,
41
- children: trimLabel(label)
52
+ children: /* @__PURE__ */ jsx("span", { className: "sable-label-trim", children: /* @__PURE__ */ jsx(NumberTicker, { value: over ? max : count, suffix: over ? "+" : void 0, format: NO_GROUPING, motion }) })
42
53
  }
43
54
  );
44
55
  }
56
+ var NO_GROUPING = { useGrouping: false };
45
57
  NotificationBadge.displayName = "NotificationBadge";
46
58
  export {
47
59
  NotificationBadge
@@ -1,7 +1,7 @@
1
1
  // src/components/notification-badge/notification-badge.meta.ts
2
2
  var notificationBadgeMeta = {
3
3
  name: "NotificationBadge",
4
- summary: `An unread indicator. Renders a bare dot or a count pill (capped as "{max}+"), or nothing when there's nothing to show. overlay pins it to the corner of a relative host; it is aria-hidden, so the host's name carries the count.`,
4
+ summary: `An unread indicator. Renders a bare dot or a count pill (capped as "{max}+"), or nothing when there's nothing to show. A changing count rolls to its new number, and a badge that appears after being empty pops in. overlay pins it to the corner of a relative host; it is aria-hidden, so the host's name carries the count.`,
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "notification",
@@ -35,7 +35,7 @@ var notificationBadgeMeta = {
35
35
  ]
36
36
  },
37
37
  related: {
38
- components: ["Badge", "StatusDot"]
38
+ components: ["Badge", "StatusDot", "NumberTicker"]
39
39
  },
40
40
  props: [
41
41
  {
@@ -72,6 +72,12 @@ var notificationBadgeMeta = {
72
72
  type: "boolean",
73
73
  default: "false",
74
74
  description: "Pin to the top-end corner of the nearest relative host (an icon button) \u2014 one placement for every caller instead of hand-tuned absolute offsets."
75
+ },
76
+ {
77
+ name: "motion",
78
+ type: '"full" | "subtle" | "off"',
79
+ default: '"full"',
80
+ description: "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down \u2014 it never overrides a reader's reduce-motion setting. Under `subtle` the count updates in place and the badge fades in without the pop."
75
81
  }
76
82
  ],
77
83
  variants: {
@@ -87,6 +93,32 @@ var notificationBadgeMeta = {
87
93
  "secondary",
88
94
  "secondary-foreground"
89
95
  ],
96
+ motion: [
97
+ {
98
+ slot: "arrival",
99
+ recipe: "motion-appear",
100
+ part: "root",
101
+ why: "A badge that shows up after being empty is the news \u2014 something just arrived \u2014 so it fades and scales in where it sits instead of popping into existence. Never on first paint: a badge that mounts with a count is content, and a page of them popping on load is noise."
102
+ },
103
+ {
104
+ slot: "response",
105
+ recipe: "none",
106
+ part: "root",
107
+ why: "It is decorative and not interactive; the host it sits on answers the pointer."
108
+ },
109
+ {
110
+ slot: "transition",
111
+ recipe: "custom",
112
+ part: "Count",
113
+ why: 'The count rolls to its new number through NumberTicker (expressive), so 3 \u2192 4 reads as one more arriving rather than a different badge. Past the cap the ticker holds at max with a static "+", so a count climbing beyond 99 does not keep rolling a badge that has not changed.'
114
+ },
115
+ {
116
+ slot: "activity",
117
+ recipe: "none",
118
+ part: "root",
119
+ why: "An unread count has no working state."
120
+ }
121
+ ],
90
122
  a11y: [
91
123
  'The badge is aria-hidden: a count rendered on a control is not associated with it, so the host needs an accessible name that includes the count \u2014 "Notifications, 3 unread" \u2014 or a screen-reader user hears "Notifications, button" and never learns there is anything new. Pass aria-hidden={false} only for a standalone badge whose number is the information.',
92
124
  "A dot carries no number and no words: on its own it tells a sighted user only that something changed, and tells everyone else nothing. Pair it with a label that says what is new, and never make its colour the only difference between two states."
@@ -0,0 +1,7 @@
1
+ // src/components/number-ticker/index.ts
2
+ import { NumberTicker } from "./number-ticker.js";
3
+ import { numberTickerMeta } from "./number-ticker.meta.js";
4
+ export {
5
+ NumberTicker,
6
+ numberTickerMeta
7
+ };
@@ -0,0 +1,246 @@
1
+ "use client";
2
+
3
+ // src/components/number-ticker/number-ticker.tsx
4
+ import {
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState
9
+ } from "react";
10
+ import { cn } from "../../lib/cn.js";
11
+ import { useIsomorphicLayoutEffect } from "../../lib/use-isomorphic-layout-effect.js";
12
+ import { motionAttribute, useMotionLevel } from "../../motion/MotionProvider.js";
13
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
+ var COPIES = 3;
15
+ var CELLS = COPIES * 10;
16
+ var MIDDLE = 10;
17
+ var ROLL = "calc(var(--motion-duration-slower) * var(--motion-expressive, 1)) var(--motion-ease-emphasized)";
18
+ var RESIZE = `width ${ROLL}, opacity var(--motion-duration-fast) var(--motion-ease-standard)`;
19
+ function rollDelay(placeFromRight) {
20
+ return `calc(${Math.min(placeFromRight, 8) * 30}ms * var(--motion-scale, 1) * var(--motion-expressive, 1))`;
21
+ }
22
+ function cellsFor(parts, glyphs, prefix, suffix) {
23
+ const digitOf = (ch) => glyphs.indexOf(ch);
24
+ const integerDigits = parts.filter((p) => p.type === "integer").reduce((n, p) => n + [...p.value].filter((ch) => digitOf(ch) >= 0).length, 0);
25
+ const cells = [];
26
+ if (prefix) cells.push({ key: "prefix", rank: 0, kind: "static", char: prefix });
27
+ let seenInteger = 0;
28
+ let fraction = 0;
29
+ let after = 0;
30
+ let afterNumber = false;
31
+ const before = [];
32
+ const typeCount = /* @__PURE__ */ new Map();
33
+ for (const part of parts) {
34
+ if (part.type === "integer") {
35
+ afterNumber = true;
36
+ for (const ch of part.value) {
37
+ const digit = digitOf(ch);
38
+ const place = integerDigits - 1 - seenInteger;
39
+ if (digit < 0) {
40
+ cells.push({ key: `i${place}x`, rank: 2e6 - place * 2, kind: "static", char: ch });
41
+ continue;
42
+ }
43
+ seenInteger++;
44
+ cells.push({ key: `i${place}`, rank: 2e6 - place * 2, kind: "digit", digit });
45
+ }
46
+ } else if (part.type === "group") {
47
+ const place = integerDigits - seenInteger;
48
+ cells.push({ key: `g${place}`, rank: 2e6 - place * 2 + 1, kind: "static", char: part.value });
49
+ } else if (part.type === "decimal") {
50
+ afterNumber = true;
51
+ cells.push({ key: "decimal", rank: 3e6, kind: "static", char: part.value });
52
+ } else if (part.type === "fraction") {
53
+ for (const ch of part.value) {
54
+ const digit = digitOf(ch);
55
+ const at = fraction++;
56
+ cells.push(
57
+ digit < 0 ? { key: `f${at}x`, rank: 4e6 + at, kind: "static", char: ch } : { key: `f${at}`, rank: 4e6 + at, kind: "digit", digit }
58
+ );
59
+ }
60
+ } else {
61
+ const side = afterNumber ? "a" : "b";
62
+ const n = typeCount.get(`${side}${part.type}`) ?? 0;
63
+ typeCount.set(`${side}${part.type}`, n + 1);
64
+ const cell = { key: `${side}-${part.type}-${n}`, rank: 5e6 + after++, kind: "static", char: part.value };
65
+ if (afterNumber) cells.push(cell);
66
+ else before.push(cell);
67
+ }
68
+ }
69
+ before.forEach((cell, i) => {
70
+ cell.rank = 2e6 - 1e5 - (before.length - i);
71
+ });
72
+ if (suffix) cells.push({ key: "suffix", rank: 6e6, kind: "static", char: suffix });
73
+ return [...before, ...cells].sort((a, b) => a.rank - b.rank);
74
+ }
75
+ var GLYPH = "before:content-[attr(data-char)]";
76
+ var CELL = "relative inline-block whitespace-pre";
77
+ function StaticCell({ char }) {
78
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
79
+ /* @__PURE__ */ jsx("span", { "data-char": char, className: cn("invisible", GLYPH) }),
80
+ /* @__PURE__ */ jsx("span", { "data-char": char, className: cn("absolute inset-0 overflow-x-clip", GLYPH) })
81
+ ] });
82
+ }
83
+ var translateFor = (pos) => `0 ${-pos / CELLS * 100}%`;
84
+ function DigitCell({ digit, glyphs, direction, delay, animate }) {
85
+ const stack = useRef(null);
86
+ const [initial] = useState(() => MIDDLE + digit);
87
+ const pos = useRef(initial);
88
+ const snap = (el, to) => {
89
+ const transition = el.style.transition;
90
+ el.style.transition = "none";
91
+ el.style.translate = translateFor(to);
92
+ void el.offsetHeight;
93
+ el.style.transition = transition;
94
+ pos.current = to;
95
+ };
96
+ useIsomorphicLayoutEffect(() => {
97
+ const el = stack.current;
98
+ if (!el || pos.current % 10 === digit) return;
99
+ if (!animate) {
100
+ snap(el, MIDDLE + digit);
101
+ return;
102
+ }
103
+ const target = (from) => {
104
+ let to2 = from - from % 10 + digit;
105
+ if (direction === "up" && to2 < from) to2 += 10;
106
+ if (direction === "down" && to2 > from) to2 -= 10;
107
+ return to2;
108
+ };
109
+ let to = target(pos.current);
110
+ if (to < 0 || to >= CELLS) {
111
+ snap(el, MIDDLE + pos.current % 10);
112
+ to = target(pos.current);
113
+ }
114
+ el.style.translate = translateFor(to);
115
+ pos.current = to;
116
+ }, [digit, direction, animate]);
117
+ const settle = (event) => {
118
+ if (event.target !== event.currentTarget || event.propertyName !== "translate") return;
119
+ const at = pos.current;
120
+ if (at < MIDDLE || at >= MIDDLE + 10) snap(event.currentTarget, MIDDLE + at % 10);
121
+ };
122
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
123
+ /* @__PURE__ */ jsx("span", { "data-char": glyphs[digit], className: cn("invisible", GLYPH) }),
124
+ /* @__PURE__ */ jsx("span", { className: "absolute inset-0 overflow-clip", children: /* @__PURE__ */ jsx(
125
+ "span",
126
+ {
127
+ ref: stack,
128
+ onTransitionEnd: settle,
129
+ className: "absolute inset-x-0 top-0 flex flex-col",
130
+ style: { translate: translateFor(initial), transition: `translate ${ROLL} ${delay}` },
131
+ children: Array.from({ length: CELLS }, (_, i) => (
132
+ // biome-ignore lint/suspicious/noArrayIndexKey: a column's positions are fixed
133
+ /* @__PURE__ */ jsx("span", { "data-char": glyphs[i % 10], className: GLYPH }, i)
134
+ ))
135
+ }
136
+ ) })
137
+ ] });
138
+ }
139
+ function NumberTicker({
140
+ value,
141
+ format,
142
+ locales,
143
+ prefix,
144
+ suffix,
145
+ trend = "auto",
146
+ motion,
147
+ className,
148
+ ...props
149
+ }) {
150
+ const { expressive } = useMotionLevel(motion);
151
+ const formatKey = JSON.stringify(format ?? {});
152
+ const localeKey = Array.isArray(locales) ? locales.join(",") : locales ?? "";
153
+ const { formatter, glyphs } = useMemo(() => {
154
+ const f = new Intl.NumberFormat(locales, format);
155
+ const digits = new Intl.NumberFormat(locales, { numberingSystem: f.resolvedOptions().numberingSystem, useGrouping: false });
156
+ return { formatter: f, glyphs: Array.from({ length: 10 }, (_, d) => digits.format(d)) };
157
+ }, [formatKey, localeKey]);
158
+ const prefixText = typeof prefix === "string" || typeof prefix === "number" ? String(prefix) : "";
159
+ const suffixText = typeof suffix === "string" || typeof suffix === "number" ? String(suffix) : "";
160
+ const parts = formatter.formatToParts(value);
161
+ const text = `${prefixText}${parts.map((p) => p.value).join("")}${suffixText}`;
162
+ const [track, setTrack] = useState(() => ({
163
+ text,
164
+ value,
165
+ cells: cellsFor(parts, glyphs, prefixText, suffixText),
166
+ leaving: [],
167
+ direction: "up",
168
+ changed: false
169
+ }));
170
+ let current = track;
171
+ if (track.text !== text) {
172
+ const cells = cellsFor(parts, glyphs, prefixText, suffixText);
173
+ const keys = new Set(cells.map((c) => c.key));
174
+ current = {
175
+ text,
176
+ value,
177
+ cells,
178
+ leaving: expressive ? [...track.leaving, ...track.cells].filter((c) => !keys.has(c.key)) : [],
179
+ direction: trend === "auto" ? value < track.value ? "down" : "up" : trend,
180
+ changed: true
181
+ };
182
+ setTrack(current);
183
+ }
184
+ useEffect(() => {
185
+ if (!current.leaving.length) return;
186
+ const id = setTimeout(() => setTrack((t) => t.leaving.length ? { ...t, leaving: [] } : t), 1200);
187
+ return () => clearTimeout(id);
188
+ }, [current.leaving]);
189
+ const drop = (key) => (event) => {
190
+ if (event.target !== event.currentTarget || event.propertyName !== "width") return;
191
+ setTrack((t) => ({ ...t, leaving: t.leaving.filter((c) => c.key !== key) }));
192
+ };
193
+ const leavingKeys = new Set(current.leaving.map((c) => c.key));
194
+ const shown = [...current.cells, ...current.leaving].sort((a, b) => a.rank - b.rank);
195
+ const animate = expressive && current.changed;
196
+ const placeFromRight = /* @__PURE__ */ new Map();
197
+ let place = 0;
198
+ for (let i = shown.length - 1; i >= 0; i--) if (shown[i].kind === "digit") placeFromRight.set(shown[i].key, place++);
199
+ return /* @__PURE__ */ jsxs(
200
+ "span",
201
+ {
202
+ "data-slot": "number-ticker",
203
+ "data-motion": motionAttribute(motion),
204
+ className: cn("inline-block whitespace-nowrap tabular-nums [interpolate-size:allow-keywords]", className),
205
+ ...props,
206
+ children: [
207
+ prefixText ? null : prefix,
208
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: text }),
209
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", dir: "ltr", children: shown.map((cell) => {
210
+ const leaving = leavingKeys.has(cell.key);
211
+ return /* @__PURE__ */ jsx(
212
+ "span",
213
+ {
214
+ onTransitionEnd: leaving ? drop(cell.key) : void 0,
215
+ className: cn(
216
+ CELL,
217
+ // A cell that arrives after the first paint grows in from nothing.
218
+ // @starting-style only applies to a newly inserted element, so the
219
+ // cells already on screen ignore it.
220
+ animate && !leaving && "starting:w-0 starting:opacity-0",
221
+ leaving && "w-0 opacity-0"
222
+ ),
223
+ style: animate ? { transition: RESIZE } : void 0,
224
+ children: cell.kind === "digit" ? /* @__PURE__ */ jsx(
225
+ DigitCell,
226
+ {
227
+ digit: cell.digit,
228
+ glyphs,
229
+ direction: current.direction,
230
+ delay: rollDelay(placeFromRight.get(cell.key) ?? 0),
231
+ animate
232
+ }
233
+ ) : /* @__PURE__ */ jsx(StaticCell, { char: cell.char })
234
+ },
235
+ cell.key
236
+ );
237
+ }) }),
238
+ suffixText ? null : suffix
239
+ ]
240
+ }
241
+ );
242
+ }
243
+ NumberTicker.displayName = "NumberTicker";
244
+ export {
245
+ NumberTicker
246
+ };
@@ -0,0 +1,159 @@
1
+ // src/components/number-ticker/number-ticker.meta.ts
2
+ var numberTickerMeta = {
3
+ name: "NumberTicker",
4
+ summary: "A number that rolls to its new value digit by digit, like an odometer. Formatted with Intl.NumberFormat (currency, percent, compact, any locale); separators, signs and units hold still while each digit column turns, and a digit that appears or goes away eases its width so nothing around it jumps. At rest on first paint; under subtle or reduced motion the value simply updates.",
5
+ status: "stable",
6
+ keywords: [
7
+ "number",
8
+ "counter",
9
+ "odometer",
10
+ "rolling number",
11
+ "animated number",
12
+ "ticker",
13
+ "count up",
14
+ "stat",
15
+ "kpi",
16
+ "metric",
17
+ "price"
18
+ ],
19
+ usage: {
20
+ description: `Use NumberTicker for a figure that changes in place while someone is looking at it \u2014 a dashboard total after a filter changes, a live count, a price as a plan's options change, a score. The roll says "this number moved, and by roughly this much"; the reader sees the change happen instead of noticing, later, that a number is different.`,
21
+ bestPractices: [
22
+ {
23
+ guidance: "do",
24
+ description: "Use it for the headline figure that a control on the same screen changes \u2014 the total above a chart, the price beside a quantity stepper \u2014 so cause and effect are seen together."
25
+ },
26
+ {
27
+ guidance: "do",
28
+ description: "Format through `format` (currency, percent, compact) rather than pre-formatting a string, so separators and units stay put while only the digits roll."
29
+ },
30
+ {
31
+ guidance: "do",
32
+ description: 'Pass `aria-live="polite"` only when the change itself is news the reader would otherwise miss; most tickers sit beside the control that changed them and need no announcement.'
33
+ },
34
+ {
35
+ guidance: "dont",
36
+ description: "Do not use it for values that change many times a second (a stopwatch, a stock tick, a progress percentage) \u2014 the rolls never land and read as a blur. Render the number as plain tabular text."
37
+ },
38
+ {
39
+ guidance: "dont",
40
+ description: "Do not use it for numbers that are names rather than amounts \u2014 an order ID, a phone number, a year, a version. Rolling implies a quantity going up or down."
41
+ },
42
+ {
43
+ guidance: "dont",
44
+ description: "Do not animate a number on page load to make it feel alive. The first paint is at rest on purpose; for an entrance, reveal the card it sits in (the Reveal motion primitive)."
45
+ },
46
+ {
47
+ guidance: "dont",
48
+ description: "Do not use it for a value inside a known range that needs a picture of how full it is \u2014 show a Meter, with the number beside it.",
49
+ instead: ["Meter"]
50
+ }
51
+ ]
52
+ },
53
+ related: {
54
+ components: ["Meter", "NotificationBadge", "PieChart", "RadialChart", "Sparkline"]
55
+ },
56
+ props: [
57
+ {
58
+ name: "value",
59
+ type: "number",
60
+ description: "The number to show. When it changes, the digits roll to it."
61
+ },
62
+ {
63
+ name: "format",
64
+ type: "Intl.NumberFormatOptions",
65
+ description: 'How to write the number \u2014 `{ style: "currency", currency: "USD" }`, `{ style: "percent" }`, `{ notation: "compact" }`, fraction digits. Passed to Intl.NumberFormat; an inline object is fine (it is compared by value).'
66
+ },
67
+ {
68
+ name: "locales",
69
+ type: "string | string[]",
70
+ default: "runtime locale",
71
+ description: "BCP 47 locale(s) for the formatting. Digits roll through the locale's own numbering system."
72
+ },
73
+ {
74
+ name: "prefix",
75
+ type: "ReactNode",
76
+ description: "Before the number. A string becomes part of the number's text (and rolls in or out with it); an element is rendered as given."
77
+ },
78
+ {
79
+ name: "suffix",
80
+ type: "ReactNode",
81
+ description: `After the number ("+", " pts"). A string becomes part of the number's text; an element is rendered as given.`
82
+ },
83
+ {
84
+ name: "trend",
85
+ type: '"auto" | "up" | "down"',
86
+ default: '"auto"',
87
+ description: "Which way the digits roll. `auto` rolls up as the value grows and down as it shrinks; `up` / `down` fix it (a countdown, a score that only climbs)."
88
+ },
89
+ {
90
+ name: "motion",
91
+ type: '"full" | "subtle" | "off"',
92
+ default: '"full"',
93
+ description: "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down \u2014 it never overrides a reader's reduce-motion setting. The roll is expressive, so `subtle` updates the value in place."
94
+ },
95
+ {
96
+ name: "className",
97
+ type: "string",
98
+ description: "Merged onto the root span. Set the type tier here (`text-heading-2`, `text-display-lg`); the ticker adds tabular figures so widths hold still."
99
+ },
100
+ {
101
+ name: "...span props",
102
+ type: 'Omit<ComponentPropsWithRef<"span">, "children" | "prefix">',
103
+ description: "Remaining span attributes pass through to the root \u2014 `aria-live` to announce changes, `id`, data attributes."
104
+ }
105
+ ],
106
+ tokensUsed: [],
107
+ motion: [
108
+ {
109
+ slot: "arrival",
110
+ recipe: "none",
111
+ part: "Root",
112
+ why: "The first paint is at rest. A number counting up on page load is decoration that delays the reading; the roll is kept for the moment the value actually changes."
113
+ },
114
+ {
115
+ slot: "response",
116
+ recipe: "none",
117
+ part: "Root",
118
+ why: "It is a reading, not a control; the control that changes it gives the feedback."
119
+ },
120
+ {
121
+ slot: "transition",
122
+ recipe: "custom",
123
+ part: "Digit",
124
+ why: "Each digit column turns to its new place on the expressive roll (the slower token, emphasized easing, staggered from the ones column), and arriving or departing columns ease their width. The roll is what shows a change happened and how big it was \u2014 the hundreds turning reads as a bigger move than the ones. No recipe can translate per-digit columns, so it is custom; it is multiplied by --motion-expressive, so `subtle` and reduced motion land the value at once."
125
+ },
126
+ {
127
+ slot: "activity",
128
+ recipe: "none",
129
+ part: "Root",
130
+ why: "A number has no working state; show a Skeleton while it loads."
131
+ }
132
+ ],
133
+ a11y: [
134
+ 'The whole written number (with any string prefix or suffix) is real text in a visually hidden span; the rolling columns are aria-hidden and draw their glyphs as generated content, so screen readers, find-in-page and copy all get "$1,234.50", never a stack of digits.',
135
+ 'It is not a live region by default \u2014 a figure that changes because the reader moved a control is already where their attention is. Pass `aria-live="polite"` when a change elsewhere should be announced.',
136
+ "Tabular figures keep every digit the same width, so a roll never shifts the text around it."
137
+ ],
138
+ examples: [
139
+ {
140
+ label: "Basic",
141
+ code: '<NumberTicker value={1284} className="text-heading-2" />'
142
+ },
143
+ {
144
+ label: "Currency",
145
+ code: '<NumberTicker value={4280.5} format={{ style: "currency", currency: "USD" }} className="text-display-lg" />'
146
+ },
147
+ {
148
+ label: "Percent, announced",
149
+ code: '<NumberTicker value={0.428} format={{ style: "percent", maximumFractionDigits: 1 }} aria-live="polite" />'
150
+ },
151
+ {
152
+ label: "Subtle",
153
+ code: '<NumberTicker value={92} suffix=" pts" motion="subtle" />'
154
+ }
155
+ ]
156
+ };
157
+ export {
158
+ numberTickerMeta
159
+ };
@@ -1,8 +1,10 @@
1
1
  // src/components/otp-field/otp-field.tsx
2
2
  import * as OtpParts from "@base-ui/react/otp-field";
3
- import { Fragment } from "react";
3
+ import { Fragment, useEffect, useRef } from "react";
4
4
  import { cn } from "../../lib/cn.js";
5
5
  import { FIELD_INVALID, FIELD_SHELL } from "../../lib/field-shell.js";
6
+ import { motionAttribute } from "../../motion/MotionProvider.js";
7
+ import { useShake } from "../../motion/useShake.js";
6
8
  import { jsx, jsxs } from "react/jsx-runtime";
7
9
  var resolvedBase;
8
10
  function otpBase() {
@@ -19,17 +21,31 @@ function OtpField({
19
21
  groupSize,
20
22
  getSlotLabel = defaultSlotLabel,
21
23
  "aria-invalid": ariaInvalid,
24
+ motion,
25
+ onAnimationEnd,
22
26
  ...root
23
27
  }) {
24
28
  const invalid = ariaInvalid === true || ariaInvalid === "true";
25
29
  const Base = otpBase();
30
+ const { shake, shakeProps } = useShake();
31
+ const wasInvalid = useRef(invalid);
32
+ useEffect(() => {
33
+ if (invalid && !wasInvalid.current) shake();
34
+ wasInvalid.current = invalid;
35
+ }, [invalid, shake]);
26
36
  return /* @__PURE__ */ jsx(
27
37
  Base.Root,
28
38
  {
29
39
  length,
30
40
  "data-invalid": invalid ? "" : void 0,
31
- className: cn("inline-flex max-w-full items-center gap-2", className),
41
+ "data-motion": motionAttribute(motion),
42
+ className: cn("inline-flex max-w-full items-center gap-2 motion-shake", className),
32
43
  ...root,
44
+ "data-shake": shakeProps["data-shake"],
45
+ onAnimationEnd: (event) => {
46
+ shakeProps.onAnimationEnd(event);
47
+ onAnimationEnd?.(event);
48
+ },
33
49
  children: Array.from({ length }, (_, index) => (
34
50
  // Slots are positional and never reorder, so the index is the identity.
35
51
  // biome-ignore lint/suspicious/noArrayIndexKey: positional slots
@@ -55,7 +71,11 @@ function OtpField({
55
71
  // Focus is FIELD_SHELL's ring alone, like every other field — a ring-
56
72
  // coloured border under it made a 3px edge only here.
57
73
  "data-[filled]:border-border-strong",
58
- FIELD_INVALID
74
+ FIELD_INVALID,
75
+ // The digits turn too, not just the box: on a six-slot code the
76
+ // border alone is a thin line, and the characters are what the
77
+ // user is reading to find the mistake.
78
+ "aria-[invalid=true]:text-destructive"
59
79
  )
60
80
  }
61
81
  )