@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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +172 -0
- package/CONTRIBUTING.md +6 -4
- package/DESIGN.md +10 -3
- package/dist/blocks/approval-block.js +93 -0
- package/dist/blocks/approval-block.meta.js +23 -0
- package/dist/blocks/index.d.ts +27 -1
- package/dist/blocks/index.js +8 -0
- package/dist/blocks/message-reactions-block.js +87 -0
- package/dist/blocks/message-reactions-block.meta.js +23 -0
- package/dist/components/accordion/accordion.meta.js +8 -2
- package/dist/components/area-chart/area-chart.meta.js +10 -3
- package/dist/components/attachment/attachment.meta.js +26 -0
- package/dist/components/bar-chart/bar-chart.meta.js +11 -4
- package/dist/components/bottom-nav/bottom-nav.js +3 -1
- package/dist/components/bottom-nav/bottom-nav.meta.js +32 -0
- package/dist/components/breadcrumbs/breadcrumbs.meta.js +26 -0
- package/dist/components/bubble/bubble.js +105 -5
- package/dist/components/bubble/bubble.meta.js +42 -6
- package/dist/components/bubble/bubble.variants.js +1 -1
- package/dist/components/bubble/index.js +2 -0
- package/dist/components/button/button.js +21 -4
- package/dist/components/button/button.meta.js +7 -7
- package/dist/components/calendar/calendar.js +18 -1
- package/dist/components/calendar/calendar.meta.js +38 -0
- package/dist/components/carousel/carousel.js +6 -4
- package/dist/components/carousel/carousel.meta.js +42 -0
- package/dist/components/chat-composer/chat-composer.js +21 -21
- package/dist/components/chat-composer/chat-composer.meta.js +38 -0
- package/dist/components/chat-composer/use-eased-growth.js +70 -0
- package/dist/components/checkbox/checkbox-mark.js +8 -1
- package/dist/components/checkbox/checkbox.js +3 -1
- package/dist/components/checkbox/checkbox.meta.js +32 -0
- package/dist/components/checkbox-card/checkbox-card.js +3 -0
- package/dist/components/checkbox-card/checkbox-card.meta.js +32 -0
- package/dist/components/code-block/code-block.js +14 -3
- package/dist/components/code-block/code-block.meta.js +28 -1
- package/dist/components/collapsible/collapsible.meta.js +32 -0
- package/dist/components/color-field/color-field.meta.js +26 -0
- package/dist/components/combobox/combobox.meta.js +44 -0
- package/dist/components/command-menu/command-menu.js +8 -3
- package/dist/components/command-menu/command-menu.meta.js +38 -0
- package/dist/components/confirm-button/confirm-button.js +179 -0
- package/dist/components/confirm-button/confirm-button.meta.js +143 -0
- package/dist/components/confirm-button/index.js +7 -0
- package/dist/components/data-table/data-table.js +6 -2
- package/dist/components/data-table/data-table.meta.js +32 -0
- package/dist/components/entity/entity.meta.js +26 -0
- package/dist/components/feedback/feedback.js +120 -47
- package/dist/components/feedback/feedback.meta.js +55 -4
- package/dist/components/input-group/input-group.meta.js +27 -1
- package/dist/components/like-button/index.js +7 -0
- package/dist/components/like-button/like-button.js +89 -0
- package/dist/components/like-button/like-button.meta.js +132 -0
- package/dist/components/line-chart/line-chart.meta.js +10 -3
- package/dist/components/middle-truncate/middle-truncate.meta.js +1 -1
- package/dist/components/note/note.meta.js +32 -0
- package/dist/components/notification-badge/notification-badge.js +17 -5
- package/dist/components/notification-badge/notification-badge.meta.js +34 -2
- package/dist/components/number-ticker/index.js +7 -0
- package/dist/components/number-ticker/number-ticker.js +246 -0
- package/dist/components/number-ticker/number-ticker.meta.js +159 -0
- package/dist/components/otp-field/otp-field.js +23 -3
- package/dist/components/otp-field/otp-field.meta.js +23 -2
- package/dist/components/panel-group/panel-group.meta.js +26 -0
- package/dist/components/panel-section/panel-section.js +37 -10
- package/dist/components/panel-section/panel-section.meta.js +32 -0
- package/dist/components/pie-chart/pie-chart.js +20 -11
- package/dist/components/pie-chart/pie-chart.meta.js +17 -4
- package/dist/components/pill/pill.meta.js +32 -0
- package/dist/components/radar-chart/radar-chart.js +4 -3
- package/dist/components/radar-chart/radar-chart.meta.js +10 -3
- package/dist/components/radial-chart/radial-chart.js +22 -22
- package/dist/components/radial-chart/radial-chart.meta.js +17 -4
- package/dist/components/radio/radio.meta.js +26 -0
- package/dist/components/radio-card/radio-card.meta.js +38 -0
- package/dist/components/reaction-picker/index.js +13 -0
- package/dist/components/reaction-picker/reaction-picker.js +104 -0
- package/dist/components/reaction-picker/reaction-picker.meta.js +156 -0
- package/dist/components/relative-time/relative-time.meta.js +32 -0
- package/dist/components/scroller/scroller.meta.js +32 -0
- package/dist/components/scrub-field/scrub-field.meta.js +32 -0
- package/dist/components/search-input/search-input.js +9 -7
- package/dist/components/search-input/search-input.meta.js +26 -0
- package/dist/components/show-more/show-more.js +1 -4
- package/dist/components/show-more/show-more.meta.js +38 -0
- package/dist/components/side-nav/side-nav.js +21 -1
- package/dist/components/side-nav/side-nav.meta.js +32 -0
- package/dist/components/side-nav/side-nav.variants.js +3 -1
- package/dist/components/sidebar/sidebar.js +31 -8
- package/dist/components/sidebar/sidebar.meta.js +38 -0
- package/dist/components/slider/slider.meta.js +26 -0
- package/dist/components/sparkline/sparkline.js +7 -4
- package/dist/components/sparkline/sparkline.meta.js +9 -2
- package/dist/components/table/table.meta.js +26 -0
- package/dist/components/tabs/tabs.js +27 -8
- package/dist/components/tabs/tabs.meta.js +46 -3
- package/dist/components/theme-selector/theme-selector.meta.js +32 -0
- package/dist/components/theme-switcher/theme-switcher.js +4 -1
- package/dist/components/theme-switcher/theme-switcher.meta.js +42 -0
- package/dist/components/toast/toast.js +25 -13
- package/dist/components/toast/toast.meta.js +7 -1
- package/dist/components/toggle/toggle.js +28 -5
- package/dist/components/toggle/toggle.meta.js +44 -2
- package/dist/components/tool-call/index.js +8 -0
- package/dist/components/tool-call/tool-call.js +117 -0
- package/dist/components/tool-call/tool-call.meta.js +147 -0
- package/dist/components/tool-call/tool-call.variants.js +33 -0
- package/dist/components/tooltip/index.js +3 -1
- package/dist/components/tooltip/tooltip.js +84 -0
- package/dist/components/tooltip/tooltip.meta.js +16 -1
- package/dist/components/tree/tree.js +71 -6
- package/dist/components/tree/tree.meta.js +32 -0
- package/dist/components/video/video.meta.js +32 -0
- package/dist/file-hashes.json +130 -104
- package/dist/foundations/motion.js +69 -3
- package/dist/icon-sets/reicon.js +11 -1
- package/dist/index.d.ts +907 -104
- package/dist/index.js +6 -1
- package/dist/lib/chart/cartesian.js +9 -10
- package/dist/lib/chart/chart-center.js +30 -0
- package/dist/lib/chart/chart-frame.js +15 -8
- package/dist/lib/chart/dim.js +13 -0
- package/dist/lib/chart/format.js +7 -1
- package/dist/lib/chart/motion.js +6 -4
- package/dist/lib/highlight-glide.js +68 -10
- package/dist/lib/taxonomy.js +5 -0
- package/dist/motion/Burst.js +79 -0
- package/dist/motion/MotionProvider.js +45 -3
- package/dist/motion/Reveal.js +1 -1
- package/dist/motion/TextReveal.js +1 -1
- package/dist/motion/index.js +17 -2
- package/dist/motion/useAutoSize.js +42 -0
- package/dist/motion/useShake.js +25 -0
- package/dist/styles.css +439 -5
- package/llms/blocks/approval-block.md +128 -0
- package/llms/blocks/message-reactions-block.md +116 -0
- package/llms/components/accordion.md +3 -2
- package/llms/components/area-chart.md +4 -3
- package/llms/components/attachment.md +7 -0
- package/llms/components/bar-chart.md +5 -4
- package/llms/components/bottom-nav.md +8 -0
- package/llms/components/breadcrumbs.md +7 -0
- package/llms/components/bubble.md +17 -7
- package/llms/components/button.md +6 -6
- package/llms/components/calendar.md +9 -0
- package/llms/components/carousel.md +17 -0
- package/llms/components/chat-composer.md +9 -0
- package/llms/components/checkbox-card.md +8 -0
- package/llms/components/checkbox.md +8 -0
- package/llms/components/code-block.md +8 -0
- package/llms/components/collapsible.md +8 -0
- package/llms/components/color-field.md +7 -0
- package/llms/components/combobox.md +10 -0
- package/llms/components/command-menu.md +9 -0
- package/llms/components/confirm-button.md +92 -0
- package/llms/components/data-table.md +8 -0
- package/llms/components/entity.md +7 -0
- package/llms/components/feedback.md +23 -3
- package/llms/components/input-group.md +8 -1
- package/llms/components/like-button.md +91 -0
- package/llms/components/line-chart.md +4 -3
- package/llms/components/middle-truncate.md +1 -1
- package/llms/components/note.md +8 -0
- package/llms/components/notification-badge.md +10 -2
- package/llms/components/number-ticker.md +86 -0
- package/llms/components/otp-field.md +15 -1
- package/llms/components/panel-group.md +7 -0
- package/llms/components/panel-section.md +8 -0
- package/llms/components/pie-chart.md +6 -4
- package/llms/components/pill.md +8 -0
- package/llms/components/radar-chart.md +4 -3
- package/llms/components/radial-chart.md +6 -4
- package/llms/components/radio-card.md +9 -0
- package/llms/components/radio.md +7 -0
- package/llms/components/reaction-picker.md +115 -0
- package/llms/components/relative-time.md +8 -0
- package/llms/components/scroller.md +8 -0
- package/llms/components/scrub-field.md +8 -0
- package/llms/components/search-input.md +7 -0
- package/llms/components/show-more.md +9 -0
- package/llms/components/side-nav.md +8 -0
- package/llms/components/sidebar.md +9 -0
- package/llms/components/slider.md +7 -0
- package/llms/components/sparkline.md +3 -2
- package/llms/components/table.md +7 -0
- package/llms/components/tabs.md +20 -2
- package/llms/components/theme-selector.md +8 -0
- package/llms/components/theme-switcher.md +17 -0
- package/llms/components/toast.md +2 -1
- package/llms/components/toggle.md +19 -2
- package/llms/components/tool-call.md +115 -0
- package/llms/components/tooltip.md +11 -1
- package/llms/components/tree.md +8 -0
- package/llms/components/video.md +8 -0
- package/llms/foundations/motion.md +64 -3
- package/llms/reference/utilities.md +26 -1
- package/llms/tasks/reviewing-motion.md +14 -0
- package/llms-full.txt +1337 -69
- package/llms.txt +19 -11
- package/package.json +1 -1
- package/registry.json +3040 -124
- 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
|
-
|
|
1848
|
-
|
|
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
|
+
```
|