@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
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ // src/motion/useAutoSize.ts
4
+ import { useLayoutEffect, useRef } from "react";
5
+ function useAutoSize({ axis = "width", enabled = true } = {}) {
6
+ const outerRef = useRef(null);
7
+ const innerRef = useRef(null);
8
+ useLayoutEffect(() => {
9
+ const outer = outerRef.current;
10
+ const inner = innerRef.current;
11
+ if (!enabled || !outer || !inner || typeof ResizeObserver === "undefined") return;
12
+ let first = true;
13
+ const apply = () => {
14
+ const style = getComputedStyle(outer);
15
+ const px = (value) => Number.parseFloat(value) || 0;
16
+ const extraX = px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth);
17
+ const extraY = px(style.paddingTop) + px(style.paddingBottom) + px(style.borderTopWidth) + px(style.borderBottomWidth);
18
+ const width = inner.offsetWidth || inner.getBoundingClientRect().width;
19
+ const height = inner.offsetHeight || inner.getBoundingClientRect().height;
20
+ if (first) outer.style.transition = "none";
21
+ if (axis !== "height") outer.style.width = `${width + extraX}px`;
22
+ if (axis !== "width") outer.style.height = `${height + extraY}px`;
23
+ if (first) {
24
+ void outer.offsetWidth;
25
+ outer.style.transition = "";
26
+ first = false;
27
+ }
28
+ };
29
+ apply();
30
+ const observer = new ResizeObserver(apply);
31
+ observer.observe(inner);
32
+ return () => {
33
+ observer.disconnect();
34
+ outer.style.width = "";
35
+ outer.style.height = "";
36
+ };
37
+ }, [axis, enabled]);
38
+ return { outerRef, innerRef };
39
+ }
40
+ export {
41
+ useAutoSize
42
+ };
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ // src/motion/useShake.ts
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ function useShake() {
6
+ const [shaking, setShaking] = useState(false);
7
+ const frame = useRef(0);
8
+ useEffect(() => () => cancelAnimationFrame(frame.current), []);
9
+ const shake = useCallback(() => {
10
+ cancelAnimationFrame(frame.current);
11
+ setShaking(false);
12
+ frame.current = requestAnimationFrame(() => {
13
+ frame.current = requestAnimationFrame(() => setShaking(true));
14
+ });
15
+ }, []);
16
+ const onAnimationEnd = useCallback((event) => {
17
+ if (event.target === event.currentTarget && event.animationName === "sable-shake") {
18
+ setShaking(false);
19
+ }
20
+ }, []);
21
+ return { shake, shakeProps: { "data-shake": shaking ? "" : void 0, onAnimationEnd } };
22
+ }
23
+ export {
24
+ useShake
25
+ };
package/dist/styles.css CHANGED
@@ -1201,7 +1201,8 @@
1201
1201
  drops the hiding rule entirely and the action simply stays visible. */
