@xenosystem/elements-react 0.0.1
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/LICENSE +18 -0
- package/README.md +41 -0
- package/dist/XenoElement.d.ts +39 -0
- package/dist/XenoElement.js +25 -0
- package/dist/containers/Avatar.d.ts +42 -0
- package/dist/containers/Avatar.js +29 -0
- package/dist/containers/Badges.d.ts +61 -0
- package/dist/containers/Badges.js +43 -0
- package/dist/containers/Card.d.ts +24 -0
- package/dist/containers/Card.js +5 -0
- package/dist/containers/Chip.d.ts +34 -0
- package/dist/containers/Chip.js +17 -0
- package/dist/containers/DataTable.d.ts +28 -0
- package/dist/containers/DataTable.js +13 -0
- package/dist/containers/ListRow.d.ts +37 -0
- package/dist/containers/ListRow.js +40 -0
- package/dist/containers/MessageBubble.d.ts +33 -0
- package/dist/containers/MessageBubble.js +9 -0
- package/dist/containers/Panel.d.ts +24 -0
- package/dist/containers/Panel.js +6 -0
- package/dist/containers/Table.d.ts +39 -0
- package/dist/containers/Table.js +20 -0
- package/dist/containers/Tile.d.ts +19 -0
- package/dist/containers/Tile.js +7 -0
- package/dist/content/Callout.d.ts +32 -0
- package/dist/content/Callout.js +10 -0
- package/dist/content/Caret.d.ts +9 -0
- package/dist/content/Caret.js +5 -0
- package/dist/content/CodeBlock.d.ts +36 -0
- package/dist/content/CodeBlock.js +50 -0
- package/dist/content/Collapsible.d.ts +24 -0
- package/dist/content/Collapsible.js +19 -0
- package/dist/content/InlineCode.d.ts +12 -0
- package/dist/content/InlineCode.js +5 -0
- package/dist/content/ModelPicker.d.ts +45 -0
- package/dist/content/ModelPicker.js +124 -0
- package/dist/content/SourceCard.d.ts +23 -0
- package/dist/content/SourceCard.js +18 -0
- package/dist/content/SourcesDisclosure.d.ts +26 -0
- package/dist/content/SourcesDisclosure.js +20 -0
- package/dist/controls/Button.d.ts +39 -0
- package/dist/controls/Button.js +16 -0
- package/dist/controls/ConfirmButton.d.ts +22 -0
- package/dist/controls/ConfirmButton.js +22 -0
- package/dist/controls/IconButton.d.ts +45 -0
- package/dist/controls/IconButton.js +15 -0
- package/dist/controls/Switch.d.ts +11 -0
- package/dist/controls/Switch.js +23 -0
- package/dist/controls/TextInput.d.ts +13 -0
- package/dist/controls/TextInput.js +6 -0
- package/dist/controls/Textarea.d.ts +8 -0
- package/dist/controls/Textarea.js +5 -0
- package/dist/controls/ToggleButton.d.ts +23 -0
- package/dist/controls/ToggleButton.js +23 -0
- package/dist/controls/util.d.ts +48 -0
- package/dist/controls/util.js +23 -0
- package/dist/forms/Checkbox.d.ts +18 -0
- package/dist/forms/Checkbox.js +28 -0
- package/dist/forms/RadioGroup.d.ts +51 -0
- package/dist/forms/RadioGroup.js +83 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +81 -0
- package/dist/layout/ResizablePanel.d.ts +40 -0
- package/dist/layout/ResizablePanel.js +108 -0
- package/dist/nav/Sidebar.d.ts +60 -0
- package/dist/nav/Sidebar.js +84 -0
- package/dist/overlays/DateTimePicker.d.ts +71 -0
- package/dist/overlays/DateTimePicker.js +191 -0
- package/dist/overlays/Menu.d.ts +28 -0
- package/dist/overlays/Menu.js +32 -0
- package/dist/overlays/MenuItem.d.ts +71 -0
- package/dist/overlays/MenuItem.js +15 -0
- package/dist/overlays/Modal.d.ts +36 -0
- package/dist/overlays/Modal.js +27 -0
- package/dist/overlays/PickerField.d.ts +32 -0
- package/dist/overlays/PickerField.js +40 -0
- package/dist/overlays/PillFilter.d.ts +25 -0
- package/dist/overlays/PillFilter.js +41 -0
- package/dist/overlays/Popover.d.ts +32 -0
- package/dist/overlays/Popover.js +38 -0
- package/dist/overlays/Reveal.d.ts +20 -0
- package/dist/overlays/Reveal.js +45 -0
- package/dist/overlays/SegmentedControl.d.ts +23 -0
- package/dist/overlays/SegmentedControl.js +43 -0
- package/dist/overlays/Tabs.d.ts +32 -0
- package/dist/overlays/Tabs.js +142 -0
- package/dist/overlays/Tooltip.d.ts +23 -0
- package/dist/overlays/Tooltip.js +61 -0
- package/dist/status/ProgressBar.d.ts +20 -0
- package/dist/status/ProgressBar.js +11 -0
- package/dist/status/Spinner.d.ts +16 -0
- package/dist/status/Spinner.js +12 -0
- package/dist/status/StatusIndicator.d.ts +19 -0
- package/dist/status/StatusIndicator.js +11 -0
- package/dist/status/StepTimeline.d.ts +26 -0
- package/dist/status/StepTimeline.js +16 -0
- package/dist/status/ThinkingCube.d.ts +24 -0
- package/dist/status/ThinkingCube.js +11 -0
- package/dist/useDialog.d.ts +45 -0
- package/dist/useDialog.js +122 -0
- package/dist/useGooPill.d.ts +57 -0
- package/dist/useGooPill.js +76 -0
- package/dist/useMenu.d.ts +50 -0
- package/dist/useMenu.js +119 -0
- package/dist/useTabs.d.ts +88 -0
- package/dist/useTabs.js +93 -0
- package/package.json +58 -0
- package/src/chrome-separated.css +132 -0
- package/src/chrome-unified.css +72 -0
- package/src/containers/Badges.css +116 -0
- package/src/containers/avatar.css +70 -0
- package/src/containers/card.css +75 -0
- package/src/containers/chip.css +103 -0
- package/src/containers/list-row.css +132 -0
- package/src/containers/message-bubble.css +112 -0
- package/src/containers/panel.css +120 -0
- package/src/containers/table.css +102 -0
- package/src/containers/tile.css +17 -0
- package/src/content/callout.css +77 -0
- package/src/content/caret.css +30 -0
- package/src/content/code-block.css +159 -0
- package/src/content/collapsible.css +80 -0
- package/src/content/inline-code.css +14 -0
- package/src/content/model-picker.css +219 -0
- package/src/content/source-card.css +99 -0
- package/src/content/sources-disclosure.css +118 -0
- package/src/controls.css +453 -0
- package/src/fonts.css +34 -0
- package/src/forms/Checkbox.css +114 -0
- package/src/forms/RadioGroup.css +115 -0
- package/src/goo.css +97 -0
- package/src/icon-motion.css +1909 -0
- package/src/layout/ResizablePanel.css +79 -0
- package/src/nav/sidebar.css +296 -0
- package/src/overlays/date-time-picker.css +238 -0
- package/src/overlays/menu.css +195 -0
- package/src/overlays/modal.css +167 -0
- package/src/overlays/picker-field.css +81 -0
- package/src/overlays/pill-filter.css +129 -0
- package/src/overlays/reveal.css +56 -0
- package/src/overlays/segmented-control.css +185 -0
- package/src/overlays/tabs.css +143 -0
- package/src/overlays/tooltip.css +126 -0
- package/src/scrollbar.css +184 -0
- package/src/size.css +179 -0
- package/src/status/progress-bar.css +88 -0
- package/src/status/spinner.css +33 -0
- package/src/status/statusindicator.css +65 -0
- package/src/status/steptimeline.css +137 -0
- package/src/status/thinkingcube.css +75 -0
- package/src/xeno-element.css +60 -0
- package/src/xeno-elements.css +71 -0
- package/src/xeno-theme.css +99 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { XenoElement } from '../XenoElement.js';
|
|
4
|
+
import { sizeAttr, iconPx, cx } from '../controls/util.js';
|
|
5
|
+
// useLayoutEffect measures before paint on the client; fall back to useEffect on the server so SSR does
|
|
6
|
+
// not warn. (Same seam as <Reveal>.)
|
|
7
|
+
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
8
|
+
export function Tabs({ value, onValueChange, items, size = 'md', renderPanel, children, className, 'aria-label': ariaLabel, ...rest }) {
|
|
9
|
+
const baseId = useId();
|
|
10
|
+
const tabRefs = useRef([]);
|
|
11
|
+
const listRef = useRef(null);
|
|
12
|
+
const glyph = iconPx(size);
|
|
13
|
+
const currentIndex = items.findIndex((it) => it.value === value);
|
|
14
|
+
/**
|
|
15
|
+
* The underline is ONE bar that slides from the old tab to the new one, and to slide it has to know
|
|
16
|
+
* where the new tab starts and how wide it is. A stylesheet cannot know that: a tab is as wide as its
|
|
17
|
+
* label, which only layout can answer. So the component measures — once, when the selection or the
|
|
18
|
+
* layout changes — and hands CSS two numbers. Measuring is not animating: CSS still owns every frame
|
|
19
|
+
* between them, and nothing here runs per-frame.
|
|
20
|
+
*
|
|
21
|
+
* The alternative was forcing every tab to the same width, which would make it pure arithmetic (that
|
|
22
|
+
* is what SegmentedControl does). Tabs are labels, not segments — they should keep their own width.
|
|
23
|
+
*/
|
|
24
|
+
const [bar, setBar] = useState({ x: 0, w: 0 });
|
|
25
|
+
useIsoLayoutEffect(() => {
|
|
26
|
+
const list = listRef.current;
|
|
27
|
+
const tab = currentIndex >= 0 ? tabRefs.current[currentIndex] : null;
|
|
28
|
+
if (!list)
|
|
29
|
+
return;
|
|
30
|
+
const measure = () => {
|
|
31
|
+
if (!tab) {
|
|
32
|
+
setBar((b) => (b.w === 0 ? b : { x: b.x, w: 0 }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const x = tab.offsetLeft;
|
|
36
|
+
const w = tab.offsetWidth;
|
|
37
|
+
setBar((b) => (b.x === x && b.w === w ? b : { x, w }));
|
|
38
|
+
};
|
|
39
|
+
measure();
|
|
40
|
+
// Re-measure when the list is resized or a web font finally lands and relays the labels out.
|
|
41
|
+
const ro = new ResizeObserver(measure);
|
|
42
|
+
ro.observe(list);
|
|
43
|
+
for (const t of tabRefs.current)
|
|
44
|
+
if (t)
|
|
45
|
+
ro.observe(t);
|
|
46
|
+
return () => ro.disconnect();
|
|
47
|
+
}, [currentIndex, items, size]);
|
|
48
|
+
/* The travel is a transition, and a transition fires the moment the value changes — including the
|
|
49
|
+
first measurement after mount, which would slide the bar in from the left edge on page load. Arm on
|
|
50
|
+
the first genuine change of `value`. */
|
|
51
|
+
const [armed, setArmed] = useState(false);
|
|
52
|
+
const [seen, setSeen] = useState(value);
|
|
53
|
+
const [distance, setDistance] = useState(1);
|
|
54
|
+
if (seen !== value) {
|
|
55
|
+
const from = items.findIndex((it) => it.value === seen);
|
|
56
|
+
if (from >= 0 && currentIndex >= 0)
|
|
57
|
+
setDistance(Math.min(2, Math.abs(currentIndex - from)));
|
|
58
|
+
setSeen(value);
|
|
59
|
+
if (!armed)
|
|
60
|
+
setArmed(true);
|
|
61
|
+
}
|
|
62
|
+
// Only the MEASURED values stay inline — they are per-instance and cannot come from a stylesheet.
|
|
63
|
+
// The size metrics moved to `data-xeno-size` so a surface can reach them.
|
|
64
|
+
const listVars = {
|
|
65
|
+
'--xeno-tab-x': `${bar.x}px`,
|
|
66
|
+
'--xeno-tab-w': `${bar.w}px`,
|
|
67
|
+
'--xeno-tab-dist': distance,
|
|
68
|
+
};
|
|
69
|
+
const activate = (index) => {
|
|
70
|
+
const item = items[index];
|
|
71
|
+
if (!item || item.disabled)
|
|
72
|
+
return;
|
|
73
|
+
onValueChange?.(item.value);
|
|
74
|
+
tabRefs.current[index]?.focus();
|
|
75
|
+
};
|
|
76
|
+
const step = (from, dir) => {
|
|
77
|
+
const n = items.length;
|
|
78
|
+
let idx = from;
|
|
79
|
+
for (let k = 0; k < n; k++) {
|
|
80
|
+
idx = (idx + dir + n) % n;
|
|
81
|
+
const it = items[idx];
|
|
82
|
+
if (it && !it.disabled)
|
|
83
|
+
return idx;
|
|
84
|
+
}
|
|
85
|
+
return from;
|
|
86
|
+
};
|
|
87
|
+
const edge = (dir) => {
|
|
88
|
+
const n = items.length;
|
|
89
|
+
if (dir === 1) {
|
|
90
|
+
for (let i = 0; i < n; i++) {
|
|
91
|
+
const it = items[i];
|
|
92
|
+
if (it && !it.disabled)
|
|
93
|
+
return i;
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
98
|
+
const it = items[i];
|
|
99
|
+
if (it && !it.disabled)
|
|
100
|
+
return i;
|
|
101
|
+
}
|
|
102
|
+
return n - 1;
|
|
103
|
+
};
|
|
104
|
+
const onKeyDown = (e) => {
|
|
105
|
+
if (items.length === 0)
|
|
106
|
+
return;
|
|
107
|
+
const base = currentIndex < 0 ? 0 : currentIndex;
|
|
108
|
+
switch (e.key) {
|
|
109
|
+
case 'ArrowRight':
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
activate(step(base, 1));
|
|
112
|
+
break;
|
|
113
|
+
case 'ArrowLeft':
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
activate(step(base, -1));
|
|
116
|
+
break;
|
|
117
|
+
case 'Home':
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
activate(edge(1));
|
|
120
|
+
break;
|
|
121
|
+
case 'End':
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
activate(edge(-1));
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const hasPanel = renderPanel !== undefined || children !== undefined;
|
|
128
|
+
const panelId = `${baseId}-panel`;
|
|
129
|
+
const panelContent = renderPanel !== undefined ? renderPanel(value) : children;
|
|
130
|
+
return (_jsxs("div", { className: cx('xeno-tabs', className), ...rest, children: [_jsxs("div", { ref: listRef, role: "tablist", "aria-orientation": "horizontal", className: "xeno-tablist", "data-index": currentIndex >= 0 ? Math.min(7, currentIndex) : undefined, ...(armed ? { 'data-motion': 'ready' } : {}), ...sizeAttr(size), style: listVars, onKeyDown: onKeyDown, ...(ariaLabel !== undefined ? { 'aria-label': ariaLabel } : {}), children: [items.map((item, i) => {
|
|
131
|
+
const selected = item.value === value;
|
|
132
|
+
const disabled = item.disabled ?? false;
|
|
133
|
+
const focusable = selected || (currentIndex < 0 && i === 0);
|
|
134
|
+
return (_jsxs("button", { type: "button", role: "tab", id: `${baseId}-tab-${item.value}`, ref: (el) => {
|
|
135
|
+
tabRefs.current[i] = el;
|
|
136
|
+
}, className: "xeno-tab", "data-selection": selected ? 'on' : 'off', "data-availability": disabled ? 'disabled' : 'enabled', "aria-selected": selected, "aria-disabled": disabled || undefined, ...(hasPanel ? { 'aria-controls': panelId } : {}), tabIndex: focusable ? 0 : -1, onClick: () => {
|
|
137
|
+
if (disabled)
|
|
138
|
+
return;
|
|
139
|
+
onValueChange?.(item.value);
|
|
140
|
+
}, children: [item.icon && _jsx(XenoElement, { decl: item.icon, size: glyph }), _jsx("span", { className: "xeno-tab-label", children: item.label })] }, item.value));
|
|
141
|
+
}), _jsx("span", { className: "xeno-tab-indicator", "aria-hidden": "true" })] }), hasPanel && (_jsx("div", { role: "tabpanel", id: panelId, "aria-labelledby": `${baseId}-tab-${value}`, tabIndex: 0, className: "xeno-tab-panel", children: panelContent }))] }));
|
|
142
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `<Tooltip>` — a hover/focus hint. Behaviour is hand-rolled (no portal lib, no Base UI): a
|
|
4
|
+
* `position: relative` wrapper holds the trigger (children) and an absolutely-positioned
|
|
5
|
+
* `role="tooltip"` panel that sits on one `side`. The panel is ALWAYS in the DOM (present for SSR
|
|
6
|
+
* and for the screen reader via `aria-describedby`); JS flips `data-state` to reveal it and the CSS
|
|
7
|
+
* animates opacity/transform. Reveal is instant on keyboard focus and, on hover, waits `delay` ms;
|
|
8
|
+
* per the ARIA tooltip pattern, Escape dismisses it while the pointer/focus stay put.
|
|
9
|
+
*
|
|
10
|
+
* Monochrome shell: an `--xeno-elevated` panel with a hairline border and muted 12px mono text.
|
|
11
|
+
*/
|
|
12
|
+
export type TooltipSide = 'top' | 'bottom' | 'left' | 'right';
|
|
13
|
+
export interface TooltipProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'content' | 'children'> {
|
|
14
|
+
/** The hint — any node; rendered into the `role="tooltip"` panel. */
|
|
15
|
+
readonly content: ReactNode;
|
|
16
|
+
/** The trigger. A single element is wired with `aria-describedby`; other nodes render as-is. */
|
|
17
|
+
readonly children: ReactNode;
|
|
18
|
+
/** Which side of the trigger the panel sits on. Default `top`. */
|
|
19
|
+
readonly side?: TooltipSide;
|
|
20
|
+
/** Milliseconds to wait before the hover reveal (keyboard focus is always instant). Default 150. */
|
|
21
|
+
readonly delay?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function Tooltip({ content, children, side, delay, className, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur, ...rest }: TooltipProps): ReactElement;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cloneElement, isValidElement, useCallback, useEffect, useId, useRef, useState, } from 'react';
|
|
3
|
+
import { cx } from '../controls/util.js';
|
|
4
|
+
export function Tooltip({ content, children, side = 'top', delay = 150, className, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur, ...rest }) {
|
|
5
|
+
const panelId = useId();
|
|
6
|
+
const [hovered, setHovered] = useState(false);
|
|
7
|
+
const [focused, setFocused] = useState(false);
|
|
8
|
+
const [dismissed, setDismissed] = useState(false);
|
|
9
|
+
const timer = useRef(undefined);
|
|
10
|
+
const clear = useCallback(() => {
|
|
11
|
+
if (timer.current !== undefined) {
|
|
12
|
+
clearTimeout(timer.current);
|
|
13
|
+
timer.current = undefined;
|
|
14
|
+
}
|
|
15
|
+
}, []);
|
|
16
|
+
useEffect(() => clear, [clear]);
|
|
17
|
+
/* Every one of these CHAINS the caller's handler rather than replacing it. They used to be written
|
|
18
|
+
after `{...rest}` in the JSX, which meant a caller's `onMouseEnter` / `onMouseLeave` / `onFocus` /
|
|
19
|
+
`onBlur` was silently overridden — `onKeyDown` was already chained here, which is what shows the
|
|
20
|
+
other four were an oversight rather than a decision. */
|
|
21
|
+
const openHover = useCallback((e) => {
|
|
22
|
+
onMouseEnter?.(e);
|
|
23
|
+
clear();
|
|
24
|
+
if (delay > 0) {
|
|
25
|
+
timer.current = setTimeout(() => setHovered(true), delay);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
setHovered(true);
|
|
29
|
+
}
|
|
30
|
+
}, [clear, delay, onMouseEnter]);
|
|
31
|
+
const closeHover = useCallback((e) => {
|
|
32
|
+
onMouseLeave?.(e);
|
|
33
|
+
clear();
|
|
34
|
+
setHovered(false);
|
|
35
|
+
setDismissed(false);
|
|
36
|
+
}, [clear, onMouseLeave]);
|
|
37
|
+
const openFocus = useCallback((e) => {
|
|
38
|
+
onFocus?.(e);
|
|
39
|
+
setFocused(true);
|
|
40
|
+
}, [onFocus]);
|
|
41
|
+
const closeFocus = useCallback((e) => {
|
|
42
|
+
onBlur?.(e);
|
|
43
|
+
setFocused(false);
|
|
44
|
+
setDismissed(false);
|
|
45
|
+
}, [onBlur]);
|
|
46
|
+
const handleKeyDown = useCallback((e) => {
|
|
47
|
+
onKeyDown?.(e);
|
|
48
|
+
if (e.key === 'Escape')
|
|
49
|
+
setDismissed(true);
|
|
50
|
+
}, [onKeyDown]);
|
|
51
|
+
const open = (hovered || focused) && !dismissed;
|
|
52
|
+
const state = open ? 'open' : 'closed';
|
|
53
|
+
// Wire the description onto the interactive trigger when it is a single element (the correct
|
|
54
|
+
// aria-describedby seam); fall back to rendering plain nodes as-is (the role still describes it).
|
|
55
|
+
const trigger = isValidElement(children)
|
|
56
|
+
? cloneElement(children, {
|
|
57
|
+
'aria-describedby': panelId,
|
|
58
|
+
})
|
|
59
|
+
: children;
|
|
60
|
+
return (_jsxs("span", { className: cx('xeno-tooltip', className), "data-side": side, "data-state": state, ...rest, onMouseEnter: openHover, onMouseLeave: closeHover, onFocus: openFocus, onBlur: closeFocus, onKeyDown: handleKeyDown, children: [trigger, _jsxs("span", { className: "xeno-tooltip-panel", role: "tooltip", id: panelId, "data-side": side, "data-state": state, children: [content, _jsx("span", { className: "xeno-tooltip-arrow", "aria-hidden": "true" })] })] }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `<ProgressBar>` — the determinate progress signal. A flat track (`--xeno-control`, `radius-sm`,
|
|
4
|
+
* ~4px tall) holds a fill (`--xeno-text`) whose width is the clamped `value` (0..1) as a percentage.
|
|
5
|
+
* The track carries `role="progressbar"` + `aria-valuenow/min/max` (0..100). An optional `label`
|
|
6
|
+
* adds a header row (label + a tabular percent readout) that is `aria-hidden` — the progressbar
|
|
7
|
+
* already announces its name and value, so the visible header would only double-read.
|
|
8
|
+
*/
|
|
9
|
+
export interface ProgressBarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
10
|
+
/**
|
|
11
|
+
* Progress fraction, 0..1. Values outside the range are clamped; non-finite reads as 0. Pass `null`
|
|
12
|
+
* for INDETERMINATE — work is happening but its extent is not known. That is a different statement
|
|
13
|
+
* from `0`, which claims the work has measurably not started, and it is the one this component could
|
|
14
|
+
* not make until now.
|
|
15
|
+
*/
|
|
16
|
+
readonly value: number | null;
|
|
17
|
+
/** Optional caption; also becomes the progressbar's accessible name. */
|
|
18
|
+
readonly label?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function ProgressBar({ value, label, className, ...rest }: ProgressBarProps): ReactElement;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from '../controls/util.js';
|
|
3
|
+
export function ProgressBar({ value, label, className, ...rest }) {
|
|
4
|
+
const indeterminate = value === null;
|
|
5
|
+
const safe = value !== null && Number.isFinite(value) ? value : 0;
|
|
6
|
+
const fraction = Math.min(1, Math.max(0, safe));
|
|
7
|
+
const pct = Math.round(fraction * 100);
|
|
8
|
+
// Keep the fill width to 0.1% precision so the inline style stays clean of float noise.
|
|
9
|
+
const widthPct = Math.round(fraction * 1000) / 10;
|
|
10
|
+
return (_jsxs("div", { className: cx('xeno-progressbar', className), ...rest, children: [label !== undefined && (_jsxs("div", { className: "xeno-progressbar-header", "aria-hidden": "true", children: [_jsx("span", { className: "xeno-progressbar-label", children: label }), _jsx("span", { className: "xeno-progressbar-value", children: indeterminate ? '—' : `${pct}%` })] })), _jsx("span", { role: "progressbar", ...(indeterminate ? {} : { 'aria-valuenow': pct }), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": label !== undefined ? label : 'Progress', className: "xeno-progressbar-track", "data-state": indeterminate ? 'indeterminate' : 'determinate', children: _jsx("span", { className: "xeno-progressbar-fill", ...(indeterminate ? {} : { style: { width: `${widthPct}%` } }) }) })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `<Spinner>` — the indeterminate loader, the `busy` signal made visible. A small rounded SQUARE
|
|
4
|
+
* outline (never a circle): all four edges sit in `--xeno-muted` as the track, the top edge burns to
|
|
5
|
+
* `--xeno-text` as the bright leading edge, and the whole square rotates. `size` drives both the edge
|
|
6
|
+
* length and the stroke, emitted as inline `--xeno-spinner-size/-stroke` vars so the metrics can never
|
|
7
|
+
* drift from the data. Motion lives behind `prefers-reduced-motion: no-preference`. Announces itself
|
|
8
|
+
* with `role="status"` + `aria-label` (default `'Loading'`).
|
|
9
|
+
*/
|
|
10
|
+
export interface SpinnerProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
|
|
11
|
+
/** Edge length of the square, in px. Stroke scales with it. Defaults to 16. */
|
|
12
|
+
readonly size?: number;
|
|
13
|
+
/** The accessible label announced by assistive tech. Defaults to `'Loading'`. */
|
|
14
|
+
readonly label?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function Spinner({ size, label, className, style, ...rest }: SpinnerProps): ReactElement;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from '../controls/util.js';
|
|
3
|
+
export function Spinner({ size = 16, label = 'Loading', className, style, ...rest }) {
|
|
4
|
+
// Guard against a zero/negative/NaN size, then scale the stroke off the edge (min 2px).
|
|
5
|
+
const edge = Number.isFinite(size) && size > 0 ? size : 16;
|
|
6
|
+
const stroke = Math.max(2, Math.round(edge / 8));
|
|
7
|
+
const vars = {
|
|
8
|
+
['--xeno-spinner-size']: `${edge}px`,
|
|
9
|
+
['--xeno-spinner-stroke']: `${stroke}px`,
|
|
10
|
+
};
|
|
11
|
+
return (_jsx("span", { role: "status", "aria-label": label, "aria-live": "polite", className: cx('xeno-spinner', className), style: { ...vars, ...style }, ...rest }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `<StatusIndicator>` — an inline square status MARK (radius-xs, never a circle) beside a label. The
|
|
4
|
+
* `tone` colours the mark CSS-first via `data-tone`:
|
|
5
|
+
* - `neutral` — a `--xeno-muted` square (dormant);
|
|
6
|
+
* - `active` — a `--xeno-active` square that BREATHES (scale keyframe, silenced under reduced-motion);
|
|
7
|
+
* - `success` — the `success` status TOKEN, injected as the inline `--xeno-status-tone` var so the
|
|
8
|
+
* value can never drift from the data;
|
|
9
|
+
* - `danger` — the theme's `--xeno-danger` (the one sanctioned hue that IS a theme var).
|
|
10
|
+
* `neutral`/`active` stay inside the monochrome shell; `success`/`danger` are the status exceptions.
|
|
11
|
+
*/
|
|
12
|
+
export type StatusIndicatorTone = 'neutral' | 'active' | 'success' | 'danger';
|
|
13
|
+
export interface StatusIndicatorProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'style'> {
|
|
14
|
+
/** The label sitting after the mark. */
|
|
15
|
+
readonly children: ReactNode;
|
|
16
|
+
/** Colours the mark. Defaults to `neutral`. */
|
|
17
|
+
readonly tone?: StatusIndicatorTone;
|
|
18
|
+
}
|
|
19
|
+
export declare function StatusIndicator({ children, tone, className, ...rest }: StatusIndicatorProps): ReactElement;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { status } from '@xenosystem/elements/tokens';
|
|
3
|
+
import { cx } from '../controls/util.js';
|
|
4
|
+
export function StatusIndicator({ children, tone = 'neutral', className, ...rest }) {
|
|
5
|
+
// success is not a theme var — resolve it from the status TOKEN into an inline CSS var.
|
|
6
|
+
// neutral/active/danger need no inline var (monochrome, or danger = --xeno-danger in CSS).
|
|
7
|
+
const toneStyle = tone === 'success'
|
|
8
|
+
? { ['--xeno-status-tone']: status.success }
|
|
9
|
+
: undefined;
|
|
10
|
+
return (_jsxs("span", { className: cx('xeno-statusindicator', className), "data-tone": tone, ...(toneStyle ? { style: toneStyle } : {}), ...rest, children: [_jsx("span", { className: "xeno-statusindicator-mark", "aria-hidden": "true" }), _jsx("span", { className: "xeno-statusindicator-label", children: children })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `<StepTimeline>` — a vertical run of steps threaded by a single hairline spine (a 1.5px rounded
|
|
4
|
+
* `--xeno-border` bar). Each step owns a square status MARK (radius-xs, never a circle) whose paint is
|
|
5
|
+
* driven CSS-first by `data-status` on the step:
|
|
6
|
+
* - `pending` — an outline square in `--xeno-muted` (not yet reached);
|
|
7
|
+
* - `active` — a filled square that BREATHES (a scale keyframe, silenced under reduced-motion);
|
|
8
|
+
* - `done` — a filled `--xeno-text` square stamped with the `check` glyph (in `--xeno-on-accent`).
|
|
9
|
+
* Beside the mark sit a label and an optional `time` (mono, muted). The list is static (no interaction),
|
|
10
|
+
* so `data-status` is the only state seam; a visually-hidden word carries each step's status to a
|
|
11
|
+
* screen reader while the decorative rail stays `aria-hidden`.
|
|
12
|
+
*/
|
|
13
|
+
export type StepStatus = 'pending' | 'active' | 'done';
|
|
14
|
+
export interface Step {
|
|
15
|
+
/** The step's name. */
|
|
16
|
+
readonly label: ReactNode;
|
|
17
|
+
/** Where the step sits in the run — drives the mark's paint via `data-status`. */
|
|
18
|
+
readonly status: StepStatus;
|
|
19
|
+
/** Optional trailing timestamp/duration, rendered mono + muted. */
|
|
20
|
+
readonly time?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface StepTimelineProps extends Omit<HTMLAttributes<HTMLOListElement>, 'children'> {
|
|
23
|
+
/** The steps, top to bottom. The spine threads every mark but the last. */
|
|
24
|
+
readonly steps: readonly Step[];
|
|
25
|
+
}
|
|
26
|
+
export declare function StepTimeline({ steps, className, ...rest }: StepTimelineProps): ReactElement;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { XenoElement } from '../XenoElement.js';
|
|
3
|
+
import { cx } from '../controls/util.js';
|
|
4
|
+
import Check from '@xenosystem/elements/elements/check';
|
|
5
|
+
/** Glyph px inside the ~16px done mark — small enough to breathe within the square. */
|
|
6
|
+
const GLYPH_PX = 11;
|
|
7
|
+
/** The word a screen reader hears for each status (the mark itself is decorative). */
|
|
8
|
+
const STATUS_WORD = {
|
|
9
|
+
pending: 'Pending',
|
|
10
|
+
active: 'In progress',
|
|
11
|
+
done: 'Done',
|
|
12
|
+
};
|
|
13
|
+
export function StepTimeline({ steps, className, ...rest }) {
|
|
14
|
+
const lastIndex = steps.length - 1;
|
|
15
|
+
return (_jsx("ol", { className: cx('xeno-steptimeline', className), ...rest, children: steps.map((step, i) => (_jsxs("li", { className: "xeno-steptimeline-step", "data-status": step.status, children: [_jsxs("span", { className: "xeno-steptimeline-rail", "aria-hidden": "true", children: [_jsx("span", { className: "xeno-steptimeline-mark", children: step.status === 'done' ? _jsx(XenoElement, { decl: Check, size: GLYPH_PX }) : null }), i !== lastIndex && _jsx("span", { className: "xeno-steptimeline-spine" })] }), _jsxs("span", { className: "xeno-steptimeline-body", children: [_jsxs("span", { className: "xeno-steptimeline-label", children: [step.label, _jsxs("span", { className: "xeno-steptimeline-sr", children: [" \u2014 ", STATUS_WORD[step.status]] })] }), step.time !== undefined && (_jsx("span", { className: "xeno-steptimeline-time", children: step.time }))] })] }, i))) }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `<ThinkingCube>` — the LOCKED branded model avatar. A single rounded SQUARE (`radius.xs`, muted 1.5px
|
|
4
|
+
* outline) that runs ONE continuous animation per state — never a set of discrete frames, never a circle:
|
|
5
|
+
*
|
|
6
|
+
* - `state="thinking"` — the cube *breathes*: `xeno-cube-life` rotates it 0→360° while the scale pulses
|
|
7
|
+
* 1 → .52 → 1, looping forever. The outline stays muted and the fill stays on the canvas (an opaque
|
|
8
|
+
* outlined square, not a hollow gap).
|
|
9
|
+
* - `state="settled"` — the answer landed: `xeno-cube-settle` runs ONCE, growing the scale .8 → 1.14 → 1
|
|
10
|
+
* while the SAME square's background fills opaque canvas → muted (both ends opaque, so there is never a
|
|
11
|
+
* dead invisible phase), then rests as a solid muted cube.
|
|
12
|
+
*
|
|
13
|
+
* The visual body is `aria-hidden`; the root is `role="img"` with a state-aware label so a screen reader
|
|
14
|
+
* announces the model's status once. Honours `prefers-reduced-motion`: reduce → no spin, and `settled`
|
|
15
|
+
* shows the filled square statically. `size` (default 24) is the pixel edge, forwarded as a CSS var.
|
|
16
|
+
*/
|
|
17
|
+
export type ThinkingCubeState = 'thinking' | 'settled';
|
|
18
|
+
export interface ThinkingCubeProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
|
|
19
|
+
/** Which continuous animation the cube runs. */
|
|
20
|
+
readonly state: ThinkingCubeState;
|
|
21
|
+
/** Pixel edge length of the square. Default 24. */
|
|
22
|
+
readonly size?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function ThinkingCube({ state, size, className, style, ...rest }: ThinkingCubeProps): ReactElement;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from '../controls/util.js';
|
|
3
|
+
/** Default accessible label per state (a caller may override with an explicit `aria-label`). */
|
|
4
|
+
const CUBE_LABEL = {
|
|
5
|
+
thinking: 'Model is thinking',
|
|
6
|
+
settled: 'Answer ready',
|
|
7
|
+
};
|
|
8
|
+
export function ThinkingCube({ state, size = 24, className, style, ...rest }) {
|
|
9
|
+
const rootStyle = { ['--xeno-cube-size']: `${size}px`, ...style };
|
|
10
|
+
return (_jsx("span", { className: cx('xeno-cube', className), "data-state": state, role: "img", "aria-label": CUBE_LABEL[state], style: rootStyle, ...rest, children: _jsx("span", { className: "xeno-cube-body", "aria-hidden": "true" }) }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { KeyboardEvent as ReactKeyboardEvent, MutableRefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `useDialog()` — everything a dialog has to DO, for a dialog this library did not build.
|
|
4
|
+
*
|
|
5
|
+
* Escape closes it, focus moves in when it opens and returns to whatever opened it when it closes, Tab
|
|
6
|
+
* stays inside, and the page behind stops scrolling. None of that is visual, and all of it is what
|
|
7
|
+
* separates a dialog from a `<div>` that happens to sit above the page.
|
|
8
|
+
*
|
|
9
|
+
* It exists because {@link Modal} had all of this and nothing else could reach it. A product with its
|
|
10
|
+
* own dialog design — its own scrim, its own card, its own entrance — had the choice of adopting the
|
|
11
|
+
* component and its looks together or writing the behaviour again, and what actually happens is the
|
|
12
|
+
* third thing: the behaviour never gets written. Measured on a real one before this existed — focus
|
|
13
|
+
* stayed on `body` when it opened, Tab walked straight out into the page behind it, and closing it left
|
|
14
|
+
* focus nowhere. The look was finished; the dialog was not.
|
|
15
|
+
*
|
|
16
|
+
* So the look stays with the product and the behaviour comes from here, one line at a time:
|
|
17
|
+
*
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const { panelProps } = useDialog({ open, onClose })
|
|
20
|
+
* return open ? <div className="my-scrim"><div role="dialog" aria-modal="true" {...panelProps} /></div> : null
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export interface UseDialogOptions<T extends HTMLElement> {
|
|
24
|
+
/** Whether the dialog is open. Everything here is inert while false. */
|
|
25
|
+
readonly open: boolean;
|
|
26
|
+
/** Called on Escape. Omit and the hook leaves the key alone. */
|
|
27
|
+
readonly onClose?: () => void;
|
|
28
|
+
/** An existing ref for the panel, if the caller already has one. */
|
|
29
|
+
readonly panelRef?: MutableRefObject<T | null>;
|
|
30
|
+
/** Lock the page scroll behind the dialog. Default `true`. */
|
|
31
|
+
readonly lockScroll?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface UseDialogResult<T extends HTMLElement> {
|
|
34
|
+
/** The dialog panel. `panelProps` already carries it. */
|
|
35
|
+
readonly panelRef: MutableRefObject<T | null>;
|
|
36
|
+
/** Spread on the element that carries `role="dialog"`. */
|
|
37
|
+
readonly panelProps: {
|
|
38
|
+
/** A CALLBACK ref — it is what makes focus land the moment the panel exists. */
|
|
39
|
+
readonly ref: (node: T | null) => void;
|
|
40
|
+
/** So the panel itself can hold focus before the user has tabbed to anything. */
|
|
41
|
+
readonly tabIndex: -1;
|
|
42
|
+
readonly onKeyDown: (e: ReactKeyboardEvent<T>) => void;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export declare function useDialog<T extends HTMLElement = HTMLDivElement>({ open, onClose, panelRef: providedRef, lockScroll, }: UseDialogOptions<T>): UseDialogResult<T>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
3
|
+
/**
|
|
4
|
+
* Page-scroll lock, REFCOUNTED across every open dialog.
|
|
5
|
+
*
|
|
6
|
+
* Each dialog saving and restoring `document.body.style.overflow` for itself is only correct while
|
|
7
|
+
* exactly one exists. Two mounted as siblings both capture a value and both write one back, in tree
|
|
8
|
+
* order: the first restores `''`, and the second then restores the `'hidden'` it captured — leaving the
|
|
9
|
+
* page permanently unscrollable with nothing on screen. A count, and one saved value taken when the
|
|
10
|
+
* count leaves zero, is the whole fix.
|
|
11
|
+
*/
|
|
12
|
+
let scrollLocks = 0;
|
|
13
|
+
let scrollPrevious = '';
|
|
14
|
+
const lockPageScroll = () => {
|
|
15
|
+
if (scrollLocks === 0) {
|
|
16
|
+
scrollPrevious = document.body.style.overflow;
|
|
17
|
+
document.body.style.overflow = 'hidden';
|
|
18
|
+
}
|
|
19
|
+
scrollLocks += 1;
|
|
20
|
+
};
|
|
21
|
+
const unlockPageScroll = () => {
|
|
22
|
+
scrollLocks = Math.max(0, scrollLocks - 1);
|
|
23
|
+
if (scrollLocks === 0)
|
|
24
|
+
document.body.style.overflow = scrollPrevious;
|
|
25
|
+
};
|
|
26
|
+
export function useDialog({ open, onClose, panelRef: providedRef, lockScroll = true, }) {
|
|
27
|
+
const ownRef = useRef(null);
|
|
28
|
+
const panelRef = providedRef ?? ownRef;
|
|
29
|
+
const restoreFocus = useRef(null);
|
|
30
|
+
// Escape, bound on the DOCUMENT so it fires regardless of where focus sits inside the dialog.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open || !onClose)
|
|
33
|
+
return;
|
|
34
|
+
const onKey = (e) => {
|
|
35
|
+
if (e.key !== 'Escape')
|
|
36
|
+
return;
|
|
37
|
+
/* `stopImmediatePropagation`, not `stopPropagation`. This listener is on `document`, the last node
|
|
38
|
+
in the bubble path, so there is nothing left to propagate TO — and stopping propagation says
|
|
39
|
+
nothing about other listeners on the same node. Only the immediate form reaches them. The case
|
|
40
|
+
it is here for: a dialog opened from inside a menu or popover that keeps its own document-level
|
|
41
|
+
Escape handler, where one press would otherwise close the dialog and the surface behind it. */
|
|
42
|
+
e.stopImmediatePropagation();
|
|
43
|
+
onClose();
|
|
44
|
+
};
|
|
45
|
+
document.addEventListener('keydown', onKey);
|
|
46
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
47
|
+
}, [open, onClose]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!open || !lockScroll)
|
|
50
|
+
return;
|
|
51
|
+
lockPageScroll();
|
|
52
|
+
return unlockPageScroll;
|
|
53
|
+
}, [open, lockScroll]);
|
|
54
|
+
/**
|
|
55
|
+
* Focus in when the panel APPEARS, not when `open` turns true.
|
|
56
|
+
*
|
|
57
|
+
* Those are the same moment only for a dialog that renders in the same commit as its flag. Real ones
|
|
58
|
+
* often do not: a common shape is open-then-mount, where one state flag says the dialog is open and a
|
|
59
|
+
* second, set by an effect that reacts to the first, is what actually renders the portal. An effect
|
|
60
|
+
* that focuses `panelRef.current` finds null, and focusing on the next frame is a guess — sometimes the
|
|
61
|
+
* panel is there and sometimes it is still two commits away. Measured on the chat's delete
|
|
62
|
+
* confirmation: trap and Escape worked, and focus never moved at all.
|
|
63
|
+
*
|
|
64
|
+
* A callback ref has no timing to get wrong. It runs the instant the node attaches, whenever that is.
|
|
65
|
+
*
|
|
66
|
+
* It also captures the element to restore to, right there, because a callback ref fires during commit —
|
|
67
|
+
* BEFORE effects — so an effect that read `document.activeElement` would already be reading the panel
|
|
68
|
+
* we are about to focus, and "restore" would mean restoring to the dialog itself.
|
|
69
|
+
*/
|
|
70
|
+
const focusedOnce = useRef(false);
|
|
71
|
+
const openRef = useRef(open);
|
|
72
|
+
openRef.current = open;
|
|
73
|
+
const setPanel = useCallback((node) => {
|
|
74
|
+
panelRef.current = node;
|
|
75
|
+
if (!node || !openRef.current || focusedOnce.current)
|
|
76
|
+
return;
|
|
77
|
+
restoreFocus.current = document.activeElement;
|
|
78
|
+
focusedOnce.current = true;
|
|
79
|
+
node.focus();
|
|
80
|
+
}, [panelRef]);
|
|
81
|
+
// Hand focus back to the opener on close. The cleanup of an effect that only runs while open IS the
|
|
82
|
+
// close, and nothing else has to know when that happened.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!open)
|
|
85
|
+
return;
|
|
86
|
+
return () => {
|
|
87
|
+
focusedOnce.current = false;
|
|
88
|
+
restoreFocus.current?.focus?.();
|
|
89
|
+
};
|
|
90
|
+
}, [open]);
|
|
91
|
+
const onKeyDown = useCallback((e) => {
|
|
92
|
+
if (e.key !== 'Tab')
|
|
93
|
+
return;
|
|
94
|
+
const panel = panelRef.current;
|
|
95
|
+
if (!panel)
|
|
96
|
+
return;
|
|
97
|
+
// `offsetParent === null` filters out anything hidden — a collapsed section's controls are in the
|
|
98
|
+
// DOM and must not be tab stops.
|
|
99
|
+
const list = Array.from(panel.querySelectorAll(FOCUSABLE)).filter((n) => n.offsetParent !== null);
|
|
100
|
+
const first = list[0];
|
|
101
|
+
const last = list[list.length - 1];
|
|
102
|
+
if (!first || !last) {
|
|
103
|
+
// Nothing focusable inside: hold focus on the panel rather than letting Tab walk out.
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
panel.focus();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const active = document.activeElement;
|
|
109
|
+
if (e.shiftKey && active === first) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
last.focus();
|
|
112
|
+
}
|
|
113
|
+
else if (!e.shiftKey && active === last) {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
first.focus();
|
|
116
|
+
}
|
|
117
|
+
}, [panelRef]);
|
|
118
|
+
return {
|
|
119
|
+
panelRef,
|
|
120
|
+
panelProps: { ref: setPanel, tabIndex: -1, onKeyDown },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { FocusEvent as ReactFocusEvent, MouseEvent as ReactMouseEvent, MutableRefObject, ReactElement, RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `useGooPill()` — the travelling hover pill (`goo.css`) for a list this hook does not own.
|
|
4
|
+
*
|
|
5
|
+
* The pill is CSS; the two numbers CSS cannot work out are where the hovered row starts and how tall it
|
|
6
|
+
* is. This hook reads them off the row and writes them to the host as custom properties. Everything
|
|
7
|
+
* between those numbers — the travel, the stretch, the fade — stays in the stylesheet.
|
|
8
|
+
*
|
|
9
|
+
* MEASURED rather than computed, and that is the whole reason it exists. A component that owns its rows
|
|
10
|
+
* (the sidebar maps an `items` array) can do this in pure CSS: every row is the same height, so the
|
|
11
|
+
* offset is `index x stride`. A host that takes arbitrary children cannot — someone will put a
|
|
12
|
+
* separator, a heading or a two-line row in there, and a stride from a constant lands the pill between
|
|
13
|
+
* two rows with nothing to say why.
|
|
14
|
+
*
|
|
15
|
+
* Usage — spread `hostProps` on the list and render `pill` as its FIRST child:
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* const { hostProps, pill } = useGooPill<HTMLDivElement>()
|
|
19
|
+
* return (
|
|
20
|
+
* <div role="menu" {...hostProps} className={`${hostProps.className} my-panel`}>
|
|
21
|
+
* {pill}
|
|
22
|
+
* <button role="menuitem">…</button>
|
|
23
|
+
* </div>
|
|
24
|
+
* )
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* The host must stop painting a hover background on its rows: the pill IS the filled surface, and two
|
|
28
|
+
* of them read as two highlights.
|
|
29
|
+
*/
|
|
30
|
+
export interface UseGooPillOptions<T extends HTMLElement> {
|
|
31
|
+
/**
|
|
32
|
+
* Which descendants count as rows. Default: enabled `role="menuitem*"` buttons.
|
|
33
|
+
*
|
|
34
|
+
* Rows outside the host's own layout tree are ignored even when they match — a nested submenu is
|
|
35
|
+
* somebody else's list, and its rows are not this pill's to land on.
|
|
36
|
+
*/
|
|
37
|
+
readonly rowSelector?: string;
|
|
38
|
+
/** An existing ref for the host, if the caller already has one. */
|
|
39
|
+
readonly hostRef?: MutableRefObject<T | null>;
|
|
40
|
+
}
|
|
41
|
+
export interface UseGooPillResult<T extends HTMLElement> {
|
|
42
|
+
/** The host element, for callers that need it themselves. `hostProps` already carries it. */
|
|
43
|
+
readonly hostRef: MutableRefObject<T | null>;
|
|
44
|
+
/** Spread on the list element. Carries the `xeno-goo-host` class and the pointer/focus handlers. */
|
|
45
|
+
readonly hostProps: {
|
|
46
|
+
readonly ref: RefObject<T>;
|
|
47
|
+
readonly className: string;
|
|
48
|
+
readonly onMouseOver: (e: ReactMouseEvent<T>) => void;
|
|
49
|
+
readonly onMouseLeave: () => void;
|
|
50
|
+
readonly onFocus: (e: ReactFocusEvent<T>) => void;
|
|
51
|
+
};
|
|
52
|
+
/** Render as the host's first child. */
|
|
53
|
+
readonly pill: ReactElement;
|
|
54
|
+
/** Put the pill on a row, or pass `null` to take it away. Exposed for keyboard-driven hosts. */
|
|
55
|
+
readonly moveGoo: (row: HTMLElement | null) => void;
|
|
56
|
+
}
|
|
57
|
+
export declare function useGooPill<T extends HTMLElement = HTMLDivElement>(options?: UseGooPillOptions<T>): UseGooPillResult<T>;
|