@robr0/design-system 0.12.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/AvatarGroup/AvatarGroup.css +61 -0
- package/components/AvatarGroup/AvatarGroup.d.ts +32 -0
- package/components/AvatarGroup/AvatarGroup.js +31 -0
- 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/FilterBar/FilterBar.css +202 -0
- package/components/FilterBar/FilterBar.d.ts +55 -0
- package/components/FilterBar/FilterBar.js +249 -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 +110 -0
- package/components/Gauge/Gauge.d.ts +64 -0
- package/components/Gauge/Gauge.js +111 -0
- 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/SplitPane/SplitPane.css +106 -0
- package/components/SplitPane/SplitPane.d.ts +36 -0
- package/components/SplitPane/SplitPane.js +130 -0
- package/components/StreamingText/StreamingText.css +40 -0
- package/components/StreamingText/StreamingText.d.ts +62 -0
- package/components/StreamingText/StreamingText.js +49 -0
- package/components/StreamingText/useStreamReveal.d.ts +70 -0
- package/components/StreamingText/useStreamReveal.js +121 -0
- package/components/registry.json +88 -0
- package/components/registry.json.d.ts +88 -0
- package/components/registry.json.js +1 -1
- package/index.d.ts +11 -0
- package/index.js +25 -0
- package/package.json +5 -1
- package/tokens/motion.d.ts +12 -3
- package/tokens/motion.js +7 -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
|
+
};
|
|
@@ -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,62 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
export type { StreamReveal, StreamRevealOptions, UseStreamRevealOptions, } from './useStreamReveal';
|
|
3
|
+
export { createStreamReveal, useStreamReveal } from './useStreamReveal';
|
|
4
|
+
/** Props owned by StreamingText itself — everything else falls through to the root span. */
|
|
5
|
+
type StreamingTextOwnProps = {
|
|
6
|
+
/**
|
|
7
|
+
* The text received so far. Grow it across renders as chunks arrive; the
|
|
8
|
+
* reveal animates through the appended part. A value that does not extend
|
|
9
|
+
* the previous one is treated as a new message and reveals from the start.
|
|
10
|
+
*/
|
|
11
|
+
text: string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the source is still producing text. Keeps the cursor visible
|
|
14
|
+
* between chunks, when the reveal has caught up but more may arrive.
|
|
15
|
+
*/
|
|
16
|
+
streaming?: boolean;
|
|
17
|
+
/**
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
charIntervalMs?: number;
|
|
35
|
+
/** Shows the blinking cursor while streaming or revealing. */
|
|
36
|
+
cursor?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Fires once when the reveal catches up with `text` after `streaming` has
|
|
39
|
+
* ended — the moment the message is fully on screen.
|
|
40
|
+
*/
|
|
41
|
+
onRevealComplete?: () => void;
|
|
42
|
+
/** Additional CSS classes */
|
|
43
|
+
className?: string;
|
|
44
|
+
};
|
|
45
|
+
export interface StreamingTextProps extends StreamingTextOwnProps, Omit<React.ComponentPropsWithoutRef<'span'>, keyof StreamingTextOwnProps | 'children'> {
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* StreamingText — the reveal for text that arrives in chunks: an LLM
|
|
49
|
+
* response typing itself out, with a cursor that blinks while more is
|
|
50
|
+
* coming. Feed it the accumulated text on every render and it animates
|
|
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.
|
|
61
|
+
*/
|
|
62
|
+
export declare const StreamingText: React.ForwardRefExoticComponent<StreamingTextProps & React.RefAttributes<HTMLSpanElement>>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
3
|
+
import React, { useRef, useEffect } from "react";
|
|
4
|
+
import { useStreamReveal } from "./useStreamReveal.js";
|
|
5
|
+
import { createStreamReveal } from "./useStreamReveal.js";
|
|
6
|
+
import "./StreamingText.css";
|
|
7
|
+
const StreamingText = React.forwardRef(
|
|
8
|
+
({
|
|
9
|
+
text,
|
|
10
|
+
streaming = false,
|
|
11
|
+
floorCps,
|
|
12
|
+
drainMs,
|
|
13
|
+
charIntervalMs,
|
|
14
|
+
cursor = true,
|
|
15
|
+
onRevealComplete,
|
|
16
|
+
className = "",
|
|
17
|
+
...rest
|
|
18
|
+
}, ref) => {
|
|
19
|
+
const baseClass = "ds-streaming-text";
|
|
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
|
+
});
|
|
25
|
+
const completed = useRef(false);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!caughtUp) {
|
|
28
|
+
completed.current = false;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!streaming && text.length > 0 && !completed.current) {
|
|
32
|
+
completed.current = true;
|
|
33
|
+
onRevealComplete?.();
|
|
34
|
+
}
|
|
35
|
+
}, [caughtUp, streaming, text, onRevealComplete]);
|
|
36
|
+
const showCursor = cursor && (streaming || !caughtUp);
|
|
37
|
+
const classes = [baseClass, className].filter(Boolean).join(" ");
|
|
38
|
+
return /* @__PURE__ */ jsxs("span", { ...rest, ref, className: classes, children: [
|
|
39
|
+
visible,
|
|
40
|
+
showCursor && /* @__PURE__ */ jsx("span", { className: `${baseClass}__cursor`, "aria-hidden": "true" })
|
|
41
|
+
] });
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
StreamingText.displayName = "StreamingText";
|
|
45
|
+
export {
|
|
46
|
+
StreamingText,
|
|
47
|
+
createStreamReveal,
|
|
48
|
+
useStreamReveal
|
|
49
|
+
};
|