1202
1202
  @utility reveal-on-hover {
1203
1203
  transition-property: color, background-color, border-color, box-shadow,
1204
- opacity, fill, stroke, scale, translate;
1204
+ opacity, fill, stroke, scale, translate, rotate, outline-color, outline-width,
1205
+ outline-offset;
1205
1206
  transition-duration: var(--motion-duration-fast);
1206
1207
  transition-timing-function: var(--motion-ease-standard);
1207
1208
 
@@ -1844,8 +1845,18 @@
1844
1845
  collapse --motion-scale to 0 — one switch makes all token-driven motion
1845
1846
  static (transitions resolve instantly; loaders hold their first frame).
1846
1847
 
1847
- Keep motion subtle: opacity + transform only (compositor-friendly), short
1848
- durations, no spring/overshoot. See DESIGN.md → Motion.
1848
+ Two LEVELS sit under the switch. ESSENTIAL motion answers the user: colour
1849
+ and focus feedback, press, overlay arrival, disclosure height, loaders.
1850
+ EXPRESSIVE motion narrates continuity: an indicator that travels between
1851
+ options (motion-glide), digits that roll (NumberTicker), a tick that draws
1852
+ (motion-tick), a shake on a wrong answer (motion-shake), staggered arrivals
1853
+ and spring overshoot. Expressive durations are multiplied by a second
1854
+ factor, --motion-expressive, which `data-motion="subtle"` sets to 0 (and
1855
+ which also takes the overshoot out of ease-spring/ease-bounce). So "subtle"
1856
+ keeps every answer and drops every flourish; "off" drops both.
1857
+
1858
+ Opacity + transform only (compositor-friendly), short durations, overshoot on
1859
+ enter only. See DESIGN.md → Motion.
1849
1860
  ========================================================================== */
1850
1861
 
1851
1862
  @keyframes sable-spin {
@@ -2009,10 +2020,22 @@
2009
2020
  `motion-press` and the hover/active surface — the flash is a second, worse
2010
2021
  answer. Both are inert on pointer devices, and neither is motion, so
2011
2022
  --motion-scale: 0 and prefers-reduced-motion leave them untouched: the delay
2012
- fix keeps working when animation is off, which is when it matters most. */
2023
+ fix keeps working when animation is off, which is when it matters most.
2024
+
2025
+ `rotate` is in the list because Tailwind v4's `rotate-*` utilities write the
2026
+ standalone `rotate` property, not `transform`. Without it every disclosure
2027
+ chevron carrying this recipe (Accordion, Collapsible, Tree) jumped 180° in a
2028
+ single frame while the panel beside it eased open.
2029
+
2030
+ The outline properties are there for focus rings drawn with `outline-*`
2031
+ (the box-shadow `ring-*` ones were already covered). An outline's computed
2032
+ width is 0 while its style is `none`, so on focus the ring grows from the
2033
+ element's edge to its full width at the fast step instead of appearing in
2034
+ one frame; on blur it shrinks back. Under reduced motion it is instant. */
2013
2035
  @utility motion-interactive {
2014
2036
  transition-property: color, background-color, border-color, box-shadow,
2015
- opacity, fill, stroke, scale, translate;
2037
+ opacity, fill, stroke, scale, translate, rotate, outline-color, outline-width,
2038
+ outline-offset;
2016
2039
  transition-duration: var(--motion-duration-fast);
2017
2040
  transition-timing-function: var(--motion-ease-standard);
2018
2041
  touch-action: manipulation;
@@ -2196,6 +2219,403 @@
2196
2219
  }
2197
2220
  }
2198
2221
 
2222
+ /* --------------------------------------------------------------------------
2223
+ Expressive recipes — continuity and choreography. Each duration is a token
2224
+ multiplied by --motion-expressive, so `data-motion="subtle"` resolves them
2225
+ instantly while the essential recipes above keep answering. Under the
2226
+ off/reduced policy the token itself is already 0.
2227
+ -------------------------------------------------------------------------- */
2228
+
2229
+ /* Glide — ONE element that travels to the selected option, instead of each
2230
+ option blinking its own fill: a tab's underline or pill, a segmented thumb,
2231
+ a carousel's active dot, a nav's current-page bar. The component positions it
2232
+ (Base UI's --active-tab-* vars, a measured box, or an index step); this
2233
+ recipe carries the timing.
2234
+
2235
+ Width and height are in the list on purpose. The glider is absolutely
2236
+ positioned, so resizing it lays out nothing else, and a pill that stretches
2237
+ to its new option's label width cannot be a scale without distorting its
2238
+ corners. Emphasized easing: it leaves quickly and settles, which is what
2239
+ reads as "one thing moved" rather than "two things swapped". */
2240
+ @utility motion-glide {
2241
+ transition-property: translate, width, height, inset, left, top, opacity, background-color;
2242
+ /* The travel is expressive; the glider fading in or out and its colour
2243
+ answering a hover are essential, so "subtle" keeps those. One duration
2244
+ per property — a shorter list repeats from the start. */
2245
+ transition-duration:
2246
+ calc(var(--motion-duration-slow) * var(--motion-expressive, 1)),
2247
+ calc(var(--motion-duration-slow) * var(--motion-expressive, 1)),
2248
+ calc(var(--motion-duration-slow) * var(--motion-expressive, 1)),
2249
+ calc(var(--motion-duration-slow) * var(--motion-expressive, 1)),
2250
+ calc(var(--motion-duration-slow) * var(--motion-expressive, 1)),
2251
+ calc(var(--motion-duration-slow) * var(--motion-expressive, 1)),
2252
+ var(--motion-duration-fast),
2253
+ var(--motion-duration-fast);
2254
+ transition-timing-function: var(--motion-ease-emphasized), var(--motion-ease-emphasized),
2255
+ var(--motion-ease-emphasized), var(--motion-ease-emphasized), var(--motion-ease-emphasized),
2256
+ var(--motion-ease-emphasized), var(--motion-ease-standard), var(--motion-ease-standard);
2257
+ }
2258
+
2259
+ /* Shake — a short horizontal no, for an answer that was refused: a wrong
2260
+ one-time code, a submit with invalid fields. Plays while `data-shake` is set;
2261
+ `useShake()` (src/motion) sets it, clears it on animationend, and restarts it
2262
+ when the next refusal lands mid-shake. Decaying amplitude, about a fifth of a
2263
+ second per swing — anything longer reads as a tantrum.
2264
+
2265
+ Never the only signal. The field turns destructive and says why; the shake
2266
+ is what makes a repeated mistake feel noticed. Under "subtle" the duration
2267
+ is 0 and only the colour remains. */
2268
+ @keyframes sable-shake {
2269
+ 0%,
2270
+ 100% {
2271
+ translate: 0 0;
2272
+ }
2273
+ 20% {
2274
+ translate: -0.375rem 0;
2275
+ }
2276
+ 40% {
2277
+ translate: 0.3rem 0;
2278
+ }
2279
+ 60% {
2280
+ translate: -0.2rem 0;
2281
+ }
2282
+ 80% {
2283
+ translate: 0.1rem 0;
2284
+ }
2285
+ }
2286
+ @utility motion-shake {
2287
+ &[data-shake] {
2288
+ animation: sable-shake calc(var(--motion-duration-slower) * 0.8 * var(--motion-expressive, 1))
2289
+ var(--motion-ease-standard);
2290
+ }
2291
+ }
2292
+
2293
+ /* Tick — a selection mark that draws itself in rather than appearing: the
2294
+ check in a Checkbox, a success glyph. The mark wipes in from its leading
2295
+ stroke (clip-path, so it works on any injected icon set — a path-length draw
2296
+ would need to own the SVG) while its opacity takes the essential fade.
2297
+ Keyed to Base UI's starting/ending style like the overlay recipes; exits only
2298
+ fade, at the exit speed, so un-checking is not a performance.
2299
+
2300
+ Two durations: the fade is essential (fast), the wipe is expressive, so
2301
+ "subtle" keeps a clean fade and drops the draw. */
2302
+ @utility motion-tick {
2303
+ /* Both ends of the wipe must be inset() so they interpolate: from the
2304
+ default `none` the clip-path flipped in one frame and only the fade and
2305
+ scale played. Outset by 2px so the resting clip never shaves an injected
2306
+ glyph's round caps. */
2307
+ clip-path: inset(-2px);
2308
+ transition-property: opacity, clip-path, scale;
2309
+ transition-duration: var(--motion-duration-fast),
2310
+ calc(var(--motion-duration-base) * var(--motion-expressive, 1)),
2311
+ calc(var(--motion-duration-base) * var(--motion-expressive, 1));
2312
+ transition-timing-function: var(--motion-ease-standard), var(--motion-ease-decelerate),
2313
+ var(--motion-ease-spring);
2314
+ &[data-starting-style] {
2315
+ opacity: 0;
2316
+ clip-path: inset(-2px 100% -2px -2px);
2317
+ scale: 0.6;
2318
+ }
2319
+ &[data-ending-style] {
2320
+ opacity: 0;
2321
+ transition-duration: var(--motion-duration-exit), 0s, 0s;
2322
+ }
2323
+ }
2324
+
2325
+ /* Appear — a small control that mounts in place and should not pop into
2326
+ existence: a search field's clear button, a send button replacing a mic, a
2327
+ count badge. Uses @starting-style, so it needs no data attribute and no JS;
2328
+ the element simply transitions in from its starting style on first render.
2329
+ The fade is essential, the scale is expressive. Removal is instant — an
2330
+ unmounted element has nothing to animate, and a control leaving should get
2331
+ out of the way. It declares its own transition-property, so put it on a
2332
+ WRAPPER around a Button or anything carrying motion-interactive — on the
2333
+ same element one of the two lists would silently win. */
2334
+ @utility motion-appear {
2335
+ transition-property: opacity, scale;
2336
+ transition-duration: var(--motion-duration-fast),
2337
+ calc(var(--motion-duration-base) * var(--motion-expressive, 1));
2338
+ transition-timing-function: var(--motion-ease-standard), var(--motion-ease-spring);
2339
+ @starting-style {
2340
+ opacity: 0;
2341
+ scale: 0.6;
2342
+ }
2343
+ }
2344
+
2345
+ /* Arrive — items that a user action brings into a list settle into place one
2346
+ after another, so the eye can follow where they came from: a tree folder's
2347
+ children on expand, results that load in, a row that was just added.
2348
+ A CSS animation rather than a transition, so it composes with the
2349
+ motion-interactive every row already carries. Set the item's stagger as a
2350
+ raw delay in --motion-arrive-delay (index x ~18ms, capped around ten items so
2351
+ a long list settles as fast as a short one); the recipe scales it, so
2352
+ "subtle" and reduced motion show the items at once with no dead wait.
2353
+ `backwards` holds the start pose through the delay and then lets go, so a
2354
+ later opacity (a dimmed row) is never pinned.
2355
+
2356
+ Only on a user action. A list that arrives this way every time it renders is
2357
+ a title sequence: mount the class only on items that are new. */
2358
+ @keyframes sable-arrive {
2359
+ from {
2360
+ opacity: 0;
2361
+ translate: 0 -0.25rem;
2362
+ }
2363
+ }
2364
+ @utility motion-arrive {
2365
+ animation: sable-arrive calc(var(--motion-duration-base) * var(--motion-expressive, 1))
2366
+ var(--motion-ease-decelerate)
2367
+ calc(var(--motion-arrive-delay, 0ms) * var(--motion-scale, 1) * var(--motion-expressive, 1))
2368
+ backwards;
2369
+ }
2370
+
2371
+ /* Calendar month paging — react-day-picker's `animate` mode, which Calendar
2372
+ turns on only while motion is expressive. RDP keeps the outgoing month as a
2373
+ snapshot laid over the incoming one and adds ONE class to each part (so
2374
+ these are plain classes, not utilities: a class list would not survive
2375
+ classList.add). The months trade places over a short travel with a fade,
2376
+ not RDP's default full-width slide, which drags a whole grid across the
2377
+ card. Enter at the slow step on the emphasized curve; exit at the exit step,
2378
+ so the old month is out of the way before the new one settles.
2379
+
2380
+ Naming follows RDP: "after" means the month that arrives is AFTER the one
2381
+ leaving (paging forward), so it comes in from the end and the old one leaves
2382
+ toward the start. Mirrored under [dir=rtl]. */
2383
+ @keyframes sable-month-from-start {
2384
+ from {
2385
+ opacity: 0;
2386
+ translate: -1.5rem 0;
2387
+ }
2388
+ }
2389
+ @keyframes sable-month-from-end {
2390
+ from {
2391
+ opacity: 0;
2392
+ translate: 1.5rem 0;
2393
+ }
2394
+ }
2395
+ @keyframes sable-month-to-start {
2396
+ to {
2397
+ opacity: 0;
2398
+ translate: -1.5rem 0;
2399
+ }
2400
+ }
2401
+ @keyframes sable-month-to-end {
2402
+ to {
2403
+ opacity: 0;
2404
+ translate: 1.5rem 0;
2405
+ }
2406
+ }
2407
+ @keyframes sable-fade-out {
2408
+ to {
2409
+ opacity: 0;
2410
+ }
2411
+ }
2412
+ .sable-month-enter-after,
2413
+ .sable-month-enter-before {
2414
+ animation: sable-month-from-end calc(var(--motion-duration-slow) * var(--motion-expressive, 1))
2415
+ var(--motion-ease-emphasized) both;
2416
+ }
2417
+ .sable-month-enter-before,
2418
+ [dir="rtl"] .sable-month-enter-after {
2419
+ animation-name: sable-month-from-start;
2420
+ }
2421
+ [dir="rtl"] .sable-month-enter-before {
2422
+ animation-name: sable-month-from-end;
2423
+ }
2424
+ .sable-month-exit-after,
2425
+ .sable-month-exit-before {
2426
+ animation: sable-month-to-start calc(var(--motion-duration-exit) * var(--motion-expressive, 1))
2427
+ var(--motion-ease-standard) both;
2428
+ }
2429
+ .sable-month-exit-after,
2430
+ [dir="rtl"] .sable-month-exit-before {
2431
+ animation-name: sable-month-to-end;
2432
+ }
2433
+ [dir="rtl"] .sable-month-exit-after {
2434
+ animation-name: sable-month-to-start;
2435
+ }
2436
+ .sable-caption-enter {
2437
+ animation: sable-fade-in calc(var(--motion-duration-slow) * var(--motion-expressive, 1))
2438
+ var(--motion-ease-decelerate) both;
2439
+ }
2440
+ .sable-caption-exit {
2441
+ animation: sable-fade-out calc(var(--motion-duration-exit) * var(--motion-expressive, 1))
2442
+ var(--motion-ease-standard) both;
2443
+ }
2444
+
2445
+ /* Size — eases an element between sizes: a button whose label changes
2446
+ ("Delete" → "Confirm delete?"), a chip that gains a count, a panel going from
2447
+ 0 to its natural height.
2448
+
2449
+ A transition needs the computed size to CHANGE. Between a keyword and a
2450
+ length (0 ↔ auto) `interpolate-size: allow-keywords` supplies that. A
2451
+ content-sized box whose content changes computes `auto` both times, so pair
2452
+ the recipe with `useAutoSize()` (src/motion), which writes the content's
2453
+ measured size as pixels for this transition to ease between. Where
2454
+ interpolate-size is unsupported the keyword case simply snaps, which is the
2455
+ old behaviour. Expressive: under "subtle" the size snaps and the label still
2456
+ changes. */
2457
+ @utility motion-size {
2458
+ interpolate-size: allow-keywords;
2459
+ /* A superset of motion-interactive's list, in the same order after the two
2460
+ sizes, so it can sit on a Button (which carries motion-interactive) and
2461
+ whichever declaration wins the cascade, nothing the other animated stops
2462
+ animating. Only the two sizes are expressive. */
2463
+ transition-property: width, height, color, background-color, border-color, box-shadow,
2464
+ opacity, fill, stroke, scale, translate, rotate, outline-color, outline-width,
2465
+ outline-offset;
2466
+ /* One entry per property: a shorter list would REPEAT from the start. */
2467
+ transition-duration: calc(var(--motion-duration-base) * var(--motion-expressive, 1)),
2468
+ calc(var(--motion-duration-base) * var(--motion-expressive, 1)),
2469
+ var(--motion-duration-fast), var(--motion-duration-fast), var(--motion-duration-fast), var(--motion-duration-fast), var(--motion-duration-fast),
2470
+ var(--motion-duration-fast), var(--motion-duration-fast), var(--motion-duration-fast), var(--motion-duration-fast), var(--motion-duration-fast),
2471
+ var(--motion-duration-fast), var(--motion-duration-fast), var(--motion-duration-fast);
2472
+ transition-timing-function: var(--motion-ease-emphasized), var(--motion-ease-emphasized),
2473
+ var(--motion-ease-standard), var(--motion-ease-standard), var(--motion-ease-standard), var(--motion-ease-standard), var(--motion-ease-standard),
2474
+ var(--motion-ease-standard), var(--motion-ease-standard), var(--motion-ease-standard), var(--motion-ease-standard), var(--motion-ease-standard),
2475
+ var(--motion-ease-standard), var(--motion-ease-standard), var(--motion-ease-standard);
2476
+ }
2477
+
2478
+ /* Bloom — a small glyph landing in its "on" state: a liked heart, a starred
2479
+ item, a toast's status icon. It grows from nothing past its size and settles
2480
+ back, the amplitude a 16px glyph needs to read as an event. Surfaces never
2481
+ bloom: a dialog that overshot 20% would be a lurch, which is why the overlay
2482
+ recipes travel from 0.96. Size is what sets the amplitude.
2483
+
2484
+ A keyframe animation, so it plays whenever the element MOUNTS: remount the
2485
+ glyph (a changing `key`) to replay it on each "on", and leave it off the
2486
+ resting state so a page that loads with a liked post does not bloom every
2487
+ heart. --motion-bloom-delay holds the glyph at nothing first (a raw delay;
2488
+ the recipe scales it), so a ring or burst can lead. Expressive: "subtle"
2489
+ and reduced motion land the glyph at once. Put it on a wrapper around a
2490
+ glyph that carries motion-press or motion-interactive: an animation holds
2491
+ `scale` while it runs. */
2492
+ @keyframes sable-bloom {
2493
+ 0% {
2494
+ scale: 0;
2495
+ }
2496
+ 55% {
2497
+ scale: 1.2;
2498
+ }
2499
+ 80% {
2500
+ scale: 0.95;
2501
+ }
2502
+ 100% {
2503
+ scale: 1;
2504
+ }
2505
+ }
2506
+ @utility motion-bloom {
2507
+ animation: sable-bloom calc(var(--motion-duration-slower) * var(--motion-expressive, 1))
2508
+ var(--motion-ease-decelerate)
2509
+ calc(var(--motion-bloom-delay, 0ms) * var(--motion-scale, 1) * var(--motion-expressive, 1))
2510
+ backwards;
2511
+ }
2512
+
2513
+ /* Burst — the follow-through behind a bloom, drawn by <Burst> (src/motion): a
2514
+ disc that hollows into a ring and shifts colour, then dots thrown outward
2515
+ that shrink as they go. Plain classes, not utilities: the component owns the
2516
+ markup and the per-dot angles. Sized by --burst-size (the ring's diameter,
2517
+ about twice the glyph's). Coloured from the themed palette through
2518
+ `data-palette` scopes, never a step: the ring blooms in currentColor and
2519
+ fades into its scope's dot colour, and each dot carries a family of its
2520
+ own. Every duration is expressive, so under
2521
+ "subtle" or reduced motion it ends on its invisible last frame at once; the
2522
+ component also renders nothing then. */
2523
+ .sable-burst {
2524
+ position: absolute;
2525
+ inset: 0;
2526
+ pointer-events: none;
2527
+ }
2528
+ /* Centred on the glyph by `translate`, which applies before the animated
2529
+ `scale` and `transform`, so each piece grows and travels from the centre.
2530
+ (A grid cell sized itself to the 2x ring and pinned it off-centre.) */
2531
+ .sable-burst > * {
2532
+ position: absolute;
2533
+ left: 50%;
2534
+ top: 50%;
2535
+ translate: -50% -50%;
2536
+ }
2537
+ @keyframes sable-burst-ring {
2538
+ 0% {
2539
+ scale: 0.2;
2540
+ border-width: calc(var(--burst-size) / 2);
2541
+ border-color: currentColor;
2542
+ opacity: 1;
2543
+ }
2544
+ 45% {
2545
+ scale: 1;
2546
+ border-width: calc(var(--burst-size) / 2);
2547
+ }
2548
+ 100% {
2549
+ scale: 1.1;
2550
+ border-width: 0;
2551
+ border-color: var(--palette-dot, currentColor);
2552
+ opacity: 0;
2553
+ }
2554
+ }
2555
+ .sable-burst-ring {
2556
+ width: var(--burst-size);
2557
+ height: var(--burst-size);
2558
+ border-radius: 9999px;
2559
+ border-style: solid;
2560
+ border-width: 0;
2561
+ opacity: 0;
2562
+ animation: sable-burst-ring calc(var(--motion-duration-slower) * var(--motion-expressive, 1))
2563
+ var(--motion-ease-decelerate) forwards;
2564
+ }
2565
+ @keyframes sable-burst-dot {
2566
+ 0% {
2567
+ opacity: 1;
2568
+ transform: rotate(var(--burst-dot-angle)) translateX(calc(var(--burst-size) * 0.4)) scale(1);
2569
+ }
2570
+ 100% {
2571
+ opacity: 1;
2572
+ transform: rotate(var(--burst-dot-angle)) translateX(calc(var(--burst-size) * var(--burst-dot-reach)))
2573
+ scale(0);
2574
+ }
2575
+ }
2576
+ .sable-burst-dot {
2577
+ width: var(--burst-dot-size);
2578
+ height: var(--burst-dot-size);
2579
+ border-radius: 9999px;
2580
+ background-color: var(--palette-dot, currentColor);
2581
+ opacity: 0;
2582
+ /* Thrown as the ring opens, not with it. */
2583
+ animation: sable-burst-dot calc(var(--motion-duration-slower) * 1.3 * var(--motion-expressive, 1))
2584
+ var(--motion-ease-decelerate) calc(var(--motion-duration-fast) * var(--motion-expressive, 1)) forwards;
2585
+ }
2586
+ /* A thrown glyph (Burst's `particle`) instead of a disc: the emoji itself,
2587
+ sized by the same --burst-dot-size, so a reaction sprays copies of what was
2588
+ chosen. Counter-rotated on its way out so it travels upright, and it fades
2589
+ at the end of its flight rather than shrinking to a point. */
2590
+ @keyframes sable-burst-particle {
2591
+ 0% {
2592
+ opacity: 1;
2593
+ transform: rotate(var(--burst-dot-angle)) translateX(calc(var(--burst-size) * 0.3))
2594
+ rotate(calc(var(--burst-dot-angle) * -1)) scale(0.4);
2595
+ }
2596
+ 60% {
2597
+ opacity: 1;
2598
+ transform: rotate(var(--burst-dot-angle)) translateX(calc(var(--burst-size) * var(--burst-dot-reach) * 0.9))
2599
+ rotate(calc(var(--burst-dot-angle) * -1)) scale(1);
2600
+ }
2601
+ 100% {
2602
+ opacity: 0;
2603
+ transform: rotate(var(--burst-dot-angle)) translateX(calc(var(--burst-size) * var(--burst-dot-reach)))
2604
+ rotate(calc(var(--burst-dot-angle) * -1)) scale(0.7);
2605
+ }
2606
+ }
2607
+ .sable-burst-dot[data-particle] {
2608
+ display: grid;
2609
+ place-items: center;
2610
+ width: auto;
2611
+ height: auto;
2612
+ border-radius: 0;
2613
+ background-color: transparent;
2614
+ font-size: var(--burst-dot-size);
2615
+ line-height: 1;
2616
+ animation-name: sable-burst-particle;
2617
+ }
2618
+
2199
2619
  /* --------------------------------------------------------------------------
2200
2620
  Loader recipes — looping indicators. Durations scale with --motion-scale, so
2201
2621
  the policy switch freezes them to a static first frame.
@@ -2459,3 +2879,17 @@
2459
2879
  [data-motion="off"] [data-sable-theme] {
2460
2880
  --motion-scale: 0 !important;
2461
2881
  }
2882
+
2883
+ /* "subtle": essential motion plays, expressive motion resolves instantly and
2884
+ nothing overshoots. It never touches --motion-scale, so it can only turn
2885
+ motion DOWN: nested inside "off", or under the OS reduce preference, the
2886
+ scale is still 0. That is what makes it safe as a per-component prop and on
2887
+ <MotionScope> — neither can switch motion back on for a reader who asked for
2888
+ it off. The easings are re-pointed rather than zeroed: a spring pop becomes a
2889
+ clean decelerate, which is still an answer. */
2890
+ [data-motion="subtle"],
2891
+ [data-motion="subtle"] [data-sable-theme] {
2892
+ --motion-expressive: 0 !important;
2893
+ --motion-ease-spring: var(--motion-ease-decelerate) !important;
2894
+ --motion-ease-bounce: var(--motion-ease-decelerate) !important;
2895
+ }
@@ -0,0 +1,128 @@
1
+ # ApprovalBlock
2
+
3
+ An agent asking permission for one tool call: the question, what will happen and what can't be undone, the call's details, an "allow for the rest of this chat" Checkbox, and Deny / Allow.
4
+
5
+ **Intent:** Reach for this in a chat or agent run when a tool call waits on the person — sending, paying, deleting, publishing. The call itself, before and after the decision, is a ToolCall with status="awaiting", then "complete" or "denied". A destructive action outside an agent is DestructiveActionModal.
6
+
7
+ **Block:** AI & agents · card · designed for 18rem–28rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - The title is the question in plain words ("Send the invoice to Ada?"), not the tool's name; the tool's name is one row of the details, for the person who wants to know exactly what runs.
12
+ - The description says what can't be undone. That sentence is what the person is deciding on.
13
+ - Deny comes first and Allow is the primary on the end, the same order as every Sable confirmation. Neither is focused on arrival: an approval that a stray Enter can grant is not an approval.
14
+ - After a decision the buttons give way to one InlineStatus line saying what happened, so the thread keeps a record of the answer.
15
+
16
+ ## Accessibility
17
+
18
+ - Focus moves to the outcome line when the buttons leave, instead of dropping to the page; the line is a role=status region, so the decision is announced.
19
+ - The details are a dl, so each value is read with its label.
20
+ - The remember Checkbox has its own Label, so the tool name inside it is part of its accessible name.
21
+
22
+ ## Source
23
+
24
+ ```tsx
25
+ import { useState, useEffect, useRef, useId } from "react"
26
+ import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, cn, Code, InlineStatus, Label } from "@sableui/react"
27
+
28
+ const request = {
29
+ tool: "send_email",
30
+ title: "Send the invoice to Ada?",
31
+ description: "The agent wants to email invoice INV-1042. An email can't be unsent.",
32
+ details: [
33
+ { label: "To", value: "ada@example.com" },
34
+ { label: "Subject", value: "INV-1042 for October" },
35
+ { label: "Attached", value: "INV-1042.pdf" },
36
+ ],
37
+ allowed: "Allowed. Sending the email.",
38
+ denied: "Denied. The agent won't send it.",
39
+ }
40
+
41
+ export interface ApprovalBlockProps {
42
+ className?: string
43
+ /** false drops the card's surface and border — for a chat column that already frames it. */
44
+ framed?: boolean
45
+ /** The person allowed the call. `remember` is the "for the rest of this chat" choice. */
46
+ onApprove?: (options: { remember: boolean }) => void
47
+ /** The person declined the call. */
48
+ onDeny?: () => void
49
+ }
50
+
51
+ export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: ApprovalBlockProps) {
52
+ const [decision, setDecision] = useState<"allowed" | "denied" | null>(null)
53
+ const [remember, setRemember] = useState(false)
54
+ const id = useId()
55
+ const outcome = useRef<HTMLDivElement>(null)
56
+ // The buttons leave with the decision, so focus moves to the line that
57
+ // replaces them instead of falling back to the top of the page.
58
+ useEffect(() => {
59
+ if (decision) outcome.current?.focus()
60
+ }, [decision])
61
+
62
+ return (
63
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-md", className)}>
64
+ <CardHeader>
65
+ <CardTitle>{request.title}</CardTitle>
66
+ <CardDescription>{request.description}</CardDescription>
67
+ </CardHeader>
68
+ <CardContent className="flex flex-col gap-4">
69
+ <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-body-sm">
70
+ {request.details.map((d) => (
71
+ <div key={d.label} className="contents">
72
+ <dt className="text-muted-foreground">{d.label}</dt>
73
+ <dd className="truncate text-foreground">{d.value}</dd>
74
+ </div>
75
+ ))}
76
+ <div className="contents">
77
+ <dt className="text-muted-foreground">Tool</dt>
78
+ <dd className="min-w-0">
79
+ <Code>{request.tool}</Code>
80
+ </dd>
81
+ </div>
82
+ </dl>
83
+ {decision ? null : (
84
+ <div className="flex items-center gap-2">
85
+ <Checkbox id={`${id}-remember`} checked={remember} onCheckedChange={(v: boolean) => setRemember(v)} />
86
+ <Label htmlFor={`${id}-remember`} className="text-body-sm">
87
+ Allow <Code>{request.tool}</Code> for the rest of this chat
88
+ </Label>
89
+ </div>
90
+ )}
91
+ </CardContent>
92
+ <CardFooter actions className="justify-end">
93
+ {decision ? (
94
+ <InlineStatus
95
+ ref={outcome}
96
+ tabIndex={-1}
97
+ role="status"
98
+ tone={decision === "allowed" ? "success" : "neutral"}
99
+ className="me-auto outline-none"
100
+ >
101
+ {decision === "allowed" ? request.allowed : request.denied}
102
+ </InlineStatus>
103
+ ) : (
104
+ <>
105
+ <Button
106
+ variant="outline"
107
+ onClick={() => {
108
+ setDecision("denied")
109
+ onDeny?.()
110
+ }}
111
+ >
112
+ Deny
113
+ </Button>
114
+ <Button
115
+ onClick={() => {
116
+ setDecision("allowed")
117
+ onApprove?.({ remember })
118
+ }}
119
+ >
120
+ Allow
121
+ </Button>
122
+ </>
123
+ )}
124
+ </CardFooter>
125
+ </Card>
126
+ )
127
+ }
128
+ ```