@robr0/design-system 0.14.0 → 0.16.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 +11 -8
- package/behaviors/focusable.d.ts +26 -0
- package/behaviors/focusable.js +18 -0
- package/behaviors/useFocusScope.d.ts +38 -0
- package/behaviors/useFocusScope.js +82 -0
- package/behaviors/useLayer.d.ts +43 -0
- package/behaviors/useLayer.js +55 -0
- package/behaviors/useMounted.d.ts +7 -0
- package/behaviors/useMounted.js +14 -0
- package/behaviors/useScrollLock.d.ts +55 -0
- package/behaviors/useScrollLock.js +59 -0
- package/charts.d.ts +1 -0
- package/charts.js +2 -0
- package/components/AgentPlan/AgentPlan.css +12 -3
- package/components/AiButton/AiButton.css +327 -0
- package/components/AiButton/AiButton.d.ts +42 -0
- package/components/AiButton/AiButton.js +140 -16
- package/components/AlertDialog/AlertDialog.js +13 -58
- package/components/AnchorNav/AnchorNav.css +74 -0
- package/components/AnchorNav/AnchorNav.d.ts +7 -0
- package/components/AnchorNav/AnchorNav.js +74 -21
- package/components/Button/Button.css +54 -0
- package/components/Button/Button.d.ts +2 -2
- package/components/Chart/Chart.css +4 -1
- package/components/CircularButton/CircularButton.css +46 -0
- package/components/CircularButton/CircularButton.d.ts +13 -2
- package/components/CircularButton/CircularButton.js +19 -16
- package/components/Combobox/Combobox.js +2 -1
- package/components/CommandPalette/CommandPalette.css +36 -8
- package/components/CommandPalette/CommandPalette.js +9 -54
- package/components/Composer/Composer.css +72 -0
- package/components/Composer/Composer.d.ts +25 -6
- package/components/Composer/Composer.js +8 -0
- package/components/ContributionGraph/ContributionGraph.css +21 -0
- package/components/ContributionGraph/ContributionGraph.js +18 -10
- package/components/Dialog/Dialog.js +9 -62
- package/components/Drawer/Drawer.js +9 -62
- package/components/Dropdown/Dropdown.js +2 -1
- package/components/DropdownMenu/DropdownMenu.js +11 -3
- package/components/Figure/Figure.css +14 -4
- package/components/Figure/Figure.js +11 -20
- package/components/FunnelChart/FunnelChart.css +11 -36
- package/components/FunnelChart/FunnelChart.d.ts +19 -16
- package/components/FunnelChart/FunnelChart.js +67 -39
- package/components/Gauge/Gauge.css +11 -1
- package/components/Meter/Meter.css +10 -1
- package/components/ModelPicker/ModelPicker.js +2 -1
- package/components/Popover/Popover.js +3 -0
- package/components/ProgressBar/ProgressBar.css +1 -1
- package/components/SegmentedControl/SegmentedControl.css +27 -2
- package/components/SegmentedControl/SegmentedControl.d.ts +3 -1
- package/components/SegmentedControl/SegmentedControl.js +3 -1
- package/components/Slider/Slider.css +1 -1
- package/components/Slider/Slider.js +1 -1
- package/components/SourceTrail/SourceTrail.css +209 -0
- package/components/SourceTrail/SourceTrail.d.ts +49 -0
- package/components/SourceTrail/SourceTrail.js +108 -0
- package/components/Sparkline/Sparkline.css +26 -2
- package/components/Sparkline/Sparkline.js +2 -1
- package/components/Spinner/Spinner.css +1 -1
- package/components/SplitButton/SplitButton.d.ts +1 -1
- package/components/SplitButton/SplitButton.js +1 -0
- package/components/TimePicker/TimePicker.js +2 -1
- package/components/ToggleGroup/ToggleGroup.css +17 -0
- package/components/ToggleGroup/ToggleGroup.d.ts +2 -0
- package/components/ToggleGroup/ToggleGroup.js +2 -0
- package/components/ToolCall/ToolCall.css +16 -3
- package/components/Tooltip/Tooltip.css +24 -7
- package/components/Tooltip/Tooltip.d.ts +2 -1
- package/components/Tooltip/Tooltip.js +6 -0
- package/components/UsageCard/UsageCard.css +30 -0
- package/components/UsageCard/UsageCard.d.ts +48 -0
- package/components/UsageCard/UsageCard.js +54 -0
- package/components/registry.d.ts +8 -2
- package/components/registry.json +43 -16
- package/components/registry.json.d.ts +43 -16
- package/components/registry.json.js +1 -1
- package/index.d.ts +2 -1
- package/index.js +4 -2
- package/package.json +5 -1
- package/tokens/motion.d.ts +10 -4
- package/tokens/motion.js +7 -1
- package/tokens/registry.json +10 -0
- package/tokens/registry.json.d.ts +10 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-dark.css +22 -2
- package/tokens/tokens-light.css +38 -11
- package/tokens/tokens-primitives.css +3 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ The **live site** is the portfolio built on the design system. **Storybook** is
|
|
|
18
18
|
| Part | Description |
|
|
19
19
|
|---|---|
|
|
20
20
|
| **Portfolio website** (`/website`) | Next.js app with case studies, work history, writing, and about pages, all built exclusively with the design system components below. **[Live site →](https://robertritacca.com/)** |
|
|
21
|
-
| **Design system** (`/src`) | <!-- component-count -->
|
|
21
|
+
| **Design system** (`/src`) | <!-- component-count -->122<!-- /component-count --> React components, a three-tier token architecture, dark mode, a WebGL2 ambient background that themes itself from your tokens, and a full documentation site. Built to production standards. **[Storybook →](https://design-system-iota-one.vercel.app/?path=/docs/robr0-ds--docs)** |
|
|
22
22
|
| **AI layer** (`ai` components in `/src`, chat in `/website`, `/evals`) | A site-wide chat that answers questions about the work: built from the library's own `ai` components, grounded in a corpus generated from the site's published content, and scored by a golden-set eval. An MCP endpoint serves the same public data, plus the component prop API, to any agent. |
|
|
23
23
|
|
|
24
24
|
---
|
|
@@ -28,7 +28,7 @@ The **live site** is the portfolio built on the design system. **Storybook** is
|
|
|
28
28
|
### Components
|
|
29
29
|
|
|
30
30
|
<!-- component-list:start -->
|
|
31
|
-
Accordion · Agent plan · Agent status · AI button · Alert · Alert dialog · Anchor nav · App layout · App sidebar · Area chart · Avatar · Avatar group · Badge · Banner · Bar chart · Breadcrumb · Button · Button group · Card · Card stack · Carousel · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Code diff · Colour picker · Combo chart · Combobox · Command palette · Composer · Contact card · Context menu · Contribution graph · Data table · Date input · Date picker · Dialog · Divider · Document chip · Drawer · Dropdown · Dropdown menu · Empty state · Entity card · Event calendar · Field · Figure · File input · Filter bar · Funnel chart · Gauge · Globe · Hover card · Image compare · Input · Instructions · Interrupt card · Kbd · Legend tile · Line chart · Link list · Map callout · Map legend · Message actions · Message card · Meter · Model picker · Nav · Nav list · Notification centre · Number input · Pagination · Panel · Pie chart · Pin input · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Rating · Reasoning · Scatter chart · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Sparkline · Spinner · Split button · Split pane · Stacked bar chart · Stat · Stepper · Streaming text · Swatch · Table · Tabs · Tag input · Textarea · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip · Tree view · Treemap
|
|
31
|
+
Accordion · Agent plan · Agent status · AI button · Alert · Alert dialog · Anchor nav · App layout · App sidebar · Area chart · Avatar · Avatar group · Badge · Banner · Bar chart · Breadcrumb · Button · Button group · Card · Card stack · Carousel · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Code diff · Colour picker · Combo chart · Combobox · Command palette · Composer · Contact card · Context menu · Contribution graph · Data table · Date input · Date picker · Dialog · Divider · Document chip · Drawer · Dropdown · Dropdown menu · Empty state · Entity card · Event calendar · Field · Figure · File input · Filter bar · Funnel chart · Gauge · Globe · Hover card · Image compare · Input · Instructions · Interrupt card · Kbd · Legend tile · Line chart · Link list · Map callout · Map legend · Message actions · Message card · Meter · Model picker · Nav · Nav list · Notification centre · Number input · Pagination · Panel · Pie chart · Pin input · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Rating · Reasoning · Scatter chart · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Source trail · Sparkline · Spinner · Split button · Split pane · Stacked bar chart · Stat · Stepper · Streaming text · Swatch · Table · Tabs · Tag input · Textarea · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip · Tree view · Treemap · Usage card
|
|
32
32
|
<!-- component-list:end -->
|
|
33
33
|
|
|
34
34
|
### Ambient background
|
|
@@ -71,7 +71,7 @@ import '@robr0/design-system/tokens/tokens.css';
|
|
|
71
71
|
import { Button, Card, Badge } from '@robr0/design-system';
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
Deep imports work too (`@robr0/design-system/components/Button/Button`). The Recharts-backed charts live behind a separate entry so the optional `recharts` peer dependency is only needed if you use them;
|
|
74
|
+
Deep imports work too (`@robr0/design-system/components/Button/Button`). The Recharts-backed charts live behind a separate entry so the optional `recharts` peer dependency is only needed if you use them; the dependency-free charts export from the main barrel with everything else:
|
|
75
75
|
|
|
76
76
|
```tsx
|
|
77
77
|
import { BarChart, LineChart } from '@robr0/design-system/charts';
|
|
@@ -84,7 +84,7 @@ import { BarChart, LineChart } from '@robr0/design-system/charts';
|
|
|
84
84
|
```css
|
|
85
85
|
:root { --font-family-primary: 'Inter', sans-serif; }
|
|
86
86
|
```
|
|
87
|
-
- **Colours, radius, spacing**: every semantic token chains to a primitive, so overriding a primitive re-themes everything built on it. The action colour is theme-split by design (light fills run teal-08/09/10, dark inverts to teal-05/04/03),
|
|
87
|
+
- **Colours, radius, spacing**: every semantic token chains to a primitive, so overriding a primitive re-themes everything built on it. The action colour is theme-split by design (light fills run teal-08/09/10, dark inverts to teal-05/04/03), and the six fill steps below are the core of a rebrand:
|
|
88
88
|
```css
|
|
89
89
|
:root {
|
|
90
90
|
--primitive-teal-08: #6D31D3; /* light fill */
|
|
@@ -96,6 +96,7 @@ import { BarChart, LineChart } from '@robr0/design-system/charts';
|
|
|
96
96
|
--primitive-radius-full: 12px; /* pill buttons become rounded rectangles */
|
|
97
97
|
}
|
|
98
98
|
```
|
|
99
|
+
A complete rebrand re-keys the whole teal ramp (01-10): the remaining steps carry the primary label, active icons, hover/selected input borders, and the AI gradient, so stopping at the six fills leaves those teal. The easiest way to get all of it right is the [playground](https://www.robertritacca.com/playground): restyle the system live and copy out a complete, paste-ready override.
|
|
99
100
|
|
|
100
101
|
Icons use a bundled Material Symbols Rounded variable font (woff2): components import it themselves, so they need no extra setup. If you render raw `.material-symbols-rounded` spans of your own, import `@robr0/design-system/fonts/material-symbols.css` once. Nunito Sans, the system's default typeface, is intentionally *not* bundled: load it yourself (e.g. Google Fonts or `next/font`) or override `--font-family-primary`.
|
|
101
102
|
|
|
@@ -142,6 +143,8 @@ The AI layer also serves machines directly. The live site exposes a Model Contex
|
|
|
142
143
|
claude mcp add --transport http robr0-ds https://robertritacca.com/api/mcp
|
|
143
144
|
```
|
|
144
145
|
|
|
146
|
+
The same generated data is served as files too: every component's prop contract lives at `https://robertritacca.com/components/<slug>.md` beside its docs page, and a generated agent skill (a `SKILL.md` plus its `references/components.md` catalogue, under `https://robertritacca.com/skill/robr0-design-system/`) can be saved as a pair into a project's own skills folder so a coding agent carries the library's install, theming and catalogue rules into every session. The [setup guide](https://robertritacca.com/docs/get-started) has the two-command install.
|
|
147
|
+
|
|
145
148
|
---
|
|
146
149
|
|
|
147
150
|
## Tech
|
|
@@ -150,20 +153,20 @@ claude mcp add --transport http robr0-ds https://robertritacca.com/api/mcp
|
|
|
150
153
|
- **Vite 7**: dev server and library build
|
|
151
154
|
- **Next.js 16**: portfolio site and design system documentation
|
|
152
155
|
- **Storybook 10**: component explorer
|
|
153
|
-
- **Vitest + Playwright + axe**: every Storybook story runs as a render test in headless Chromium, with an accessibility audit on each
|
|
156
|
+
- **Vitest + Playwright + axe**: every Storybook story runs as a render test in headless Chromium, with an accessibility audit on each and interaction assertions where a story carries a play function
|
|
154
157
|
- **CSS custom properties**: all theming via semantic tokens, no CSS-in-JS
|
|
155
158
|
|
|
156
159
|
---
|
|
157
160
|
|
|
158
161
|
## Quality & CI
|
|
159
162
|
|
|
160
|
-
Every push and pull request runs a GitHub Actions pipeline ([`ci.yml`](.github/workflows/ci.yml)) with four jobs: lint + library build, story tests, Storybook build, and website lint + build followed by the
|
|
163
|
+
Every push and pull request runs a GitHub Actions pipeline ([`ci.yml`](.github/workflows/ci.yml)) with four jobs: lint + library build + the package-publish lint, story tests, Storybook build, and website lint + build followed by the built-HTML checks (rendered spacing, chat-corpus coverage, internal links) and the served-site checks: a hydration smoke that loads the built site in a real browser, and a page-level axe pass over the served pages in both themes. The story tests render every Storybook story in headless Chromium via Vitest and run an axe accessibility audit on each, so a violation fails the build exactly like a render error; the overlay stories also assert their keyboard and focus behaviour with play functions, enforced the same way. A scheduled workflow ([`uptime.yml`](.github/workflows/uptime.yml)) re-runs the hydration smoke against production every four hours, because incremental regeneration means the served site can change with no deploy. The same checklist runs locally with one command:
|
|
161
164
|
|
|
162
165
|
```bash
|
|
163
|
-
npm run verify # lint + library type-check + package build + story tests + Storybook build + website lint + build + built-HTML checks
|
|
166
|
+
npm run verify # lint + library type-check + package build + publish lint + story tests + Storybook build + website lint + build + the built-HTML and served-site checks
|
|
164
167
|
```
|
|
165
168
|
|
|
166
|
-
CI also guards against documentation drift: generated
|
|
169
|
+
CI also guards against documentation drift: every generated surface (the `validate-registry` entry in the root `package.json` is the authoritative list, from this README's component count to the per-component markdown and the consumer agent skill) is rebuilt from its source registries on every build, and CI fails if the committed copies are stale. The numbers on the site are never hand-written.
|
|
167
170
|
|
|
168
171
|
---
|
|
169
172
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared focusability queries for the overlay behavior layer.
|
|
3
|
+
*
|
|
4
|
+
* One home for the selector the modal components previously each carried a
|
|
5
|
+
* copy of. Plain DOM utilities — no React, safe to import anywhere.
|
|
6
|
+
*/
|
|
7
|
+
/** Everything a focus trap must cycle through. */
|
|
8
|
+
export declare const FOCUSABLE = "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
9
|
+
/**
|
|
10
|
+
* The subset worth landing initial focus on: real controls, not arbitrary
|
|
11
|
+
* tabindex carriers (a scrollable body region is tabbable but focusing it on
|
|
12
|
+
* open would strand a keyboard user on a non-control).
|
|
13
|
+
*/
|
|
14
|
+
export declare const INITIAL_FOCUS = "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])";
|
|
15
|
+
/**
|
|
16
|
+
* The focusable elements inside a container, in DOM order, excluding
|
|
17
|
+
* invisible ones — a control hidden by `display: none` must not count as the
|
|
18
|
+
* trap's first or last stop.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getFocusable(container: HTMLElement): HTMLElement[];
|
|
21
|
+
/**
|
|
22
|
+
* Where initial focus should land when a scope activates: the first visible
|
|
23
|
+
* real control, then the first visible focusable of any kind, else null (the
|
|
24
|
+
* caller falls back to the container itself).
|
|
25
|
+
*/
|
|
26
|
+
export declare function getInitialFocus(container: HTMLElement): HTMLElement | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
2
|
+
const INITIAL_FOCUS = "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])";
|
|
3
|
+
const isVisible = (el) => typeof el.checkVisibility === "function" ? el.checkVisibility() : el.offsetParent !== null;
|
|
4
|
+
function getFocusable(container) {
|
|
5
|
+
return Array.from(container.querySelectorAll(FOCUSABLE)).filter(isVisible);
|
|
6
|
+
}
|
|
7
|
+
function getInitialFocus(container) {
|
|
8
|
+
const preferred = Array.from(container.querySelectorAll(INITIAL_FOCUS)).find(
|
|
9
|
+
isVisible
|
|
10
|
+
);
|
|
11
|
+
return preferred ?? getFocusable(container)[0] ?? null;
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
FOCUSABLE,
|
|
15
|
+
INITIAL_FOCUS,
|
|
16
|
+
getFocusable,
|
|
17
|
+
getInitialFocus
|
|
18
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus containment for modal overlays: initial focus, Tab wrapping, focus
|
|
3
|
+
* restore, and background isolation.
|
|
4
|
+
*
|
|
5
|
+
* Restore is guarded by `document.contains` — the element that opened a
|
|
6
|
+
* dialog can be gone by the time it closes (a row the dialog itself deleted),
|
|
7
|
+
* and restoring into a detached node silently drops focus to `<body>`. It
|
|
8
|
+
* also runs when the component unmounts while open, which the per-component
|
|
9
|
+
* implementations this replaces never handled.
|
|
10
|
+
*
|
|
11
|
+
* Background isolation uses `inert`, not `aria-hidden`: a JS focus trap only
|
|
12
|
+
* fences the Tab key, while a screen reader's virtual cursor walks the page
|
|
13
|
+
* behind the dialog freely. `inert` closes all three doors at once —
|
|
14
|
+
* assistive-tech traversal, focusability, and pointer events. Scopes stack:
|
|
15
|
+
* only the topmost active scope's overlay is interactive, and closing it
|
|
16
|
+
* hands the page back to the scope below, not to the whole document. The
|
|
17
|
+
* inert set is recomputed only when a scope activates or deactivates, so a
|
|
18
|
+
* portal that mounts *while* a modal is open (a toast, say) is not inerted
|
|
19
|
+
* until the stack next changes — under `aria-modal` that content is outside
|
|
20
|
+
* the dialog's world anyway.
|
|
21
|
+
*/
|
|
22
|
+
type FocusScopeOptions = {
|
|
23
|
+
/** Whether the scope is active — the overlay's open state. */
|
|
24
|
+
active: boolean;
|
|
25
|
+
/** Wrap Tab / Shift+Tab within the scope. Default true. */
|
|
26
|
+
trap?: boolean;
|
|
27
|
+
/** Restore focus to the previously focused element on deactivate. Default true. */
|
|
28
|
+
restoreFocus?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Where initial focus lands. Defaults to the first visible real control,
|
|
31
|
+
* then the first focusable of any kind, then the container itself.
|
|
32
|
+
*/
|
|
33
|
+
initialFocus?: () => HTMLElement | null;
|
|
34
|
+
/** Make everything outside the scope inert while active. Default true. */
|
|
35
|
+
inertOutside?: boolean;
|
|
36
|
+
};
|
|
37
|
+
export declare function useFocusScope(ref: React.RefObject<HTMLElement | null>, options: FocusScopeOptions): void;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRef, useEffect } from "react";
|
|
3
|
+
import { getInitialFocus, getFocusable } from "./focusable.js";
|
|
4
|
+
const inertScopes = [];
|
|
5
|
+
const inerted = /* @__PURE__ */ new Set();
|
|
6
|
+
const applyInert = () => {
|
|
7
|
+
const top = inertScopes[inertScopes.length - 1];
|
|
8
|
+
for (const child of Array.from(document.body.children)) {
|
|
9
|
+
if (!(child instanceof HTMLElement)) continue;
|
|
10
|
+
if (child.tagName === "SCRIPT" || child.tagName === "STYLE") continue;
|
|
11
|
+
const shouldBeInert = top !== void 0 && !child.contains(top.panel);
|
|
12
|
+
if (shouldBeInert && !child.inert) {
|
|
13
|
+
child.inert = true;
|
|
14
|
+
inerted.add(child);
|
|
15
|
+
} else if (!shouldBeInert && inerted.has(child)) {
|
|
16
|
+
child.inert = false;
|
|
17
|
+
inerted.delete(child);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (top === void 0 && inerted.size > 0) {
|
|
21
|
+
for (const el of inerted) el.inert = false;
|
|
22
|
+
inerted.clear();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function useFocusScope(ref, options) {
|
|
26
|
+
const optionsRef = useRef(options);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
optionsRef.current = options;
|
|
29
|
+
});
|
|
30
|
+
const { active } = options;
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!active) return;
|
|
33
|
+
const panel = ref.current;
|
|
34
|
+
if (!panel) return;
|
|
35
|
+
const previousFocus = document.activeElement;
|
|
36
|
+
let focusRaf = 0;
|
|
37
|
+
let focusAttempts = 0;
|
|
38
|
+
const tryInitialFocus = () => {
|
|
39
|
+
const initial = optionsRef.current.initialFocus?.() ?? getInitialFocus(panel) ?? panel;
|
|
40
|
+
initial.focus();
|
|
41
|
+
if (document.activeElement === initial) return;
|
|
42
|
+
focusAttempts += 1;
|
|
43
|
+
if (focusAttempts < 5) focusRaf = requestAnimationFrame(tryInitialFocus);
|
|
44
|
+
};
|
|
45
|
+
tryInitialFocus();
|
|
46
|
+
const scope = { panel };
|
|
47
|
+
if (optionsRef.current.inertOutside !== false) {
|
|
48
|
+
inertScopes.push(scope);
|
|
49
|
+
applyInert();
|
|
50
|
+
}
|
|
51
|
+
const handleKeyDown = (event) => {
|
|
52
|
+
if (event.key !== "Tab" || optionsRef.current.trap === false) return;
|
|
53
|
+
const focusable = getFocusable(panel);
|
|
54
|
+
if (focusable.length === 0) return;
|
|
55
|
+
const first = focusable[0];
|
|
56
|
+
const last = focusable[focusable.length - 1];
|
|
57
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
last.focus();
|
|
60
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
first.focus();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
66
|
+
return () => {
|
|
67
|
+
cancelAnimationFrame(focusRaf);
|
|
68
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
69
|
+
const index = inertScopes.indexOf(scope);
|
|
70
|
+
if (index !== -1) {
|
|
71
|
+
inertScopes.splice(index, 1);
|
|
72
|
+
applyInert();
|
|
73
|
+
}
|
|
74
|
+
if (optionsRef.current.restoreFocus !== false && previousFocus && document.contains(previousFocus)) {
|
|
75
|
+
previousFocus.focus();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}, [active, ref]);
|
|
79
|
+
}
|
|
80
|
+
export {
|
|
81
|
+
useFocusScope
|
|
82
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dismissal layer stack — the single owner of the document-level Escape
|
|
3
|
+
* and outside-press listeners for every overlay in the library.
|
|
4
|
+
*
|
|
5
|
+
* Before this existed, each open overlay registered its own document keydown,
|
|
6
|
+
* so Escape over a stacked Dialog-in-Drawer closed both at once. The stack
|
|
7
|
+
* routes each interaction to the **topmost** open layer only: Escape
|
|
8
|
+
* dismisses it (or is swallowed when it is not Escape-dismissible — a lower
|
|
9
|
+
* layer must never close through a blocking one), and an outside press
|
|
10
|
+
* dismisses it only when the press lands outside every element the layer
|
|
11
|
+
* claims. Layers below are untouched; one press, one layer.
|
|
12
|
+
*
|
|
13
|
+
* Exactly two document listeners exist while any layer is open, and none
|
|
14
|
+
* while the stack is empty. Registration order is stack order — overlays
|
|
15
|
+
* portal into `document.body` in the order they open, so stack order and
|
|
16
|
+
* paint order agree without reading z-index.
|
|
17
|
+
*/
|
|
18
|
+
type LayerOptions = {
|
|
19
|
+
/** The overlay's open state — the layer registers while true. */
|
|
20
|
+
open: boolean;
|
|
21
|
+
/** Called when the stack decides this layer should dismiss. */
|
|
22
|
+
onDismiss?: () => void;
|
|
23
|
+
/** Whether Escape dismisses this layer. Default true. */
|
|
24
|
+
dismissOnEscape?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a pointerdown outside `getElements()` dismisses this layer.
|
|
27
|
+
* Default false — modal components keep their own backdrop click handler,
|
|
28
|
+
* which is part of their DOM and needs no document listener.
|
|
29
|
+
*/
|
|
30
|
+
dismissOnOutsidePress?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The elements that count as "inside" for outside-press checks — typically
|
|
33
|
+
* the panel and its trigger. Only read when `dismissOnOutsidePress` is on.
|
|
34
|
+
*/
|
|
35
|
+
getElements?: () => (HTMLElement | null | undefined)[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Register an overlay on the dismissal stack while `open` is true. Options
|
|
39
|
+
* are read at event time, so a `dismissOnEscape` toggle takes effect without
|
|
40
|
+
* re-registering (which would wrongly move the layer to the top).
|
|
41
|
+
*/
|
|
42
|
+
export declare function useLayer(options: LayerOptions): void;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRef, useEffect } from "react";
|
|
3
|
+
const layers = [];
|
|
4
|
+
let listenersInstalled = false;
|
|
5
|
+
const handleKeyDown = (event) => {
|
|
6
|
+
if (event.key !== "Escape") return;
|
|
7
|
+
const top = layers[layers.length - 1];
|
|
8
|
+
if (!top) return;
|
|
9
|
+
const options = top.optionsRef.current;
|
|
10
|
+
if (options.dismissOnEscape === false) return;
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
options.onDismiss?.();
|
|
13
|
+
};
|
|
14
|
+
const handlePointerDown = (event) => {
|
|
15
|
+
const top = layers[layers.length - 1];
|
|
16
|
+
if (!top) return;
|
|
17
|
+
const options = top.optionsRef.current;
|
|
18
|
+
if (!options.dismissOnOutsidePress) return;
|
|
19
|
+
const target = event.target instanceof Node ? event.target : null;
|
|
20
|
+
if (!target) return;
|
|
21
|
+
const inside = (options.getElements?.() ?? []).some((el) => el?.contains(target));
|
|
22
|
+
if (!inside) options.onDismiss?.();
|
|
23
|
+
};
|
|
24
|
+
const syncListeners = () => {
|
|
25
|
+
if (layers.length > 0 && !listenersInstalled) {
|
|
26
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
27
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
28
|
+
listenersInstalled = true;
|
|
29
|
+
} else if (layers.length === 0 && listenersInstalled) {
|
|
30
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
31
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
32
|
+
listenersInstalled = false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function useLayer(options) {
|
|
36
|
+
const optionsRef = useRef(options);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
optionsRef.current = options;
|
|
39
|
+
});
|
|
40
|
+
const { open } = options;
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!open) return;
|
|
43
|
+
const layer = { optionsRef };
|
|
44
|
+
layers.push(layer);
|
|
45
|
+
syncListeners();
|
|
46
|
+
return () => {
|
|
47
|
+
const index = layers.indexOf(layer);
|
|
48
|
+
if (index !== -1) layers.splice(index, 1);
|
|
49
|
+
syncListeners();
|
|
50
|
+
};
|
|
51
|
+
}, [open]);
|
|
52
|
+
}
|
|
53
|
+
export {
|
|
54
|
+
useLayer
|
|
55
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* False during SSR and the hydration render, true afterwards — the guard a
|
|
3
|
+
* portal component needs before touching `document.body`. Uses
|
|
4
|
+
* `useSyncExternalStore` rather than a mount effect so the client's first
|
|
5
|
+
* render matches the server HTML without an extra render pass.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useMounted(): boolean;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page scroll locking for modal overlays, with two strategies and one owner.
|
|
3
|
+
*
|
|
4
|
+
* `pin` (the default) fixes the body in place rather than hiding its
|
|
5
|
+
* overflow. iOS Safari ignores `overflow: hidden` on the body: a touch still
|
|
6
|
+
* scrolls the document, and focusing a field scrolls it programmatically to
|
|
7
|
+
* bring the field above the keyboard — which slides the page out from under
|
|
8
|
+
* the overlay. A fixed body has no scrollable overflow at all, so neither can
|
|
9
|
+
* move it. The body is pinned at its current scroll offset so the page behind
|
|
10
|
+
* the overlay does not visibly jump, and the offset is restored when the last
|
|
11
|
+
* holder releases. `overflow: hidden` rides along so the pinned body cannot
|
|
12
|
+
* grow scrollbars of its own.
|
|
13
|
+
*
|
|
14
|
+
* The pin is **counted**, and the counter is the whole point: closing a
|
|
15
|
+
* Dialog opened over a Drawer must not unlock the Drawer's scroll. The
|
|
16
|
+
* imperative pair (`acquireScrollLock` / `releaseScrollLock`) exposes the
|
|
17
|
+
* same counter to host chrome — a site's nav drawer, a bespoke chat panel —
|
|
18
|
+
* because two locks that cannot see each other each believe they own
|
|
19
|
+
* `document.body`, and whichever releases first unlocks the page under the
|
|
20
|
+
* overlay the other still holds (or restores styles the other already
|
|
21
|
+
* changed). One counter means the first acquire captures and pins, only the
|
|
22
|
+
* last release restores, whichever module it belongs to. The captured inline
|
|
23
|
+
* styles are restored verbatim, never blanked, so a host's own body styling
|
|
24
|
+
* survives a lock cycle.
|
|
25
|
+
*
|
|
26
|
+
* `cancel-events` keeps the document scrollbar rendered and cancels scroll
|
|
27
|
+
* *input* instead — wheel and touch gestures work only inside `allowWithin`.
|
|
28
|
+
* This is CommandPalette's documented design decision (unscrollable-body
|
|
29
|
+
* treatments blink the scrollbar rail out, which reads as a layout jump on
|
|
30
|
+
* classic-scrollbar platforms); it is deliberately not the default because
|
|
31
|
+
* swapping the modals onto it would change their scrollbar rendering.
|
|
32
|
+
* Instances are independent — overlapping cancel-events locks stack
|
|
33
|
+
* harmlessly.
|
|
34
|
+
*/
|
|
35
|
+
type ScrollLockOptions = {
|
|
36
|
+
/** Which locking strategy to use. Default `'pin'`. */
|
|
37
|
+
strategy?: 'pin' | 'cancel-events';
|
|
38
|
+
/** For `cancel-events`: the scrollable region that keeps receiving input. */
|
|
39
|
+
allowWithin?: React.RefObject<HTMLElement | null>;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Take one hold on the shared page scroll lock — the counter behind the
|
|
43
|
+
* hook's `pin` strategy. For overlays outside this library (host chrome)
|
|
44
|
+
* that must not fight the modal components over `document.body`. Every
|
|
45
|
+
* acquire needs exactly one matching release.
|
|
46
|
+
*/
|
|
47
|
+
export declare function acquireScrollLock(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Release one hold on the shared page scroll lock. The body styles and the
|
|
50
|
+
* scroll offset are restored only when the last hold anywhere releases; an
|
|
51
|
+
* unbalanced release never touches the page.
|
|
52
|
+
*/
|
|
53
|
+
export declare function releaseScrollLock(): void;
|
|
54
|
+
export declare function useScrollLock(active: boolean, options?: ScrollLockOptions): void;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRef, useEffect } from "react";
|
|
3
|
+
const PINNED_PROPS = ["position", "top", "left", "right", "overflow"];
|
|
4
|
+
let pinCount = 0;
|
|
5
|
+
let pinnedScrollY = 0;
|
|
6
|
+
let previousBodyStyle = {};
|
|
7
|
+
function acquireScrollLock() {
|
|
8
|
+
if (pinCount === 0) {
|
|
9
|
+
const { style } = document.body;
|
|
10
|
+
pinnedScrollY = window.scrollY;
|
|
11
|
+
previousBodyStyle = {};
|
|
12
|
+
for (const prop of PINNED_PROPS) previousBodyStyle[prop] = style[prop];
|
|
13
|
+
style.position = "fixed";
|
|
14
|
+
style.top = `-${pinnedScrollY}px`;
|
|
15
|
+
style.left = "0";
|
|
16
|
+
style.right = "0";
|
|
17
|
+
style.overflow = "hidden";
|
|
18
|
+
}
|
|
19
|
+
pinCount += 1;
|
|
20
|
+
}
|
|
21
|
+
function releaseScrollLock() {
|
|
22
|
+
if (pinCount === 0) return;
|
|
23
|
+
pinCount -= 1;
|
|
24
|
+
if (pinCount > 0) return;
|
|
25
|
+
const { style } = document.body;
|
|
26
|
+
for (const prop of PINNED_PROPS) style[prop] = previousBodyStyle[prop] ?? "";
|
|
27
|
+
window.scrollTo({ top: pinnedScrollY, behavior: "instant" });
|
|
28
|
+
}
|
|
29
|
+
function useScrollLock(active, options) {
|
|
30
|
+
const optionsRef = useRef(options);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
optionsRef.current = options;
|
|
33
|
+
});
|
|
34
|
+
const strategy = options?.strategy ?? "pin";
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!active) return;
|
|
37
|
+
if (strategy === "pin") {
|
|
38
|
+
acquireScrollLock();
|
|
39
|
+
return releaseScrollLock;
|
|
40
|
+
}
|
|
41
|
+
const block = (event) => {
|
|
42
|
+
const allow = optionsRef.current?.allowWithin?.current;
|
|
43
|
+
const target = event.target instanceof Node ? event.target : null;
|
|
44
|
+
if (allow && target && allow.contains(target)) return;
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
};
|
|
47
|
+
document.addEventListener("wheel", block, { passive: false });
|
|
48
|
+
document.addEventListener("touchmove", block, { passive: false });
|
|
49
|
+
return () => {
|
|
50
|
+
document.removeEventListener("wheel", block);
|
|
51
|
+
document.removeEventListener("touchmove", block);
|
|
52
|
+
};
|
|
53
|
+
}, [active, strategy]);
|
|
54
|
+
}
|
|
55
|
+
export {
|
|
56
|
+
acquireScrollLock,
|
|
57
|
+
releaseScrollLock,
|
|
58
|
+
useScrollLock
|
|
59
|
+
};
|
package/charts.d.ts
CHANGED
package/charts.js
CHANGED
|
@@ -8,10 +8,12 @@ import { RadialChart } from "./components/Chart/RadialChart.js";
|
|
|
8
8
|
import { ScatterChart } from "./components/Chart/ScatterChart.js";
|
|
9
9
|
import { StackedBarChart } from "./components/Chart/StackedBarChart.js";
|
|
10
10
|
import { Treemap } from "./components/Chart/Treemap.js";
|
|
11
|
+
import { FunnelChart } from "./components/FunnelChart/FunnelChart.js";
|
|
11
12
|
export {
|
|
12
13
|
AreaChart,
|
|
13
14
|
BarChart,
|
|
14
15
|
ComboChart,
|
|
16
|
+
FunnelChart,
|
|
15
17
|
LineChart,
|
|
16
18
|
PieChart,
|
|
17
19
|
RadarChart,
|
|
@@ -151,6 +151,11 @@
|
|
|
151
151
|
align-items: center;
|
|
152
152
|
justify-content: center;
|
|
153
153
|
flex: none;
|
|
154
|
+
/* Fixed box: the active step's spinner (16px) is smaller than the step
|
|
155
|
+
icons (20px), so without this the column narrows and the spinner drifts
|
|
156
|
+
off the connector's centre line. */
|
|
157
|
+
width: var(--icon-size-sm);
|
|
158
|
+
height: var(--icon-size-sm);
|
|
154
159
|
color: var(--color-icon-secondary);
|
|
155
160
|
}
|
|
156
161
|
|
|
@@ -188,8 +193,12 @@
|
|
|
188
193
|
STEP STATES
|
|
189
194
|
============================================ */
|
|
190
195
|
|
|
196
|
+
.ds-agent-plan__step--pending .ds-agent-plan__indicator {
|
|
197
|
+
color: var(--color-status-neutral-icon);
|
|
198
|
+
}
|
|
199
|
+
|
|
191
200
|
.ds-agent-plan__step--completed .ds-agent-plan__indicator {
|
|
192
|
-
color: var(--color-status-positive-
|
|
201
|
+
color: var(--color-status-positive-icon);
|
|
193
202
|
}
|
|
194
203
|
|
|
195
204
|
.ds-agent-plan__step--completed .ds-agent-plan__label {
|
|
@@ -197,7 +206,7 @@
|
|
|
197
206
|
}
|
|
198
207
|
|
|
199
208
|
.ds-agent-plan__step--active .ds-agent-plan__indicator {
|
|
200
|
-
color: var(--color-status-info-
|
|
209
|
+
color: var(--color-status-info-icon);
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
.ds-agent-plan__step--active .ds-agent-plan__label {
|
|
@@ -207,7 +216,7 @@
|
|
|
207
216
|
}
|
|
208
217
|
|
|
209
218
|
.ds-agent-plan__step--failed .ds-agent-plan__indicator {
|
|
210
|
-
color: var(--color-status-error-
|
|
219
|
+
color: var(--color-status-error-icon);
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
.ds-agent-plan__step--failed .ds-agent-plan__label {
|