@robr0/design-system 0.13.0 → 0.14.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/README.md +9 -3
- package/components/Banner/Banner.css +110 -0
- package/components/Banner/Banner.d.ts +36 -0
- package/components/Banner/Banner.js +59 -0
- package/components/ContributionGraph/ContributionGraph.css +7 -1
- package/components/ContributionGraph/ContributionGraph.d.ts +9 -1
- package/components/ContributionGraph/ContributionGraph.js +59 -43
- package/components/EmptyState/EmptyState.css +5 -0
- package/components/FunnelChart/FunnelChart.css +3 -2
- package/components/FunnelChart/FunnelChart.d.ts +12 -4
- package/components/FunnelChart/FunnelChart.js +48 -28
- package/components/Gauge/Gauge.css +30 -13
- package/components/Gauge/Gauge.d.ts +16 -8
- package/components/Gauge/Gauge.js +78 -62
- package/components/HoverCard/HoverCard.css +97 -0
- package/components/HoverCard/HoverCard.d.ts +29 -0
- package/components/HoverCard/HoverCard.js +83 -0
- package/components/ImageCompare/ImageCompare.css +112 -0
- package/components/ImageCompare/ImageCompare.d.ts +40 -0
- package/components/ImageCompare/ImageCompare.js +134 -0
- package/components/LinkList/LinkList.d.ts +3 -1
- package/components/LinkList/LinkList.js +4 -3
- package/components/Meter/Meter.css +89 -0
- package/components/Meter/Meter.d.ts +36 -0
- package/components/Meter/Meter.js +48 -0
- package/components/NotificationCenter/NotificationCenter.css +11 -3
- package/components/Rating/Rating.css +74 -0
- package/components/Rating/Rating.d.ts +41 -0
- package/components/Rating/Rating.js +124 -0
- package/components/Sparkline/Sparkline.d.ts +5 -3
- package/components/Sparkline/Sparkline.js +30 -1
- package/components/SplitButton/SplitButton.css +93 -0
- package/components/SplitButton/SplitButton.d.ts +43 -0
- package/components/SplitButton/SplitButton.js +66 -0
- package/components/StreamingText/StreamingText.d.ts +27 -9
- package/components/StreamingText/StreamingText.js +17 -29
- package/components/StreamingText/useStreamReveal.d.ts +70 -0
- package/components/StreamingText/useStreamReveal.js +121 -0
- package/components/registry.json +48 -0
- package/components/registry.json.d.ts +48 -0
- package/components/registry.json.js +1 -1
- package/index.d.ts +6 -0
- package/index.js +15 -0
- package/package.json +5 -1
- package/tokens/motion.d.ts +9 -4
- package/tokens/motion.js +5 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* ============================================
|
|
2
|
+
SPLIT BUTTON COMPONENT
|
|
3
|
+
Primary action + attached menu of alternatives
|
|
4
|
+
============================================ */
|
|
5
|
+
|
|
6
|
+
.ds-split-button {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: stretch;
|
|
9
|
+
/* On the filled variant the hairline of page showing between the
|
|
10
|
+
segments is the split: a gap the width of the system hairline,
|
|
11
|
+
not a drawn border. */
|
|
12
|
+
gap: var(--border-xs);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* ============================================
|
|
16
|
+
SEGMENT GEOMETRY
|
|
17
|
+
Outer silhouette stays the Button pill; the
|
|
18
|
+
meeting edges square off. The main segment's
|
|
19
|
+
inner-edge padding tightens one step - the
|
|
20
|
+
squared edge has no pill cap to clear, so
|
|
21
|
+
the resting 16px reads as a hole before the
|
|
22
|
+
chevron.
|
|
23
|
+
============================================ */
|
|
24
|
+
|
|
25
|
+
.ds-split-button .ds-split-button__main {
|
|
26
|
+
border-start-end-radius: var(--radius-xxs);
|
|
27
|
+
border-end-end-radius: var(--radius-xxs);
|
|
28
|
+
padding-inline-end: var(--padding-sm-md);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.ds-split-button .ds-split-button__main.ds-button--compact {
|
|
32
|
+
padding-inline-end: var(--padding-sm);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.ds-split-button .ds-split-button__trigger {
|
|
36
|
+
border-start-start-radius: var(--radius-xxs);
|
|
37
|
+
border-end-start-radius: var(--radius-xxs);
|
|
38
|
+
/* Standalone, CircularButton is a fixed circle; inside the split its
|
|
39
|
+
explicit height would block the wrapper's align-items: stretch, and
|
|
40
|
+
the main segment's height moves (secondary's border adds to it, a
|
|
41
|
+
loading spinner grows it) — so the flat edges overhang at the seam.
|
|
42
|
+
Let the flex stretch size it to the main segment instead. */
|
|
43
|
+
height: auto;
|
|
44
|
+
align-self: stretch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Optical centering: the trigger is flat on the left and a full round cap
|
|
48
|
+
on the right, so its visual mass sits left of the box centre (the
|
|
49
|
+
centroid of a square-plus-semicircle is ~5% of the width off-centre).
|
|
50
|
+
A geometrically centred chevron reads as pushed toward the cap; nudge
|
|
51
|
+
it toward the flat edge to match. */
|
|
52
|
+
.ds-split-button .ds-split-button__trigger .ds-circular-button__icon {
|
|
53
|
+
transform: translateX(-2px);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ds-split-button .ds-split-button__trigger.ds-circular-button--compact .ds-circular-button__icon {
|
|
57
|
+
transform: translateX(-1px);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ============================================
|
|
61
|
+
LOADING
|
|
62
|
+
The trigger is inert while the main segment
|
|
63
|
+
runs its async action, but it is busy, not
|
|
64
|
+
broken: Button and CircularButton both define
|
|
65
|
+
loading as full-colour appearance with a
|
|
66
|
+
progress cursor, and the disabled dim on one
|
|
67
|
+
half would split the pill into two unrelated
|
|
68
|
+
controls. When the main segment is disabled
|
|
69
|
+
as well, the whole control dims together and
|
|
70
|
+
this rule deliberately stands down.
|
|
71
|
+
============================================ */
|
|
72
|
+
|
|
73
|
+
.ds-split-button:has(.ds-button--loading:not(.ds-button--disabled)) .ds-split-button__trigger.ds-circular-button--disabled {
|
|
74
|
+
opacity: 1;
|
|
75
|
+
cursor: progress;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ============================================
|
|
79
|
+
SECONDARY
|
|
80
|
+
Both segments are outlined, so the gap would
|
|
81
|
+
put two hairlines side by side at the seam.
|
|
82
|
+
Collapse it: no gap, the trigger drops its
|
|
83
|
+
leading border, and the main segment's own
|
|
84
|
+
border is the one divider line.
|
|
85
|
+
============================================ */
|
|
86
|
+
|
|
87
|
+
.ds-split-button--secondary {
|
|
88
|
+
gap: 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.ds-split-button--secondary .ds-split-button__trigger {
|
|
92
|
+
border-inline-start: none;
|
|
93
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
import { DropdownMenuEntry } from '../DropdownMenu/DropdownMenu';
|
|
3
|
+
export type { DropdownMenuEntry as SplitButtonMenuEntry } from '../DropdownMenu/DropdownMenu';
|
|
4
|
+
/** Props owned by SplitButton itself — everything else falls through to the primary button. */
|
|
5
|
+
type SplitButtonOwnProps = {
|
|
6
|
+
/** Label of the primary action */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Click handler for the primary action */
|
|
9
|
+
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
|
10
|
+
/** Menu entries for the alternative actions */
|
|
11
|
+
items: DropdownMenuEntry[];
|
|
12
|
+
/** Visual treatment, shared by both segments */
|
|
13
|
+
variant?: 'primary' | 'secondary';
|
|
14
|
+
/** Component size */
|
|
15
|
+
size?: 'default' | 'compact';
|
|
16
|
+
/** Disables both segments */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Shows a spinner on the primary segment and blocks interaction while an async action runs */
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
/** Icon for the primary segment — Material Symbol name or custom element */
|
|
21
|
+
iconLeft?: string | React.ReactNode;
|
|
22
|
+
/** Horizontal alignment of the menu panel relative to the control */
|
|
23
|
+
align?: 'start' | 'end';
|
|
24
|
+
/** Accessible name of the menu trigger segment */
|
|
25
|
+
menuLabel?: string;
|
|
26
|
+
/** Additional CSS classes, applied to the wrapper around both segments, not the primary button */
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
export interface SplitButtonProps extends SplitButtonOwnProps, Omit<React.ComponentPropsWithoutRef<'button'>, keyof SplitButtonOwnProps | 'type'> {
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A primary action with an attached menu of alternatives — "Save" beside
|
|
33
|
+
* "Save as draft" and "Save as template". One pill silhouette, two segments:
|
|
34
|
+
* the label fires `onClick` directly, the chevron opens a DropdownMenu, so
|
|
35
|
+
* the default stays one click away while the variants stay discoverable.
|
|
36
|
+
*
|
|
37
|
+
* Forwards a ref to the primary segment's button and spreads unrecognised
|
|
38
|
+
* props onto that segment — while `className` composes onto the wrapper
|
|
39
|
+
* around both segments. Menu behaviour (keyboard, outside click, sub-menus)
|
|
40
|
+
* is DropdownMenu's, unchanged. Purely compositional (no 'use client'), like
|
|
41
|
+
* ButtonGroup: the interactivity lives in the pieces it assembles.
|
|
42
|
+
*/
|
|
43
|
+
export declare const SplitButton: React.ForwardRefExoticComponent<SplitButtonProps & React.RefAttributes<HTMLButtonElement>>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Button } from "../Button/Button.js";
|
|
4
|
+
import { CircularButton } from "../CircularButton/CircularButton.js";
|
|
5
|
+
import { DropdownMenu } from "../DropdownMenu/DropdownMenu.js";
|
|
6
|
+
import "./SplitButton.css";
|
|
7
|
+
const SplitButton = React.forwardRef(
|
|
8
|
+
({
|
|
9
|
+
label,
|
|
10
|
+
onClick,
|
|
11
|
+
items,
|
|
12
|
+
variant = "primary",
|
|
13
|
+
size = "default",
|
|
14
|
+
disabled = false,
|
|
15
|
+
loading = false,
|
|
16
|
+
iconLeft,
|
|
17
|
+
align = "end",
|
|
18
|
+
menuLabel = "More actions",
|
|
19
|
+
className = "",
|
|
20
|
+
...rest
|
|
21
|
+
}, ref) => {
|
|
22
|
+
const baseClass = "ds-split-button";
|
|
23
|
+
const classes = [baseClass, `${baseClass}--${variant}`, className].filter(Boolean).join(" ");
|
|
24
|
+
return /* @__PURE__ */ jsxs("span", { className: classes, children: [
|
|
25
|
+
/* @__PURE__ */ jsx(
|
|
26
|
+
Button,
|
|
27
|
+
{
|
|
28
|
+
...rest,
|
|
29
|
+
ref,
|
|
30
|
+
className: `${baseClass}__main`,
|
|
31
|
+
label,
|
|
32
|
+
iconLeft,
|
|
33
|
+
variant,
|
|
34
|
+
size,
|
|
35
|
+
disabled,
|
|
36
|
+
loading,
|
|
37
|
+
onClick
|
|
38
|
+
}
|
|
39
|
+
),
|
|
40
|
+
/* @__PURE__ */ jsx(
|
|
41
|
+
DropdownMenu,
|
|
42
|
+
{
|
|
43
|
+
className: `${baseClass}__menu`,
|
|
44
|
+
align,
|
|
45
|
+
size,
|
|
46
|
+
items,
|
|
47
|
+
trigger: /* @__PURE__ */ jsx(
|
|
48
|
+
CircularButton,
|
|
49
|
+
{
|
|
50
|
+
className: `${baseClass}__trigger`,
|
|
51
|
+
icon: "keyboard_arrow_down",
|
|
52
|
+
ariaLabel: menuLabel,
|
|
53
|
+
variant,
|
|
54
|
+
size,
|
|
55
|
+
disabled: disabled || loading
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
] });
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
SplitButton.displayName = "SplitButton";
|
|
64
|
+
export {
|
|
65
|
+
SplitButton
|
|
66
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
|
+
export type { StreamReveal, StreamRevealOptions, UseStreamRevealOptions, } from './useStreamReveal';
|
|
3
|
+
export { createStreamReveal, useStreamReveal } from './useStreamReveal';
|
|
2
4
|
/** Props owned by StreamingText itself — everything else falls through to the root span. */
|
|
3
5
|
type StreamingTextOwnProps = {
|
|
4
6
|
/**
|
|
@@ -13,9 +15,21 @@ type StreamingTextOwnProps = {
|
|
|
13
15
|
*/
|
|
14
16
|
streaming?: boolean;
|
|
15
17
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
18
|
+
* Slowest the reveal ever runs, in characters per second — the pace a
|
|
19
|
+
* thin trickle of chunks types at. Defaults to MOTION_STREAM_FLOOR_CPS.
|
|
20
|
+
*/
|
|
21
|
+
floorCps?: number;
|
|
22
|
+
/**
|
|
23
|
+
* However much text is waiting, it is fully on screen within this long,
|
|
24
|
+
* in milliseconds — the rate rises with the backlog. Defaults to
|
|
25
|
+
* MOTION_STREAM_DRAIN_MS.
|
|
26
|
+
*/
|
|
27
|
+
drainMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* The retired interval between reveal steps; when set, its equivalent
|
|
30
|
+
* rate becomes the reveal's floor.
|
|
31
|
+
* @deprecated The reveal is frame-driven now — pace it with `floorCps`
|
|
32
|
+
* and `drainMs` instead.
|
|
19
33
|
*/
|
|
20
34
|
charIntervalMs?: number;
|
|
21
35
|
/** Shows the blinking cursor while streaming or revealing. */
|
|
@@ -34,11 +48,15 @@ export interface StreamingTextProps extends StreamingTextOwnProps, Omit<React.Co
|
|
|
34
48
|
* StreamingText — the reveal for text that arrives in chunks: an LLM
|
|
35
49
|
* response typing itself out, with a cursor that blinks while more is
|
|
36
50
|
* coming. Feed it the accumulated text on every render and it animates
|
|
37
|
-
* through what was appended
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* the
|
|
51
|
+
* through what was appended from a frame loop that spends real elapsed
|
|
52
|
+
* time: a thin trickle types at the floor rate, and however much lands at
|
|
53
|
+
* once is on screen within the drain window. Under
|
|
54
|
+
* `prefers-reduced-motion` the reveal is skipped and each chunk appears
|
|
55
|
+
* whole. Announcement is the container's job — pair it with an
|
|
56
|
+
* `aria-live` region when the surrounding UI does not already announce
|
|
57
|
+
* the message. When the streamed text needs its own renderer (markdown
|
|
58
|
+
* through a parser), pace it with `useStreamReveal` or
|
|
59
|
+
* `createStreamReveal` from this folder instead — the same engine without
|
|
60
|
+
* the span.
|
|
42
61
|
*/
|
|
43
62
|
export declare const StreamingText: React.ForwardRefExoticComponent<StreamingTextProps & React.RefAttributes<HTMLSpanElement>>;
|
|
44
|
-
export {};
|
|
@@ -1,48 +1,34 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsxs, jsx } from "react/jsx-runtime";
|
|
3
|
-
import React, {
|
|
4
|
-
import {
|
|
3
|
+
import React, { useRef, useEffect } from "react";
|
|
4
|
+
import { useStreamReveal } from "./useStreamReveal.js";
|
|
5
|
+
import { createStreamReveal } from "./useStreamReveal.js";
|
|
5
6
|
import "./StreamingText.css";
|
|
6
7
|
const StreamingText = React.forwardRef(
|
|
7
8
|
({
|
|
8
9
|
text,
|
|
9
10
|
streaming = false,
|
|
10
|
-
|
|
11
|
+
floorCps,
|
|
12
|
+
drainMs,
|
|
13
|
+
charIntervalMs,
|
|
11
14
|
cursor = true,
|
|
12
15
|
onRevealComplete,
|
|
13
16
|
className = "",
|
|
14
17
|
...rest
|
|
15
18
|
}, ref) => {
|
|
16
19
|
const baseClass = "ds-streaming-text";
|
|
17
|
-
const
|
|
18
|
-
|
|
20
|
+
const { visible, caughtUp } = useStreamReveal(text, {
|
|
21
|
+
// The legacy interval's equivalent rate: one character per step.
|
|
22
|
+
floorCps: charIntervalMs != null ? 1e3 / charIntervalMs : floorCps,
|
|
23
|
+
drainMs
|
|
24
|
+
});
|
|
19
25
|
const completed = useRef(false);
|
|
20
26
|
useEffect(() => {
|
|
21
|
-
if (!
|
|
22
|
-
setRevealed(0);
|
|
27
|
+
if (!caughtUp) {
|
|
23
28
|
completed.current = false;
|
|
24
|
-
}
|
|
25
|
-
previousText.current = text;
|
|
26
|
-
}, [text]);
|
|
27
|
-
const caughtUp = revealed >= text.length;
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
if (caughtUp) return;
|
|
30
|
-
completed.current = false;
|
|
31
|
-
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
32
|
-
setRevealed(text.length);
|
|
33
29
|
return;
|
|
34
30
|
}
|
|
35
|
-
|
|
36
|
-
setRevealed((current) => {
|
|
37
|
-
const pending = text.length - current;
|
|
38
|
-
if (pending <= 0) return current;
|
|
39
|
-
return current + Math.max(1, Math.round(pending / 20));
|
|
40
|
-
});
|
|
41
|
-
}, charIntervalMs);
|
|
42
|
-
return () => clearInterval(interval);
|
|
43
|
-
}, [text, caughtUp, charIntervalMs]);
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (caughtUp && !streaming && text.length > 0 && !completed.current) {
|
|
31
|
+
if (!streaming && text.length > 0 && !completed.current) {
|
|
46
32
|
completed.current = true;
|
|
47
33
|
onRevealComplete?.();
|
|
48
34
|
}
|
|
@@ -50,12 +36,14 @@ const StreamingText = React.forwardRef(
|
|
|
50
36
|
const showCursor = cursor && (streaming || !caughtUp);
|
|
51
37
|
const classes = [baseClass, className].filter(Boolean).join(" ");
|
|
52
38
|
return /* @__PURE__ */ jsxs("span", { ...rest, ref, className: classes, children: [
|
|
53
|
-
|
|
39
|
+
visible,
|
|
54
40
|
showCursor && /* @__PURE__ */ jsx("span", { className: `${baseClass}__cursor`, "aria-hidden": "true" })
|
|
55
41
|
] });
|
|
56
42
|
}
|
|
57
43
|
);
|
|
58
44
|
StreamingText.displayName = "StreamingText";
|
|
59
45
|
export {
|
|
60
|
-
StreamingText
|
|
46
|
+
StreamingText,
|
|
47
|
+
createStreamReveal,
|
|
48
|
+
useStreamReveal
|
|
61
49
|
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface StreamRevealOptions {
|
|
2
|
+
/** Receives the visible slice each time the reveal moves it. */
|
|
3
|
+
onUpdate: (visible: string) => void;
|
|
4
|
+
/**
|
|
5
|
+
* Slowest the reveal ever runs, in characters per second. Defaults to
|
|
6
|
+
* MOTION_STREAM_FLOOR_CPS.
|
|
7
|
+
*/
|
|
8
|
+
floorCps?: number;
|
|
9
|
+
/**
|
|
10
|
+
* However much text is waiting, it is fully on screen within this long,
|
|
11
|
+
* in milliseconds. Defaults to MOTION_STREAM_DRAIN_MS.
|
|
12
|
+
*/
|
|
13
|
+
drainMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Whether appended text is paced at all. Pass false (or a function
|
|
16
|
+
* returning false — it is read on every append) to show each chunk
|
|
17
|
+
* whole: the reduced-motion path.
|
|
18
|
+
*/
|
|
19
|
+
paced?: boolean | (() => boolean);
|
|
20
|
+
}
|
|
21
|
+
export interface StreamReveal {
|
|
22
|
+
/** Text the source produced since the last call — paced onto the screen. */
|
|
23
|
+
append: (chunk: string) => void;
|
|
24
|
+
/** Everything that has arrived, on screen now — for stops and aborts. */
|
|
25
|
+
flush: () => void;
|
|
26
|
+
/**
|
|
27
|
+
* Replace what has arrived wholesale and show it immediately, unpaced —
|
|
28
|
+
* for text that is not the stream speaking (a notice, an error), or to
|
|
29
|
+
* seed text that should not animate.
|
|
30
|
+
*/
|
|
31
|
+
showWhole: (text: string) => void;
|
|
32
|
+
/** Drop everything and reveal `text` as a new message, from zero. */
|
|
33
|
+
restart: (text: string) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Resolves once the reveal has caught up with everything that arrived —
|
|
36
|
+
* immediately when it already has.
|
|
37
|
+
*/
|
|
38
|
+
drained: () => Promise<void>;
|
|
39
|
+
/** Stop the frame loop. Nothing more is revealed until the next append. */
|
|
40
|
+
cancel: () => void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Create a reveal for one streamed message. The caller owns rendering:
|
|
44
|
+
* `onUpdate` fires with the visible slice, at most once per frame.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createStreamReveal(options: StreamRevealOptions): StreamReveal;
|
|
47
|
+
export interface UseStreamRevealOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Slowest the reveal ever runs, in characters per second. Defaults to
|
|
50
|
+
* MOTION_STREAM_FLOOR_CPS.
|
|
51
|
+
*/
|
|
52
|
+
floorCps?: number;
|
|
53
|
+
/**
|
|
54
|
+
* However much text is waiting, it is fully on screen within this long,
|
|
55
|
+
* in milliseconds. Defaults to MOTION_STREAM_DRAIN_MS.
|
|
56
|
+
*/
|
|
57
|
+
drainMs?: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The declarative face of the engine, for React surfaces: feed it the
|
|
61
|
+
* accumulated text on every render and read back the paced slice. Text
|
|
62
|
+
* present on mount shows whole; a value that does not extend the previous
|
|
63
|
+
* one is a new message and reveals from zero. Checks
|
|
64
|
+
* `prefers-reduced-motion` itself, since no CSS guard can see a frame
|
|
65
|
+
* loop, and shows each chunk whole under it.
|
|
66
|
+
*/
|
|
67
|
+
export declare function useStreamReveal(text: string, { floorCps, drainMs }?: UseStreamRevealOptions): {
|
|
68
|
+
visible: string;
|
|
69
|
+
caughtUp: boolean;
|
|
70
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useRef, useEffect } from "react";
|
|
3
|
+
import { MOTION_STREAM_FLOOR_CPS, MOTION_STREAM_DRAIN_MS } from "../../tokens/motion.js";
|
|
4
|
+
function createStreamReveal(options) {
|
|
5
|
+
let arrived = "";
|
|
6
|
+
let revealed = 0;
|
|
7
|
+
let rafId = null;
|
|
8
|
+
let lastFrame = 0;
|
|
9
|
+
let resolvers = [];
|
|
10
|
+
const isPaced = () => {
|
|
11
|
+
const paced = options.paced;
|
|
12
|
+
return typeof paced === "function" ? paced() : paced !== false;
|
|
13
|
+
};
|
|
14
|
+
const settle = () => {
|
|
15
|
+
const pending = resolvers;
|
|
16
|
+
resolvers = [];
|
|
17
|
+
for (const resolve of pending) resolve();
|
|
18
|
+
};
|
|
19
|
+
const cancel = () => {
|
|
20
|
+
if (rafId != null) cancelAnimationFrame(rafId);
|
|
21
|
+
rafId = null;
|
|
22
|
+
lastFrame = 0;
|
|
23
|
+
};
|
|
24
|
+
const schedule = () => {
|
|
25
|
+
if (rafId == null) rafId = requestAnimationFrame(frame);
|
|
26
|
+
};
|
|
27
|
+
function frame(now) {
|
|
28
|
+
rafId = null;
|
|
29
|
+
const seconds = lastFrame === 0 ? 0 : (now - lastFrame) / 1e3;
|
|
30
|
+
lastFrame = now;
|
|
31
|
+
const backlog = arrived.length - revealed;
|
|
32
|
+
if (backlog > 0 && seconds > 0) {
|
|
33
|
+
const floorCps = options.floorCps ?? MOTION_STREAM_FLOOR_CPS;
|
|
34
|
+
const drainMs = options.drainMs ?? MOTION_STREAM_DRAIN_MS;
|
|
35
|
+
const cps = Math.max(floorCps, backlog / (drainMs / 1e3));
|
|
36
|
+
revealed = Math.min(arrived.length, revealed + Math.max(1, Math.round(cps * seconds)));
|
|
37
|
+
options.onUpdate(arrived.slice(0, revealed));
|
|
38
|
+
}
|
|
39
|
+
if (revealed < arrived.length) schedule();
|
|
40
|
+
else {
|
|
41
|
+
lastFrame = 0;
|
|
42
|
+
settle();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const flush = () => {
|
|
46
|
+
cancel();
|
|
47
|
+
if (revealed < arrived.length) {
|
|
48
|
+
revealed = arrived.length;
|
|
49
|
+
options.onUpdate(arrived);
|
|
50
|
+
}
|
|
51
|
+
settle();
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
append: (chunk) => {
|
|
55
|
+
if (chunk === "") return;
|
|
56
|
+
arrived += chunk;
|
|
57
|
+
if (isPaced()) schedule();
|
|
58
|
+
else flush();
|
|
59
|
+
},
|
|
60
|
+
flush,
|
|
61
|
+
showWhole: (text) => {
|
|
62
|
+
cancel();
|
|
63
|
+
arrived = text;
|
|
64
|
+
revealed = text.length;
|
|
65
|
+
options.onUpdate(text);
|
|
66
|
+
settle();
|
|
67
|
+
},
|
|
68
|
+
restart: (text) => {
|
|
69
|
+
cancel();
|
|
70
|
+
arrived = text;
|
|
71
|
+
revealed = 0;
|
|
72
|
+
options.onUpdate("");
|
|
73
|
+
if (arrived === "") return;
|
|
74
|
+
if (isPaced()) schedule();
|
|
75
|
+
else flush();
|
|
76
|
+
},
|
|
77
|
+
drained: () => revealed >= arrived.length ? Promise.resolve() : new Promise((resolve) => {
|
|
78
|
+
resolvers.push(resolve);
|
|
79
|
+
schedule();
|
|
80
|
+
}),
|
|
81
|
+
cancel
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function useStreamReveal(text, { floorCps, drainMs } = {}) {
|
|
85
|
+
const [revealedText, setRevealedText] = useState(text);
|
|
86
|
+
const engineRef = useRef(null);
|
|
87
|
+
const floorCpsRef = useRef(floorCps);
|
|
88
|
+
const drainMsRef = useRef(drainMs);
|
|
89
|
+
const previous = useRef(text);
|
|
90
|
+
useEffect(() => () => engineRef.current?.cancel(), []);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
floorCpsRef.current = floorCps;
|
|
93
|
+
drainMsRef.current = drainMs;
|
|
94
|
+
}, [floorCps, drainMs]);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (engineRef.current === null) {
|
|
97
|
+
engineRef.current = createStreamReveal({
|
|
98
|
+
onUpdate: setRevealedText,
|
|
99
|
+
paced: () => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
100
|
+
get floorCps() {
|
|
101
|
+
return floorCpsRef.current;
|
|
102
|
+
},
|
|
103
|
+
get drainMs() {
|
|
104
|
+
return drainMsRef.current;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
engineRef.current.showWhole(previous.current);
|
|
108
|
+
}
|
|
109
|
+
if (text === previous.current) return;
|
|
110
|
+
const engine = engineRef.current;
|
|
111
|
+
if (text.startsWith(previous.current)) engine.append(text.slice(previous.current.length));
|
|
112
|
+
else engine.restart(text);
|
|
113
|
+
previous.current = text;
|
|
114
|
+
}, [text]);
|
|
115
|
+
const visible = text.startsWith(revealedText) ? revealedText : "";
|
|
116
|
+
return { visible, caughtUp: visible.length >= text.length };
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
createStreamReveal,
|
|
120
|
+
useStreamReveal
|
|
121
|
+
};
|
package/components/registry.json
CHANGED
|
@@ -163,6 +163,14 @@
|
|
|
163
163
|
"category": "data-display",
|
|
164
164
|
"client": false
|
|
165
165
|
},
|
|
166
|
+
{
|
|
167
|
+
"name": "Banner",
|
|
168
|
+
"label": "Banner",
|
|
169
|
+
"slug": "banner",
|
|
170
|
+
"description": "Full-width status strip for page-level announcements, with an action slot and optional dismissal.",
|
|
171
|
+
"category": "feedback",
|
|
172
|
+
"client": false
|
|
173
|
+
},
|
|
166
174
|
{
|
|
167
175
|
"name": "BarChart",
|
|
168
176
|
"label": "Bar chart",
|
|
@@ -509,6 +517,22 @@
|
|
|
509
517
|
"category": "maps",
|
|
510
518
|
"client": true
|
|
511
519
|
},
|
|
520
|
+
{
|
|
521
|
+
"name": "HoverCard",
|
|
522
|
+
"label": "Hover card",
|
|
523
|
+
"slug": "hover-card",
|
|
524
|
+
"description": "Rich preview panel that opens from hover or focus, with interactive content and position options.",
|
|
525
|
+
"category": "overlays",
|
|
526
|
+
"client": true
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
"name": "ImageCompare",
|
|
530
|
+
"label": "Image compare",
|
|
531
|
+
"slug": "image-compare",
|
|
532
|
+
"description": "Before-and-after image comparison with a draggable divider, keyboard control, and corner labels.",
|
|
533
|
+
"category": "data-display",
|
|
534
|
+
"client": true
|
|
535
|
+
},
|
|
512
536
|
{
|
|
513
537
|
"name": "Input",
|
|
514
538
|
"label": "Input",
|
|
@@ -598,6 +622,14 @@
|
|
|
598
622
|
"category": "ai",
|
|
599
623
|
"client": false
|
|
600
624
|
},
|
|
625
|
+
{
|
|
626
|
+
"name": "Meter",
|
|
627
|
+
"label": "Meter",
|
|
628
|
+
"slug": "meter",
|
|
629
|
+
"description": "Level indicator for a known quantity, with a status-coloured fill and an optional value readout.",
|
|
630
|
+
"category": "feedback",
|
|
631
|
+
"client": false
|
|
632
|
+
},
|
|
601
633
|
{
|
|
602
634
|
"name": "ModelPicker",
|
|
603
635
|
"label": "Model picker",
|
|
@@ -737,6 +769,14 @@
|
|
|
737
769
|
"category": "forms",
|
|
738
770
|
"client": true
|
|
739
771
|
},
|
|
772
|
+
{
|
|
773
|
+
"name": "Rating",
|
|
774
|
+
"label": "Rating",
|
|
775
|
+
"slug": "rating",
|
|
776
|
+
"description": "Star-scale rating control with keyboard selection, a read-only mode, and a configurable icon.",
|
|
777
|
+
"category": "forms",
|
|
778
|
+
"client": true
|
|
779
|
+
},
|
|
740
780
|
{
|
|
741
781
|
"name": "Reasoning",
|
|
742
782
|
"label": "Reasoning",
|
|
@@ -826,6 +866,14 @@
|
|
|
826
866
|
"category": "feedback",
|
|
827
867
|
"client": false
|
|
828
868
|
},
|
|
869
|
+
{
|
|
870
|
+
"name": "SplitButton",
|
|
871
|
+
"label": "Split button",
|
|
872
|
+
"slug": "split-button",
|
|
873
|
+
"description": "Primary action with an attached menu of alternatives, composing Button and DropdownMenu in one pill.",
|
|
874
|
+
"category": "actions",
|
|
875
|
+
"client": false
|
|
876
|
+
},
|
|
829
877
|
{
|
|
830
878
|
"name": "SplitPane",
|
|
831
879
|
"label": "Split pane",
|