@robr0/design-system 0.15.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 +3 -3
- 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/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/Button/Button.css +3 -0
- package/components/Combobox/Combobox.js +2 -1
- package/components/CommandPalette/CommandPalette.css +10 -7
- package/components/CommandPalette/CommandPalette.js +9 -58
- package/components/Composer/Composer.css +54 -0
- package/components/Composer/Composer.d.ts +21 -4
- package/components/Composer/Composer.js +7 -0
- 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/ModelPicker/ModelPicker.js +2 -1
- package/components/Popover/Popover.js +3 -0
- package/components/SegmentedControl/SegmentedControl.css +4 -2
- package/components/SourceTrail/SourceTrail.css +2 -2
- package/components/TimePicker/TimePicker.js +2 -1
- package/components/ToolCall/ToolCall.css +9 -1
- package/components/Tooltip/Tooltip.js +6 -0
- package/components/registry.json +2 -2
- package/components/registry.json.d.ts +2 -2
- package/components/registry.json.js +1 -1
- package/package.json +5 -1
- package/tokens/motion.d.ts +10 -4
- package/tokens/motion.js +7 -1
- package/tokens/registry.json +6 -0
- package/tokens/registry.json.d.ts +6 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-dark.css +11 -0
- package/tokens/tokens-light.css +13 -0
package/README.md
CHANGED
|
@@ -153,17 +153,17 @@ The same generated data is served as files too: every component's prop contract
|
|
|
153
153
|
- **Vite 7**: dev server and library build
|
|
154
154
|
- **Next.js 16**: portfolio site and design system documentation
|
|
155
155
|
- **Storybook 10**: component explorer
|
|
156
|
-
- **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
|
|
157
157
|
- **CSS custom properties**: all theming via semantic tokens, no CSS-in-JS
|
|
158
158
|
|
|
159
159
|
---
|
|
160
160
|
|
|
161
161
|
## Quality & CI
|
|
162
162
|
|
|
163
|
-
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:
|
|
164
164
|
|
|
165
165
|
```bash
|
|
166
|
-
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
|
|
167
167
|
```
|
|
168
168
|
|
|
169
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.
|
|
@@ -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
|
+
};
|
|
@@ -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 {
|