@robr0/design-system 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ /* ============================================
2
+ GAUGE COMPONENT
3
+ Radial dial for a single bounded reading.
4
+ Colour flows through `color` — the value arc
5
+ and reading draw with currentColor, so a tone
6
+ is one custom property swap.
7
+ ============================================ */
8
+
9
+ /* Base */
10
+
11
+ .ds-gauge {
12
+ position: relative;
13
+ display: inline-flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ flex-shrink: 0;
17
+ color: var(--color-action-primary-bg);
18
+ }
19
+
20
+ .ds-gauge__svg {
21
+ display: block;
22
+ }
23
+
24
+ /* Parts */
25
+
26
+ .ds-gauge__track {
27
+ fill: none;
28
+ stroke: var(--color-bg-container-tertiary);
29
+ stroke-linecap: round;
30
+ }
31
+
32
+ .ds-gauge__arc {
33
+ fill: none;
34
+ stroke: currentColor;
35
+ stroke-linecap: round;
36
+ transition: stroke-dasharray var(--motion-duration-slow) var(--motion-ease-emphasized);
37
+ }
38
+
39
+ .ds-gauge__center {
40
+ position: absolute;
41
+ inset: 0;
42
+ display: flex;
43
+ flex-direction: column;
44
+ align-items: center;
45
+ justify-content: center;
46
+ gap: var(--gap-xxs);
47
+ text-align: center;
48
+ pointer-events: none;
49
+ }
50
+
51
+ .ds-gauge__value {
52
+ font-family: var(--font-heading-3-family);
53
+ font-size: var(--font-heading-3-size);
54
+ font-weight: var(--font-heading-3-weight);
55
+ line-height: var(--font-heading-3-line-height);
56
+ letter-spacing: var(--font-heading-3-letter-spacing);
57
+ color: var(--color-text-primary);
58
+ }
59
+
60
+ .ds-gauge__label {
61
+ font-family: var(--font-caption-family);
62
+ font-size: var(--font-caption-size);
63
+ font-weight: var(--font-caption-weight);
64
+ line-height: var(--font-caption-line-height);
65
+ letter-spacing: var(--font-caption-letter-spacing);
66
+ color: var(--color-text-secondary);
67
+ }
68
+
69
+ /* Tones */
70
+
71
+ .ds-gauge--accent {
72
+ color: var(--color-action-primary-bg);
73
+ }
74
+
75
+ /* The status hues come from the border tokens, not the text tokens: the
76
+ border set is stable across themes, while the dark-mode text tints are
77
+ near-white and would wash three tones into one. */
78
+
79
+ .ds-gauge--positive {
80
+ color: var(--color-status-positive-border);
81
+ }
82
+
83
+ .ds-gauge--warning {
84
+ color: var(--color-status-warning-border);
85
+ }
86
+
87
+ .ds-gauge--error {
88
+ color: var(--color-status-error-border);
89
+ }
90
+
91
+ .ds-gauge--neutral {
92
+ color: var(--color-text-secondary);
93
+ }
@@ -0,0 +1,56 @@
1
+ import { default as React } from 'react';
2
+ /** One colour switch point: at or above `value`, the dial takes `tone`. */
3
+ export interface GaugeThreshold {
4
+ /** Reading at which this tone takes over. */
5
+ value: number;
6
+ /** Tone applied from this reading upward. */
7
+ tone: 'accent' | 'positive' | 'warning' | 'error' | 'neutral';
8
+ }
9
+ /** Props owned by Gauge itself — everything else falls through to the root div. */
10
+ type GaugeOwnProps = {
11
+ /** Current reading. Clamped into the `min`–`max` range for drawing. */
12
+ value: number;
13
+ /** Lower bound of the dial. */
14
+ min?: number;
15
+ /** Upper bound of the dial. */
16
+ max?: number;
17
+ /**
18
+ * Colour role for the value arc and reading. Ignored while a threshold
19
+ * matches — thresholds exist so the dial recolours itself as the reading
20
+ * crosses them.
21
+ */
22
+ tone?: 'accent' | 'positive' | 'warning' | 'error' | 'neutral';
23
+ /**
24
+ * Colour switch points, e.g. warning at 70 and error at 90. The highest
25
+ * threshold at or below the current reading wins; below them all, the
26
+ * `tone` prop applies.
27
+ */
28
+ thresholds?: GaugeThreshold[];
29
+ /** Shows the reading in the centre of the dial. */
30
+ showValue?: boolean;
31
+ /** Formats the centre reading — for units, precision, or locale. */
32
+ formatValue?: (value: number) => string;
33
+ /**
34
+ * What the reading measures, shown as a caption under it and used as the
35
+ * accessible name, e.g. "CPU usage".
36
+ */
37
+ label?: string;
38
+ /** Rendered diameter in pixels. The arc geometry scales with it. */
39
+ size?: number;
40
+ /** Arc thickness in pixels. */
41
+ strokeWidth?: number;
42
+ /** Additional CSS classes */
43
+ className?: string;
44
+ };
45
+ export interface GaugeProps extends GaugeOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof GaugeOwnProps | 'children'> {
46
+ }
47
+ /**
48
+ * Gauge — a radial dial for a single bounded reading: capacity, usage, a
49
+ * score against a target. Pure SVG computed from props — no charting library,
50
+ * no hooks — so it renders from a Server Component and drops straight into a
51
+ * Panel or Stat row. Thresholds recolour the dial through the status roles as
52
+ * the reading crosses them, and the arc animates between readings via a CSS
53
+ * transition. Announced as a `meter` with the label as its accessible name.
54
+ */
55
+ export declare const Gauge: React.ForwardRefExoticComponent<GaugeProps & React.RefAttributes<HTMLDivElement>>;
56
+ export {};
@@ -0,0 +1,95 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import "./Gauge.css";
4
+ const SWEEP_UNITS = 75;
5
+ const Gauge = React.forwardRef(
6
+ ({
7
+ value,
8
+ min = 0,
9
+ max = 100,
10
+ tone = "accent",
11
+ thresholds,
12
+ showValue = true,
13
+ formatValue,
14
+ label,
15
+ size = 120,
16
+ strokeWidth = 10,
17
+ className = "",
18
+ ...rest
19
+ }, ref) => {
20
+ const baseClass = "ds-gauge";
21
+ const range = max - min;
22
+ const fraction = range > 0 ? Math.min(Math.max((value - min) / range, 0), 1) : 0;
23
+ const activeThreshold = thresholds?.filter((t) => value >= t.value).sort((a, b) => b.value - a.value)[0];
24
+ const resolvedTone = activeThreshold?.tone ?? tone;
25
+ const classes = [baseClass, `${baseClass}--${resolvedTone}`, className].filter(Boolean).join(" ");
26
+ const radius = (size - strokeWidth) / 2;
27
+ const center = size / 2;
28
+ const valueUnits = Math.round(SWEEP_UNITS * fraction * 100) / 100;
29
+ const displayValue = formatValue ? formatValue(value) : String(Math.round(value));
30
+ return /* @__PURE__ */ jsxs(
31
+ "div",
32
+ {
33
+ ...rest,
34
+ ref,
35
+ className: classes,
36
+ style: { width: size, height: size, ...rest.style },
37
+ role: "meter",
38
+ "aria-valuenow": value,
39
+ "aria-valuemin": min,
40
+ "aria-valuemax": max,
41
+ "aria-valuetext": displayValue,
42
+ "aria-label": label ?? rest["aria-label"],
43
+ children: [
44
+ /* @__PURE__ */ jsxs(
45
+ "svg",
46
+ {
47
+ className: `${baseClass}__svg`,
48
+ viewBox: `0 0 ${size} ${size}`,
49
+ width: size,
50
+ height: size,
51
+ xmlns: "http://www.w3.org/2000/svg",
52
+ "aria-hidden": "true",
53
+ children: [
54
+ /* @__PURE__ */ jsx(
55
+ "circle",
56
+ {
57
+ className: `${baseClass}__track`,
58
+ cx: center,
59
+ cy: center,
60
+ r: radius,
61
+ pathLength: 100,
62
+ strokeWidth,
63
+ strokeDasharray: `${SWEEP_UNITS} ${100 - SWEEP_UNITS}`,
64
+ transform: `rotate(135 ${center} ${center})`
65
+ }
66
+ ),
67
+ valueUnits > 0 && /* @__PURE__ */ jsx(
68
+ "circle",
69
+ {
70
+ className: `${baseClass}__arc`,
71
+ cx: center,
72
+ cy: center,
73
+ r: radius,
74
+ pathLength: 100,
75
+ strokeWidth,
76
+ strokeDasharray: `${valueUnits} ${100 - valueUnits}`,
77
+ transform: `rotate(135 ${center} ${center})`
78
+ }
79
+ )
80
+ ]
81
+ }
82
+ ),
83
+ (showValue || label) && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__center`, children: [
84
+ showValue && /* @__PURE__ */ jsx("span", { className: `${baseClass}__value`, children: displayValue }),
85
+ label && /* @__PURE__ */ jsx("span", { className: `${baseClass}__label`, children: label })
86
+ ] })
87
+ ]
88
+ }
89
+ );
90
+ }
91
+ );
92
+ Gauge.displayName = "Gauge";
93
+ export {
94
+ Gauge
95
+ };
@@ -0,0 +1,106 @@
1
+ /* ============================================
2
+ SPLIT PANE COMPONENT
3
+ Two resizable regions with a draggable
4
+ divider. The split rides a custom property
5
+ set from JS; everything visual is tokens.
6
+ ============================================ */
7
+
8
+ /* Base */
9
+
10
+ .ds-split-pane {
11
+ display: flex;
12
+ width: 100%;
13
+ height: 100%;
14
+ min-width: 0;
15
+ min-height: 0;
16
+ }
17
+
18
+ .ds-split-pane--horizontal {
19
+ flex-direction: row;
20
+ }
21
+
22
+ .ds-split-pane--vertical {
23
+ flex-direction: column;
24
+ }
25
+
26
+ /* While dragging, suppress text selection everywhere in the container so a
27
+ fast drag doesn't paint selections through the panes. */
28
+ .ds-split-pane--dragging {
29
+ user-select: none;
30
+ }
31
+
32
+ /* Panes clip; scrolling belongs to a consumer-owned container inside the
33
+ pane, which can then be focusable (a bare scrollable region fails the
34
+ axe scrollable-region-focusable rule, and a pane that is always a tab
35
+ stop would be worse). */
36
+
37
+ .ds-split-pane__pane {
38
+ overflow: hidden;
39
+ min-width: 0;
40
+ min-height: 0;
41
+ }
42
+
43
+ .ds-split-pane__pane--first {
44
+ flex: 0 0 var(--ds-split-pane-split, 50%);
45
+ }
46
+
47
+ .ds-split-pane__pane--second {
48
+ flex: 1 1 0;
49
+ }
50
+
51
+ /* Separator */
52
+
53
+ .ds-split-pane__separator {
54
+ flex: 0 0 auto;
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: center;
58
+ background-color: transparent;
59
+ touch-action: none;
60
+ transition: background-color var(--motion-duration-fast) var(--motion-ease-standard);
61
+ }
62
+
63
+ .ds-split-pane--horizontal .ds-split-pane__separator {
64
+ width: var(--gap-sm);
65
+ cursor: col-resize;
66
+ }
67
+
68
+ .ds-split-pane--vertical .ds-split-pane__separator {
69
+ height: var(--gap-sm);
70
+ cursor: row-resize;
71
+ }
72
+
73
+ .ds-split-pane__separator:hover,
74
+ .ds-split-pane--dragging .ds-split-pane__separator {
75
+ background-color: var(--color-bg-container-secondary);
76
+ }
77
+
78
+ .ds-split-pane__separator:focus-visible {
79
+ outline: var(--border-md) solid var(--color-action-primary-bg);
80
+ outline-offset: calc(var(--border-md) * -1);
81
+ border-radius: var(--radius-xxs);
82
+ }
83
+
84
+ /* Grip */
85
+
86
+ .ds-split-pane__grip {
87
+ display: block;
88
+ border-radius: var(--radius-full);
89
+ background-color: var(--color-bg-container-border);
90
+ transition: background-color var(--motion-duration-fast) var(--motion-ease-standard);
91
+ }
92
+
93
+ .ds-split-pane--horizontal .ds-split-pane__grip {
94
+ width: var(--gap-xxs);
95
+ height: var(--gap-xl);
96
+ }
97
+
98
+ .ds-split-pane--vertical .ds-split-pane__grip {
99
+ width: var(--gap-xl);
100
+ height: var(--gap-xxs);
101
+ }
102
+
103
+ .ds-split-pane__separator:hover .ds-split-pane__grip,
104
+ .ds-split-pane--dragging .ds-split-pane__grip {
105
+ background-color: var(--color-icon-secondary);
106
+ }
@@ -0,0 +1,36 @@
1
+ import { default as React } from 'react';
2
+ /** Props owned by SplitPane itself — everything else falls through to the root div. */
3
+ type SplitPaneOwnProps = {
4
+ /** The two panes, in order. Children beyond the first two are ignored. */
5
+ children: React.ReactNode;
6
+ /** Which way the panes sit: side by side, or stacked. */
7
+ direction?: 'horizontal' | 'vertical';
8
+ /** First pane's share as a percentage (controlled). Pair with `onSplitChange`. */
9
+ split?: number;
10
+ /** First pane's share as a percentage (uncontrolled initial value). */
11
+ defaultSplit?: number;
12
+ /** Smallest share the first pane can be dragged to, as a percentage. */
13
+ minSplit?: number;
14
+ /** Largest share the first pane can be dragged to, as a percentage. */
15
+ maxSplit?: number;
16
+ /** Fires with the new percentage on every drag step or keyboard resize. */
17
+ onSplitChange?: (split: number) => void;
18
+ /** Accessible name for the resize handle. */
19
+ separatorLabel?: string;
20
+ /** Additional CSS classes */
21
+ className?: string;
22
+ };
23
+ export interface SplitPaneProps extends SplitPaneOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof SplitPaneOwnProps> {
24
+ }
25
+ /**
26
+ * SplitPane — two resizable regions with a draggable divider: the sidebar
27
+ * and canvas, the list and detail, the editor and preview. The split is a
28
+ * percentage, so it survives container resizes. The divider is a real
29
+ * `separator`: focusable, arrow keys nudge it (Shift for big steps, Home/End
30
+ * to the limits), and pointer drags use capture so a fast drag can't escape
31
+ * it. Panes clip their content rather than growing the page; a region that
32
+ * should scroll brings its own focusable scroll container, so keyboard
33
+ * users can reach it.
34
+ */
35
+ export declare const SplitPane: React.ForwardRefExoticComponent<SplitPaneProps & React.RefAttributes<HTMLDivElement>>;
36
+ export {};
@@ -0,0 +1,130 @@
1
+ "use client";
2
+ import { jsxs, jsx } from "react/jsx-runtime";
3
+ import React, { useState, useRef } from "react";
4
+ import "./SplitPane.css";
5
+ const KEY_STEP = 2;
6
+ const KEY_STEP_LARGE = 10;
7
+ const SplitPane = React.forwardRef(
8
+ ({
9
+ children,
10
+ direction = "horizontal",
11
+ split,
12
+ defaultSplit = 50,
13
+ minSplit = 10,
14
+ maxSplit = 90,
15
+ onSplitChange,
16
+ separatorLabel = "Resize panes",
17
+ className = "",
18
+ ...rest
19
+ }, ref) => {
20
+ const baseClass = "ds-split-pane";
21
+ const [uncontrolledSplit, setUncontrolledSplit] = useState(defaultSplit);
22
+ const [dragging, setDragging] = useState(false);
23
+ const draggingRef = useRef(false);
24
+ const internalRef = useRef(null);
25
+ const setRef = (node) => {
26
+ internalRef.current = node;
27
+ if (typeof ref === "function") ref(node);
28
+ else if (ref) ref.current = node;
29
+ };
30
+ const clamp = (value) => Math.min(Math.max(value, minSplit), maxSplit);
31
+ const currentSplit = clamp(split ?? uncontrolledSplit);
32
+ const applySplit = (value) => {
33
+ const next = clamp(value);
34
+ if (split === void 0) setUncontrolledSplit(next);
35
+ onSplitChange?.(next);
36
+ };
37
+ const splitFromPointer = (e) => {
38
+ const rect = internalRef.current?.getBoundingClientRect();
39
+ if (!rect) return;
40
+ const fraction = direction === "horizontal" ? (e.clientX - rect.left) / rect.width : (e.clientY - rect.top) / rect.height;
41
+ applySplit(fraction * 100);
42
+ };
43
+ const handlePointerDown = (e) => {
44
+ e.currentTarget.setPointerCapture(e.pointerId);
45
+ draggingRef.current = true;
46
+ setDragging(true);
47
+ splitFromPointer(e);
48
+ };
49
+ const handlePointerMove = (e) => {
50
+ if (!draggingRef.current) return;
51
+ splitFromPointer(e);
52
+ };
53
+ const handlePointerUp = (e) => {
54
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
55
+ e.currentTarget.releasePointerCapture(e.pointerId);
56
+ }
57
+ draggingRef.current = false;
58
+ setDragging(false);
59
+ };
60
+ const handleKeyDown = (e) => {
61
+ const step = e.shiftKey ? KEY_STEP_LARGE : KEY_STEP;
62
+ const shrinkKey = direction === "horizontal" ? "ArrowLeft" : "ArrowUp";
63
+ const growKey = direction === "horizontal" ? "ArrowRight" : "ArrowDown";
64
+ switch (e.key) {
65
+ case shrinkKey:
66
+ e.preventDefault();
67
+ applySplit(currentSplit - step);
68
+ break;
69
+ case growKey:
70
+ e.preventDefault();
71
+ applySplit(currentSplit + step);
72
+ break;
73
+ case "Home":
74
+ e.preventDefault();
75
+ applySplit(minSplit);
76
+ break;
77
+ case "End":
78
+ e.preventDefault();
79
+ applySplit(maxSplit);
80
+ break;
81
+ }
82
+ };
83
+ const classes = [
84
+ baseClass,
85
+ `${baseClass}--${direction}`,
86
+ dragging && `${baseClass}--dragging`,
87
+ className
88
+ ].filter(Boolean).join(" ");
89
+ const [first, second] = React.Children.toArray(children);
90
+ return /* @__PURE__ */ jsxs(
91
+ "div",
92
+ {
93
+ ...rest,
94
+ ref: setRef,
95
+ className: classes,
96
+ style: {
97
+ "--ds-split-pane-split": `${currentSplit}%`,
98
+ ...rest.style
99
+ },
100
+ children: [
101
+ /* @__PURE__ */ jsx("div", { className: `${baseClass}__pane ${baseClass}__pane--first`, children: first }),
102
+ /* @__PURE__ */ jsx(
103
+ "div",
104
+ {
105
+ className: `${baseClass}__separator`,
106
+ role: "separator",
107
+ tabIndex: 0,
108
+ "aria-label": separatorLabel,
109
+ "aria-orientation": direction === "horizontal" ? "vertical" : "horizontal",
110
+ "aria-valuenow": Math.round(currentSplit),
111
+ "aria-valuemin": Math.round(minSplit),
112
+ "aria-valuemax": Math.round(maxSplit),
113
+ onPointerDown: handlePointerDown,
114
+ onPointerMove: handlePointerMove,
115
+ onPointerUp: handlePointerUp,
116
+ onPointerCancel: handlePointerUp,
117
+ onKeyDown: handleKeyDown,
118
+ children: /* @__PURE__ */ jsx("span", { className: `${baseClass}__grip`, "aria-hidden": "true" })
119
+ }
120
+ ),
121
+ /* @__PURE__ */ jsx("div", { className: `${baseClass}__pane ${baseClass}__pane--second`, children: second })
122
+ ]
123
+ }
124
+ );
125
+ }
126
+ );
127
+ SplitPane.displayName = "SplitPane";
128
+ export {
129
+ SplitPane
130
+ };
@@ -0,0 +1,40 @@
1
+ /* ============================================
2
+ STREAMING TEXT COMPONENT
3
+ Progressive reveal for text arriving in
4
+ chunks, with a blinking cursor. Inherits the
5
+ surrounding typography — the reveal is not a
6
+ text style of its own.
7
+ ============================================ */
8
+
9
+ /* Base */
10
+
11
+ .ds-streaming-text {
12
+ white-space: pre-wrap;
13
+ overflow-wrap: break-word;
14
+ }
15
+
16
+ /* Cursor */
17
+
18
+ .ds-streaming-text__cursor {
19
+ display: inline-block;
20
+ width: 0.55em;
21
+ height: 1em;
22
+ margin-left: 0.1em;
23
+ vertical-align: text-bottom;
24
+ border-radius: var(--radius-xxs);
25
+ background-color: currentColor;
26
+ animation: ds-streaming-text-blink var(--motion-duration-loop-spin) var(--motion-ease-standard)
27
+ infinite;
28
+ }
29
+
30
+ @keyframes ds-streaming-text-blink {
31
+ 0%,
32
+ 45% {
33
+ opacity: 1;
34
+ }
35
+
36
+ 55%,
37
+ 100% {
38
+ opacity: 0.15;
39
+ }
40
+ }
@@ -0,0 +1,44 @@
1
+ import { default as React } from 'react';
2
+ /** Props owned by StreamingText itself — everything else falls through to the root span. */
3
+ type StreamingTextOwnProps = {
4
+ /**
5
+ * The text received so far. Grow it across renders as chunks arrive; the
6
+ * reveal animates through the appended part. A value that does not extend
7
+ * the previous one is treated as a new message and reveals from the start.
8
+ */
9
+ text: string;
10
+ /**
11
+ * Whether the source is still producing text. Keeps the cursor visible
12
+ * between chunks, when the reveal has caught up but more may arrive.
13
+ */
14
+ streaming?: boolean;
15
+ /**
16
+ * Milliseconds between reveal steps. The reveal adds more characters per
17
+ * step the further it falls behind, so a large chunk catches up instead of
18
+ * typing for seconds.
19
+ */
20
+ charIntervalMs?: number;
21
+ /** Shows the blinking cursor while streaming or revealing. */
22
+ cursor?: boolean;
23
+ /**
24
+ * Fires once when the reveal catches up with `text` after `streaming` has
25
+ * ended — the moment the message is fully on screen.
26
+ */
27
+ onRevealComplete?: () => void;
28
+ /** Additional CSS classes */
29
+ className?: string;
30
+ };
31
+ export interface StreamingTextProps extends StreamingTextOwnProps, Omit<React.ComponentPropsWithoutRef<'span'>, keyof StreamingTextOwnProps | 'children'> {
32
+ }
33
+ /**
34
+ * StreamingText — the reveal for text that arrives in chunks: an LLM
35
+ * response typing itself out, with a cursor that blinks while more is
36
+ * coming. Feed it the accumulated text on every render and it animates
37
+ * through what was appended, catching up faster the further behind it
38
+ * falls. Under `prefers-reduced-motion` the reveal is skipped and each
39
+ * chunk appears whole. Announcement is the container's job — pair it with
40
+ * an `aria-live` region when the surrounding UI does not already announce
41
+ * the message.
42
+ */
43
+ export declare const StreamingText: React.ForwardRefExoticComponent<StreamingTextProps & React.RefAttributes<HTMLSpanElement>>;
44
+ export {};
@@ -0,0 +1,61 @@
1
+ "use client";
2
+ import { jsxs, jsx } from "react/jsx-runtime";
3
+ import React, { useState, useRef, useEffect } from "react";
4
+ import { MOTION_STREAM_CHAR_INTERVAL_MS } from "../../tokens/motion.js";
5
+ import "./StreamingText.css";
6
+ const StreamingText = React.forwardRef(
7
+ ({
8
+ text,
9
+ streaming = false,
10
+ charIntervalMs = MOTION_STREAM_CHAR_INTERVAL_MS,
11
+ cursor = true,
12
+ onRevealComplete,
13
+ className = "",
14
+ ...rest
15
+ }, ref) => {
16
+ const baseClass = "ds-streaming-text";
17
+ const [revealed, setRevealed] = useState(text.length);
18
+ const previousText = useRef(text);
19
+ const completed = useRef(false);
20
+ useEffect(() => {
21
+ if (!text.startsWith(previousText.current)) {
22
+ setRevealed(0);
23
+ 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
+ return;
34
+ }
35
+ const interval = setInterval(() => {
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) {
46
+ completed.current = true;
47
+ onRevealComplete?.();
48
+ }
49
+ }, [caughtUp, streaming, text, onRevealComplete]);
50
+ const showCursor = cursor && (streaming || !caughtUp);
51
+ const classes = [baseClass, className].filter(Boolean).join(" ");
52
+ return /* @__PURE__ */ jsxs("span", { ...rest, ref, className: classes, children: [
53
+ caughtUp ? text : text.slice(0, revealed),
54
+ showCursor && /* @__PURE__ */ jsx("span", { className: `${baseClass}__cursor`, "aria-hidden": "true" })
55
+ ] });
56
+ }
57
+ );
58
+ StreamingText.displayName = "StreamingText";
59
+ export {
60
+ StreamingText
61
+ };