@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.
Files changed (153) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +41 -0
  3. package/dist/XenoElement.d.ts +39 -0
  4. package/dist/XenoElement.js +25 -0
  5. package/dist/containers/Avatar.d.ts +42 -0
  6. package/dist/containers/Avatar.js +29 -0
  7. package/dist/containers/Badges.d.ts +61 -0
  8. package/dist/containers/Badges.js +43 -0
  9. package/dist/containers/Card.d.ts +24 -0
  10. package/dist/containers/Card.js +5 -0
  11. package/dist/containers/Chip.d.ts +34 -0
  12. package/dist/containers/Chip.js +17 -0
  13. package/dist/containers/DataTable.d.ts +28 -0
  14. package/dist/containers/DataTable.js +13 -0
  15. package/dist/containers/ListRow.d.ts +37 -0
  16. package/dist/containers/ListRow.js +40 -0
  17. package/dist/containers/MessageBubble.d.ts +33 -0
  18. package/dist/containers/MessageBubble.js +9 -0
  19. package/dist/containers/Panel.d.ts +24 -0
  20. package/dist/containers/Panel.js +6 -0
  21. package/dist/containers/Table.d.ts +39 -0
  22. package/dist/containers/Table.js +20 -0
  23. package/dist/containers/Tile.d.ts +19 -0
  24. package/dist/containers/Tile.js +7 -0
  25. package/dist/content/Callout.d.ts +32 -0
  26. package/dist/content/Callout.js +10 -0
  27. package/dist/content/Caret.d.ts +9 -0
  28. package/dist/content/Caret.js +5 -0
  29. package/dist/content/CodeBlock.d.ts +36 -0
  30. package/dist/content/CodeBlock.js +50 -0
  31. package/dist/content/Collapsible.d.ts +24 -0
  32. package/dist/content/Collapsible.js +19 -0
  33. package/dist/content/InlineCode.d.ts +12 -0
  34. package/dist/content/InlineCode.js +5 -0
  35. package/dist/content/ModelPicker.d.ts +45 -0
  36. package/dist/content/ModelPicker.js +124 -0
  37. package/dist/content/SourceCard.d.ts +23 -0
  38. package/dist/content/SourceCard.js +18 -0
  39. package/dist/content/SourcesDisclosure.d.ts +26 -0
  40. package/dist/content/SourcesDisclosure.js +20 -0
  41. package/dist/controls/Button.d.ts +39 -0
  42. package/dist/controls/Button.js +16 -0
  43. package/dist/controls/ConfirmButton.d.ts +22 -0
  44. package/dist/controls/ConfirmButton.js +22 -0
  45. package/dist/controls/IconButton.d.ts +45 -0
  46. package/dist/controls/IconButton.js +15 -0
  47. package/dist/controls/Switch.d.ts +11 -0
  48. package/dist/controls/Switch.js +23 -0
  49. package/dist/controls/TextInput.d.ts +13 -0
  50. package/dist/controls/TextInput.js +6 -0
  51. package/dist/controls/Textarea.d.ts +8 -0
  52. package/dist/controls/Textarea.js +5 -0
  53. package/dist/controls/ToggleButton.d.ts +23 -0
  54. package/dist/controls/ToggleButton.js +23 -0
  55. package/dist/controls/util.d.ts +48 -0
  56. package/dist/controls/util.js +23 -0
  57. package/dist/forms/Checkbox.d.ts +18 -0
  58. package/dist/forms/Checkbox.js +28 -0
  59. package/dist/forms/RadioGroup.d.ts +51 -0
  60. package/dist/forms/RadioGroup.js +83 -0
  61. package/dist/index.d.ts +75 -0
  62. package/dist/index.js +81 -0
  63. package/dist/layout/ResizablePanel.d.ts +40 -0
  64. package/dist/layout/ResizablePanel.js +108 -0
  65. package/dist/nav/Sidebar.d.ts +60 -0
  66. package/dist/nav/Sidebar.js +84 -0
  67. package/dist/overlays/DateTimePicker.d.ts +71 -0
  68. package/dist/overlays/DateTimePicker.js +191 -0
  69. package/dist/overlays/Menu.d.ts +28 -0
  70. package/dist/overlays/Menu.js +32 -0
  71. package/dist/overlays/MenuItem.d.ts +71 -0
  72. package/dist/overlays/MenuItem.js +15 -0
  73. package/dist/overlays/Modal.d.ts +36 -0
  74. package/dist/overlays/Modal.js +27 -0
  75. package/dist/overlays/PickerField.d.ts +32 -0
  76. package/dist/overlays/PickerField.js +40 -0
  77. package/dist/overlays/PillFilter.d.ts +25 -0
  78. package/dist/overlays/PillFilter.js +41 -0
  79. package/dist/overlays/Popover.d.ts +32 -0
  80. package/dist/overlays/Popover.js +38 -0
  81. package/dist/overlays/Reveal.d.ts +20 -0
  82. package/dist/overlays/Reveal.js +45 -0
  83. package/dist/overlays/SegmentedControl.d.ts +23 -0
  84. package/dist/overlays/SegmentedControl.js +43 -0
  85. package/dist/overlays/Tabs.d.ts +32 -0
  86. package/dist/overlays/Tabs.js +142 -0
  87. package/dist/overlays/Tooltip.d.ts +23 -0
  88. package/dist/overlays/Tooltip.js +61 -0
  89. package/dist/status/ProgressBar.d.ts +20 -0
  90. package/dist/status/ProgressBar.js +11 -0
  91. package/dist/status/Spinner.d.ts +16 -0
  92. package/dist/status/Spinner.js +12 -0
  93. package/dist/status/StatusIndicator.d.ts +19 -0
  94. package/dist/status/StatusIndicator.js +11 -0
  95. package/dist/status/StepTimeline.d.ts +26 -0
  96. package/dist/status/StepTimeline.js +16 -0
  97. package/dist/status/ThinkingCube.d.ts +24 -0
  98. package/dist/status/ThinkingCube.js +11 -0
  99. package/dist/useDialog.d.ts +45 -0
  100. package/dist/useDialog.js +122 -0
  101. package/dist/useGooPill.d.ts +57 -0
  102. package/dist/useGooPill.js +76 -0
  103. package/dist/useMenu.d.ts +50 -0
  104. package/dist/useMenu.js +119 -0
  105. package/dist/useTabs.d.ts +88 -0
  106. package/dist/useTabs.js +93 -0
  107. package/package.json +58 -0
  108. package/src/chrome-separated.css +132 -0
  109. package/src/chrome-unified.css +72 -0
  110. package/src/containers/Badges.css +116 -0
  111. package/src/containers/avatar.css +70 -0
  112. package/src/containers/card.css +75 -0
  113. package/src/containers/chip.css +103 -0
  114. package/src/containers/list-row.css +132 -0
  115. package/src/containers/message-bubble.css +112 -0
  116. package/src/containers/panel.css +120 -0
  117. package/src/containers/table.css +102 -0
  118. package/src/containers/tile.css +17 -0
  119. package/src/content/callout.css +77 -0
  120. package/src/content/caret.css +30 -0
  121. package/src/content/code-block.css +159 -0
  122. package/src/content/collapsible.css +80 -0
  123. package/src/content/inline-code.css +14 -0
  124. package/src/content/model-picker.css +219 -0
  125. package/src/content/source-card.css +99 -0
  126. package/src/content/sources-disclosure.css +118 -0
  127. package/src/controls.css +453 -0
  128. package/src/fonts.css +34 -0
  129. package/src/forms/Checkbox.css +114 -0
  130. package/src/forms/RadioGroup.css +115 -0
  131. package/src/goo.css +97 -0
  132. package/src/icon-motion.css +1909 -0
  133. package/src/layout/ResizablePanel.css +79 -0
  134. package/src/nav/sidebar.css +296 -0
  135. package/src/overlays/date-time-picker.css +238 -0
  136. package/src/overlays/menu.css +195 -0
  137. package/src/overlays/modal.css +167 -0
  138. package/src/overlays/picker-field.css +81 -0
  139. package/src/overlays/pill-filter.css +129 -0
  140. package/src/overlays/reveal.css +56 -0
  141. package/src/overlays/segmented-control.css +185 -0
  142. package/src/overlays/tabs.css +143 -0
  143. package/src/overlays/tooltip.css +126 -0
  144. package/src/scrollbar.css +184 -0
  145. package/src/size.css +179 -0
  146. package/src/status/progress-bar.css +88 -0
  147. package/src/status/spinner.css +33 -0
  148. package/src/status/statusindicator.css +65 -0
  149. package/src/status/steptimeline.css +137 -0
  150. package/src/status/thinkingcube.css +75 -0
  151. package/src/xeno-element.css +60 -0
  152. package/src/xeno-elements.css +71 -0
  153. package/src/xeno-theme.css +99 -0
package/dist/index.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `@xenosystem/elements-react` — the XENO web renderer (React + SVG).
3
+ *
4
+ * - {@link XenoElement} renders any element DECLARATION (icons + stateful glyphs) from data.
5
+ * - Tier-1 CONTROLS (Button, IconButton, ToggleButton, Switch, TextInput, Textarea) are the first
6
+ * non-icon elements — styled from the design tokens, CSS-first, behaviour hand-rolled (Base UI later).
7
+ * - Tier-2 ELEMENTS (containers, overlays, forms, content, nav, layout, status) compose the controls
8
+ * and the renderer into the surfaces a chat/agent product needs.
9
+ *
10
+ * Import the styles once:
11
+ * `@xenosystem/elements-react/xeno-elements.css` (theme tokens + element + control + every element style)
12
+ * or à la carte:
13
+ * `@xenosystem/elements-react/xeno-theme.css` (tokens as CSS vars; scope with a `.xeno` element)
14
+ * `@xenosystem/elements-react/controls.css` (control styles)
15
+ *
16
+ * Glyphs are DATA: import each per-id from `@xenosystem/elements/elements/<id>`. No barrel.
17
+ *
18
+ * @module
19
+ */
20
+ export { XenoElement } from './XenoElement.js';
21
+ export { useDialog } from './useDialog.js';
22
+ export { useTabs } from './useTabs.js';
23
+ export { useMenu } from './useMenu.js';
24
+ export { useGooPill, } from './useGooPill.js';
25
+ // ── Tier-1 controls ─────────────────────────────────────────────────────────────────────────────
26
+ export { Button } from './controls/Button.js';
27
+ export { IconButton } from './controls/IconButton.js';
28
+ export { ConfirmButton } from './controls/ConfirmButton.js';
29
+ export { ToggleButton } from './controls/ToggleButton.js';
30
+ export { Switch } from './controls/Switch.js';
31
+ export { TextInput } from './controls/TextInput.js';
32
+ export { Textarea } from './controls/Textarea.js';
33
+ // ── Containers ──────────────────────────────────────────────────────────────────────────────────
34
+ export { Card } from './containers/Card.js';
35
+ export { Panel } from './containers/Panel.js';
36
+ export { Chip } from './containers/Chip.js';
37
+ export { Tile } from './containers/Tile.js';
38
+ export { MessageBubble } from './containers/MessageBubble.js';
39
+ export { Badge } from './containers/Badges.js';
40
+ export { StatusPill } from './containers/Badges.js';
41
+ export { CountBadge } from './containers/Badges.js';
42
+ export { CitationBadge } from './containers/Badges.js';
43
+ export { Avatar } from './containers/Avatar.js';
44
+ export { AvatarStack } from './containers/Avatar.js';
45
+ export { ListRow } from './containers/ListRow.js';
46
+ export { Table, THead, TBody, Tr, Th, Td, } from './containers/Table.js';
47
+ export { DataTable } from './containers/DataTable.js';
48
+ // ── Overlays ────────────────────────────────────────────────────────────────────────────────────
49
+ export { Popover } from './overlays/Popover.js';
50
+ export { Menu } from './overlays/Menu.js';
51
+ export { MenuItem } from './overlays/MenuItem.js';
52
+ export { Modal, Sheet } from './overlays/Modal.js';
53
+ export { Tabs } from './overlays/Tabs.js';
54
+ export { SegmentedControl } from './overlays/SegmentedControl.js';
55
+ export { PillFilter } from './overlays/PillFilter.js';
56
+ export { Tooltip } from './overlays/Tooltip.js';
57
+ export { PickerField } from './overlays/PickerField.js';
58
+ export { Reveal } from './overlays/Reveal.js';
59
+ export { DatePicker, TimePicker } from './overlays/DateTimePicker.js';
60
+ // ── Forms ───────────────────────────────────────────────────────────────────────────────────────
61
+ export { Checkbox } from './forms/Checkbox.js';
62
+ export { RadioGroup, RadioRow } from './forms/RadioGroup.js';
63
+ // ── Content ─────────────────────────────────────────────────────────────────────────────────────
64
+ export { CodeBlock, } from './content/CodeBlock.js';
65
+ export { SourceCard } from './content/SourceCard.js';
66
+ export { SourcesDisclosure } from './content/SourcesDisclosure.js';
67
+ export { ModelPicker } from './content/ModelPicker.js';
68
+ export { InlineCode } from './content/InlineCode.js';
69
+ export { Callout } from './content/Callout.js';
70
+ export { Collapsible } from './content/Collapsible.js';
71
+ export { Caret } from './content/Caret.js';
72
+ // ── Nav ─────────────────────────────────────────────────────────────────────────────────────────
73
+ export { Sidebar } from './nav/Sidebar.js';
74
+ // ── Layout ──────────────────────────────────────────────────────────────────────────────────────
75
+ export { ResizablePanel } from './layout/ResizablePanel.js';
76
+ // ── Status ──────────────────────────────────────────────────────────────────────────────────────
77
+ export { ThinkingCube } from './status/ThinkingCube.js';
78
+ export { Spinner } from './status/Spinner.js';
79
+ export { ProgressBar } from './status/ProgressBar.js';
80
+ export { StepTimeline } from './status/StepTimeline.js';
81
+ export { StatusIndicator } from './status/StatusIndicator.js';
@@ -0,0 +1,40 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
2
+ /** Which edge the drag handle rides on (and therefore which direction grows the panel). */
3
+ export type ResizablePanelSide = 'left' | 'right';
4
+ export interface ResizablePanelProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {
5
+ /** Edge the handle sits on. `right` grows rightward; `left` grows leftward. Default `right`. */
6
+ readonly side?: ResizablePanelSide;
7
+ /** Starting width (px) when uncontrolled. Default `280`. */
8
+ readonly defaultSize?: number;
9
+ /** Smallest allowed width (px). Default `160`. */
10
+ readonly min?: number;
11
+ /** Largest allowed width (px). Default `480`. */
12
+ readonly max?: number;
13
+ /** Controlled width (px). When set, the component defers to `onSizeChange` for updates. */
14
+ readonly size?: number;
15
+ /** Fires with the next clamped width (px) on drag or keyboard resize. */
16
+ readonly onSizeChange?: (size: number) => void;
17
+ /** Keyboard resize increment (px) for the arrow keys. Default `16`. */
18
+ readonly step?: number;
19
+ /** When true the handle is inert (no drag, no keyboard, disabled cursor). */
20
+ readonly disabled?: boolean;
21
+ /** Accessible name for the resize handle. Default `Resize panel`. */
22
+ readonly handleLabel?: string;
23
+ /** Panel contents. */
24
+ readonly children?: ReactNode;
25
+ }
26
+ /**
27
+ * `<ResizablePanel>` — a fixed-width panel with a draggable divider on one edge.
28
+ *
29
+ * The root is a flex row of the panel body (its `width` the only sized property) and a 4px handle
30
+ * (`cursor: col-resize`) pinned to `side`. A pointer press on the handle registers `window`
31
+ * `pointermove`/`pointerup` listeners (removed on release) and translates horizontal travel into width,
32
+ * clamped to `[min, max]`. `side='right'` grows rightward, `side='left'` grows leftward.
33
+ *
34
+ * Width is CONTROLLED when `size` is supplied (updates flow through `onSizeChange`), otherwise
35
+ * uncontrolled from `defaultSize`. The handle is a `role="separator"` with `aria-orientation="vertical"`
36
+ * and live `aria-valuenow/min/max`; it is keyboard-operable — ←/→ nudge by `step` in the drag direction,
37
+ * Home/End jump to `min`/`max`. Dragging is reflected as `data-dragging` on the root (drives the grab
38
+ * cursor + a text-selection lock); `disabled` flows to `data-availability`.
39
+ */
40
+ export declare function ResizablePanel({ side, defaultSize, min, max, size, onSizeChange, step, disabled, handleLabel, children, className, ...rest }: ResizablePanelProps): ReactElement;
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
4
+ import { cx } from '../controls/util.js';
5
+ /** Keep a value inside `[lo, hi]` (tolerant of an inverted range). */
6
+ function clamp(n, lo, hi) {
7
+ return Math.min(Math.max(n, lo), hi);
8
+ }
9
+ /**
10
+ * `<ResizablePanel>` — a fixed-width panel with a draggable divider on one edge.
11
+ *
12
+ * The root is a flex row of the panel body (its `width` the only sized property) and a 4px handle
13
+ * (`cursor: col-resize`) pinned to `side`. A pointer press on the handle registers `window`
14
+ * `pointermove`/`pointerup` listeners (removed on release) and translates horizontal travel into width,
15
+ * clamped to `[min, max]`. `side='right'` grows rightward, `side='left'` grows leftward.
16
+ *
17
+ * Width is CONTROLLED when `size` is supplied (updates flow through `onSizeChange`), otherwise
18
+ * uncontrolled from `defaultSize`. The handle is a `role="separator"` with `aria-orientation="vertical"`
19
+ * and live `aria-valuenow/min/max`; it is keyboard-operable — ←/→ nudge by `step` in the drag direction,
20
+ * Home/End jump to `min`/`max`. Dragging is reflected as `data-dragging` on the root (drives the grab
21
+ * cursor + a text-selection lock); `disabled` flows to `data-availability`.
22
+ */
23
+ export function ResizablePanel({ side = 'right', defaultSize = 280, min = 160, max = 480, size, onSizeChange, step = 16, disabled = false, handleLabel = 'Resize panel', children, className, ...rest }) {
24
+ const isControlled = size !== undefined;
25
+ const [internalSize, setInternalSize] = useState(defaultSize);
26
+ const current = clamp(size ?? internalSize, min, max);
27
+ // `right` handle: rightward pointer travel widens; `left` handle: leftward travel widens.
28
+ const dirSign = side === 'right' ? 1 : -1;
29
+ const [dragging, setDragging] = useState(false);
30
+ const dragStart = useRef(null);
31
+ const moveRef = useRef(null);
32
+ const upRef = useRef(null);
33
+ const panelId = useId();
34
+ // Commit a proposed width: clamp, update internal state when uncontrolled, always notify.
35
+ const commit = (next) => {
36
+ const clamped = clamp(next, min, max);
37
+ if (!isControlled)
38
+ setInternalSize(clamped);
39
+ onSizeChange?.(clamped);
40
+ };
41
+ // Latest-commit ref so the long-lived pointermove listener never fires a stale closure.
42
+ const commitRef = useRef(commit);
43
+ useEffect(() => {
44
+ commitRef.current = commit;
45
+ });
46
+ /**
47
+ * Tear down any live drag listeners — shared by pointerup, POINTERCANCEL and unmount.
48
+ *
49
+ * `pointercancel` is not optional. The browser fires it instead of `pointerup` whenever it takes the
50
+ * pointer stream away — a touch turning into a scroll, a system gesture, the handle unmounting
51
+ * mid-drag. Listening only for `pointerup` meant that in those cases the teardown never ran: the
52
+ * `pointermove` listener stayed on `window`, `dragging` stayed true, and the panel went on resizing
53
+ * itself under a pointer with no button held until some unrelated pointerup happened to land.
54
+ */
55
+ const endDrag = useCallback(() => {
56
+ if (moveRef.current)
57
+ window.removeEventListener('pointermove', moveRef.current);
58
+ if (upRef.current) {
59
+ window.removeEventListener('pointerup', upRef.current);
60
+ window.removeEventListener('pointercancel', upRef.current);
61
+ }
62
+ moveRef.current = null;
63
+ upRef.current = null;
64
+ dragStart.current = null;
65
+ setDragging(false);
66
+ }, []);
67
+ useEffect(() => endDrag, [endDrag]);
68
+ const onPointerDown = (e) => {
69
+ if (disabled || e.button !== 0)
70
+ return;
71
+ e.preventDefault();
72
+ dragStart.current = { x: e.clientX, size: current };
73
+ setDragging(true);
74
+ const onMove = (ev) => {
75
+ const start = dragStart.current;
76
+ if (!start)
77
+ return;
78
+ const delta = (ev.clientX - start.x) * dirSign;
79
+ commitRef.current(start.size + delta);
80
+ };
81
+ const onUp = () => endDrag();
82
+ moveRef.current = onMove;
83
+ upRef.current = onUp;
84
+ window.addEventListener('pointermove', onMove);
85
+ window.addEventListener('pointerup', onUp);
86
+ window.addEventListener('pointercancel', onUp);
87
+ };
88
+ const onKeyDown = (e) => {
89
+ if (disabled)
90
+ return;
91
+ let next = null;
92
+ if (e.key === 'ArrowRight')
93
+ next = current + dirSign * step;
94
+ else if (e.key === 'ArrowLeft')
95
+ next = current - dirSign * step;
96
+ else if (e.key === 'Home')
97
+ next = min;
98
+ else if (e.key === 'End')
99
+ next = max;
100
+ if (next === null)
101
+ return;
102
+ e.preventDefault();
103
+ commit(next);
104
+ };
105
+ const availability = disabled ? 'disabled' : 'enabled';
106
+ const handle = (_jsx("div", { className: "xeno-resizable-handle", role: "separator", "aria-orientation": "vertical", "aria-label": handleLabel, "aria-controls": panelId, "aria-valuenow": Math.round(current), "aria-valuemin": min, "aria-valuemax": max, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : 0, "data-availability": availability, onPointerDown: onPointerDown, onKeyDown: onKeyDown }));
107
+ return (_jsxs("div", { className: cx('xeno-resizable', className), "data-side": side, "data-dragging": dragging ? 'true' : 'false', "data-availability": availability, ...rest, children: [side === 'left' && handle, _jsx("div", { id: panelId, className: "xeno-resizable-panel", style: { width: `${current}px` }, children: children }), side === 'right' && handle] }));
108
+ }
@@ -0,0 +1,60 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
2
+ import type { ElementDeclaration } from '@xenosystem/elements/schema';
3
+ /** One navigable row: a leading glyph (optional) + a label, keyed by a stable id. */
4
+ export interface SidebarItem {
5
+ readonly id: string;
6
+ readonly label: string;
7
+ /** A glyph before the label — imported per-id from `@xenosystem/elements`. */
8
+ readonly icon?: ElementDeclaration;
9
+ }
10
+ /** A titled group of rows — e.g. `Pinned` / `Recents`. The heading is mono muted. */
11
+ export interface SidebarSection {
12
+ /** Section heading (mono muted). Omit for an untitled group. */
13
+ readonly heading?: string;
14
+ readonly items: readonly SidebarItem[];
15
+ }
16
+ export interface SidebarProps extends Omit<HTMLAttributes<HTMLElement>, 'onSelect' | 'children'> {
17
+ /** Controlled open/closed. Closed slides the panel to `translateX(-100%)`. */
18
+ readonly open: boolean;
19
+ /** Requests a state change — from Esc, or a backdrop tap on small screens. */
20
+ readonly onOpenChange?: (open: boolean) => void;
21
+ /** The primary (untitled) nav section. */
22
+ readonly items: readonly SidebarItem[];
23
+ /** Optional titled groups rendered after `items` — Pinned / Recents / … */
24
+ readonly sections?: readonly SidebarSection[];
25
+ /** The id of the active row (→ `var(--xeno-control)`, `aria-current="page"`). */
26
+ readonly activeId?: string;
27
+ /** A row was activated (click / Enter / Space). */
28
+ readonly onSelect?: (id: string) => void;
29
+ /** Brand shown top-left of the header. Defaults to the wordmark `XENO`. */
30
+ readonly brand?: ReactNode;
31
+ /** Show the header search field. Default `true`. */
32
+ readonly searchable?: boolean;
33
+ /** Controlled search text. Leave unset for an uncontrolled field. */
34
+ readonly searchValue?: string;
35
+ readonly onSearchChange?: (value: string) => void;
36
+ readonly searchPlaceholder?: string;
37
+ /** Override the search field glyph. Defaults to `xeno.search`. */
38
+ readonly searchIcon?: ElementDeclaration;
39
+ /** Pinned to the bottom — account row, version, sign-out, … */
40
+ readonly footer?: ReactNode;
41
+ /** Render a scrim behind the panel on small screens. Default `true`. */
42
+ readonly backdrop?: boolean;
43
+ /** Accessible name for the inner nav landmark. Default `Primary`. */
44
+ readonly navLabel?: string;
45
+ }
46
+ /**
47
+ * `<Sidebar>` — the slide-in left navigation panel.
48
+ *
49
+ * Locked geometry: SQUARE corners, flush to the viewport edge (`position: fixed; left: 0`), a fixed
50
+ * ~260px column on `var(--xeno-surface)`. It is CONTROLLED (`open` / `onOpenChange`) and opens by
51
+ * transforming `translateX(-100% → 0)` — the only animated property, guarded by reduced-motion.
52
+ *
53
+ * Structure: a header (brand + a search `TextInput` carrying the search glyph), a scrollable nav of
54
+ * self-contained rows (leading glyph + label; the active row fills with `var(--xeno-control)`), any
55
+ * number of titled `sections` (Pinned / Recents), and an optional pinned `footer`.
56
+ *
57
+ * Behaviour is hand-rolled: Esc closes; ↑/↓/Home/End rove focus across rows; a backdrop tap closes on
58
+ * small screens. When closed the panel is `inert` + `aria-hidden`, so it leaves the tab order.
59
+ */
60
+ export declare function Sidebar({ open, onOpenChange, items, sections, activeId, onSelect, brand, searchable, searchValue, onSearchChange, searchPlaceholder, searchIcon, footer, backdrop, navLabel, className, onKeyDown, ...rest }: SidebarProps): ReactElement;
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { version as reactVersion } from 'react';
3
+ import { XenoElement } from '../XenoElement.js';
4
+ import { TextInput } from '../controls/TextInput.js';
5
+ import { cx } from '../controls/util.js';
6
+ import Search from '@xenosystem/elements/elements/search';
7
+ /** Glyph px for a nav row — sits a hair under the 20px switch knob so labels lead. */
8
+ const ITEM_ICON = 18;
9
+ /**
10
+ * `inert`, spelled so it survives BOTH React versions in the peer range.
11
+ *
12
+ * The two versions genuinely disagree, and each one DROPS the other's spelling:
13
+ *
14
+ * React 18 does not know `inert`, so `true` is refused as a non-boolean attribute value and the
15
+ * attribute never reaches the DOM. `''` renders as the custom attribute `inert=""`.
16
+ * React 19 knows it as a boolean: `true` renders `inert=""`, while `''` is read as FALSE (it warns
17
+ * "Received an empty string for a boolean attribute") and the attribute is dropped.
18
+ *
19
+ * There is no single value that works on both, so the value is chosen from the version actually
20
+ * loaded. Getting it wrong is silent and costly: a CLOSED panel keeps every nav button and the search
21
+ * field in the tab order, which is precisely what this is here to prevent.
22
+ *
23
+ * The cast exists only to satisfy React 19's typings, which narrow the prop to `boolean`.
24
+ */
25
+ const INERT_VALUE = Number.parseInt(reactVersion, 10) >= 19 ? true : '';
26
+ const inertWhen = (on) => on ? { inert: INERT_VALUE } : {};
27
+ /**
28
+ * `<Sidebar>` — the slide-in left navigation panel.
29
+ *
30
+ * Locked geometry: SQUARE corners, flush to the viewport edge (`position: fixed; left: 0`), a fixed
31
+ * ~260px column on `var(--xeno-surface)`. It is CONTROLLED (`open` / `onOpenChange`) and opens by
32
+ * transforming `translateX(-100% → 0)` — the only animated property, guarded by reduced-motion.
33
+ *
34
+ * Structure: a header (brand + a search `TextInput` carrying the search glyph), a scrollable nav of
35
+ * self-contained rows (leading glyph + label; the active row fills with `var(--xeno-control)`), any
36
+ * number of titled `sections` (Pinned / Recents), and an optional pinned `footer`.
37
+ *
38
+ * Behaviour is hand-rolled: Esc closes; ↑/↓/Home/End rove focus across rows; a backdrop tap closes on
39
+ * small screens. When closed the panel is `inert` + `aria-hidden`, so it leaves the tab order.
40
+ */
41
+ export function Sidebar({ open, onOpenChange, items, sections, activeId, onSelect, brand = 'XENO', searchable = true, searchValue, onSearchChange, searchPlaceholder = 'Search', searchIcon, footer, backdrop = true, navLabel = 'Primary', className, onKeyDown, ...rest }) {
42
+ const searchGlyph = searchIcon ?? Search;
43
+ const onRootKeyDown = (e) => {
44
+ onKeyDown?.(e);
45
+ if (!e.defaultPrevented && e.key === 'Escape') {
46
+ onOpenChange?.(false);
47
+ }
48
+ };
49
+ // Roving focus across the rows — arrows never leave the nav, Home/End jump to the ends.
50
+ const onNavKeyDown = (e) => {
51
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End')
52
+ return;
53
+ const rows = Array.from(e.currentTarget.querySelectorAll('[data-sidebar-item]:not([disabled])'));
54
+ if (rows.length === 0)
55
+ return;
56
+ e.preventDefault();
57
+ const active = typeof document !== 'undefined' ? document.activeElement : null;
58
+ const idx = active instanceof HTMLButtonElement ? rows.indexOf(active) : -1;
59
+ let next = idx;
60
+ if (e.key === 'ArrowDown')
61
+ next = idx < 0 ? 0 : (idx + 1) % rows.length;
62
+ else if (e.key === 'ArrowUp')
63
+ next = idx <= 0 ? rows.length - 1 : idx - 1;
64
+ else if (e.key === 'Home')
65
+ next = 0;
66
+ else if (e.key === 'End')
67
+ next = rows.length - 1;
68
+ rows[next]?.focus();
69
+ };
70
+ const renderItem = (item) => {
71
+ const isActive = item.id === activeId;
72
+ return (_jsxs("button", { type: "button", className: "xeno-sidebar-item", "data-sidebar-item": "", "data-selection": isActive ? 'on' : 'off', "data-availability": "enabled", "aria-current": isActive ? 'page' : undefined, onClick: () => onSelect?.(item.id), children: [item.icon && (_jsx("span", { className: "xeno-sidebar-item-icon", "aria-hidden": "true", children: _jsx(XenoElement, { decl: item.icon, size: ITEM_ICON, ...(item.icon.contract.axes.includes('selection')
73
+ ? { state: { selection: isActive ? 'on' : 'off' } }
74
+ : {}) }) })), _jsx("span", { className: "xeno-sidebar-item-label", children: item.label })] }, item.id));
75
+ };
76
+ /**
77
+ * A group of rows plus the travelling pill that backs them. The pill is a real element and it is
78
+ * rendered LAST so the rows keep their natural `:nth-child` numbering — the stylesheet drives it
79
+ * purely with `.xeno-sidebar-item:nth-child(n):hover ~ .xeno-sidebar-goo`, no JS and no measuring.
80
+ */
81
+ const renderRows = (list) => (_jsxs("div", { className: "xeno-sidebar-items", children: [list.map(renderItem), _jsx("span", { className: "xeno-sidebar-goo", "aria-hidden": "true" })] }));
82
+ const state = open ? 'open' : 'closed';
83
+ return (_jsxs(_Fragment, { children: [backdrop && (_jsx("div", { className: "xeno-sidebar-backdrop", "data-state": state, "aria-hidden": "true", onClick: () => onOpenChange?.(false) })), _jsxs("aside", { className: cx('xeno-sidebar', className), "data-state": state, "aria-hidden": !open || undefined, ...inertWhen(!open), onKeyDown: onRootKeyDown, ...rest, children: [_jsxs("header", { className: "xeno-sidebar-header", children: [_jsx("div", { className: "xeno-sidebar-brand", children: brand }), searchable && (_jsx(TextInput, { className: "xeno-sidebar-search", leadingIcon: searchGlyph, placeholder: searchPlaceholder, "aria-label": searchPlaceholder, onChange: (e) => onSearchChange?.(e.currentTarget.value), ...(searchValue !== undefined ? { value: searchValue } : {}) }))] }), _jsxs("nav", { className: "xeno-sidebar-nav", "aria-label": navLabel, onKeyDown: onNavKeyDown, children: [_jsx("div", { className: "xeno-sidebar-section", children: renderRows(items) }), sections?.map((section, i) => (_jsxs("div", { className: "xeno-sidebar-section", children: [section.heading && (_jsx("div", { className: "xeno-sidebar-heading", "aria-hidden": "true", children: section.heading })), renderRows(section.items)] }, `section-${i}`)))] }), footer && _jsx("footer", { className: "xeno-sidebar-footer", children: footer })] })] }));
84
+ }
@@ -0,0 +1,71 @@
1
+ import type { HTMLAttributes, ReactElement } from 'react';
2
+ /**
3
+ * DateTimePicker — a faithful port of the XENO chat's project "Scheduled" date/time popover
4
+ * (ChatWithLLM.tsx): monochrome, `rounded-md` (6px) throughout, a soft `--xeno-control` fill for the
5
+ * selection (never bright inverted ink), a fixed 6-week calendar (greyed overflow days keep the height
6
+ * stable), and a three-column time picker whose Hour/Min are quiet scroll lists and AM/PM two flat
7
+ * buttons. Both pieces render PURE from props so SSR is byte-deterministic (no `new Date()`).
8
+ *
9
+ * Exports {@link DatePicker} + {@link TimePicker}.
10
+ */
11
+ /** A calendar date. `month` is 0-based (0 = January) to match `Date`'s month index. */
12
+ export interface DateParts {
13
+ readonly year: number;
14
+ readonly month: number;
15
+ readonly day: number;
16
+ }
17
+ /** A visible month. `month` is 0-based (0 = January). */
18
+ export interface MonthParts {
19
+ readonly year: number;
20
+ readonly month: number;
21
+ }
22
+ export interface DatePickerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'children'> {
23
+ /** The month currently on screen (0-based month). */
24
+ readonly month: MonthParts;
25
+ /** The selected date — its cell gets the soft `--xeno-control` fill. */
26
+ readonly selected?: DateParts;
27
+ /** "Today" — gets `aria-current` and drives the Today button. */
28
+ readonly today?: DateParts;
29
+ /** The visible month should change (prev/next chevrons, PageUp/PageDown, Today). */
30
+ readonly onMonthChange?: (month: MonthParts) => void;
31
+ /** A day cell was activated (may be a day from the adjacent month). */
32
+ readonly onSelect?: (date: DateParts) => void;
33
+ /** The Clear button was pressed. When omitted the Clear button is hidden. */
34
+ readonly onClear?: () => void;
35
+ /** First column of the week: 0 = Sunday, 1 = Monday (default). */
36
+ readonly weekStartsOn?: 0 | 1;
37
+ /** Override the 12 month names (index 0 = January). */
38
+ readonly monthNames?: readonly string[];
39
+ /** Override the 7 short weekday names (index 0 = Sunday). */
40
+ readonly weekdayNames?: readonly string[];
41
+ }
42
+ /**
43
+ * `<DatePicker>` — a single-month calendar. A header (prev/next chevron {@link IconButton}s around a
44
+ * `Month Year` label), a weekday header (Mo…Su by default), a FIXED 6-week grid of square day cells
45
+ * (adjacent-month days shown greyed so the height never jumps), and a footer of Clear / Today ghost
46
+ * buttons. Controlled. Keyboard: arrows rove day-to-day (↑/↓ = week, ←/→ = day), Home/End jump to the
47
+ * ends of the grid, PageUp/PageDown change month, Enter/Space select (native).
48
+ */
49
+ export declare function DatePicker({ month, selected, today, onMonthChange, onSelect, onClear, weekStartsOn, monthNames, weekdayNames, className, ...rest }: DatePickerProps): ReactElement;
50
+ export type Meridiem = 'AM' | 'PM';
51
+ /** A 12-hour clock time. `hour` is 1–12, `minute` is 0–59. */
52
+ export interface TimeValue {
53
+ readonly hour: number;
54
+ readonly minute: number;
55
+ readonly meridiem: Meridiem;
56
+ }
57
+ export interface TimePickerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'children'> {
58
+ /** The selected time. */
59
+ readonly value: TimeValue;
60
+ readonly onChange?: (value: TimeValue) => void;
61
+ /** Minute granularity for the middle column. Default 5. */
62
+ readonly minuteStep?: number;
63
+ }
64
+ /**
65
+ * `<TimePicker>` — three columns (Hour 1–12, Minute 00–59 by `minuteStep`, AM/PM). Hour + Min are quiet
66
+ * scroll lists inside their own hairline box; AM/PM is two flat buttons, not a scroller. The selected
67
+ * option in each column sits on `--xeno-control`. Controlled via `value` / `onChange`; each list is a
68
+ * `listbox` whose options rove with ↑/↓, Home/End jump, Enter/Space select. On change the selected
69
+ * scroll option is centred within its own column (never scrolling the page).
70
+ */
71
+ export declare function TimePicker({ value, onChange, minuteStep, className, ...rest }: TimePickerProps): ReactElement;
@@ -0,0 +1,191 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef } from 'react';
3
+ import { IconButton } from '../controls/IconButton.js';
4
+ import { Button } from '../controls/Button.js';
5
+ import { cx } from '../controls/util.js';
6
+ import ChevronRight from '@xenosystem/elements/elements/chevron-right';
7
+ // ── Pure calendar math (no `Date`, so identical on server + client, any timezone) ─────────────────
8
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
9
+ const SAKAMOTO = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
10
+ const isLeapYear = (y) => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
11
+ const daysInMonth = (year, month0) => month0 === 1 && isLeapYear(year) ? 29 : (DAYS_IN_MONTH[month0] ?? 30);
12
+ /** Day of week for a date, 0 = Sunday … 6 = Saturday. Sakamoto's algorithm — `month0` is 0-based. */
13
+ const dayOfWeek = (year, month0, day) => {
14
+ const y = month0 < 2 ? year - 1 : year;
15
+ const t = SAKAMOTO[month0] ?? 0;
16
+ return (((y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) + t + day) % 7) + 7) % 7;
17
+ };
18
+ /** Shift a month by ±n, carrying the year. */
19
+ const shiftMonth = (m, delta) => {
20
+ const total = m.year * 12 + m.month + delta;
21
+ return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 };
22
+ };
23
+ const DEFAULT_MONTH_NAMES = [
24
+ 'January',
25
+ 'February',
26
+ 'March',
27
+ 'April',
28
+ 'May',
29
+ 'June',
30
+ 'July',
31
+ 'August',
32
+ 'September',
33
+ 'October',
34
+ 'November',
35
+ 'December',
36
+ ];
37
+ /** Short weekday names indexed by JS day: 0 = Sunday … 6 = Saturday. */
38
+ const DEFAULT_WEEKDAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
39
+ const sameDay = (a, c) => a !== undefined && a.year === c.year && a.month === c.month && a.day === c.day;
40
+ /**
41
+ * `<DatePicker>` — a single-month calendar. A header (prev/next chevron {@link IconButton}s around a
42
+ * `Month Year` label), a weekday header (Mo…Su by default), a FIXED 6-week grid of square day cells
43
+ * (adjacent-month days shown greyed so the height never jumps), and a footer of Clear / Today ghost
44
+ * buttons. Controlled. Keyboard: arrows rove day-to-day (↑/↓ = week, ←/→ = day), Home/End jump to the
45
+ * ends of the grid, PageUp/PageDown change month, Enter/Space select (native).
46
+ */
47
+ export function DatePicker({ month, selected, today, onMonthChange, onSelect, onClear, weekStartsOn = 1, monthNames = DEFAULT_MONTH_NAMES, weekdayNames = DEFAULT_WEEKDAY_NAMES, className, ...rest }) {
48
+ const gridRef = useRef(null);
49
+ const labelId = useId();
50
+ const { year, month: m0 } = month;
51
+ const monthName = monthNames[m0] ?? '';
52
+ const total = daysInMonth(year, m0);
53
+ const firstDow = dayOfWeek(year, m0, 1);
54
+ const lead = (firstDow - weekStartsOn + 7) % 7;
55
+ // A FIXED 6-week (42-cell) grid, filled with real days from the previous and next months so the
56
+ // calendar height is constant and every cell is a real, clickable date.
57
+ const prev = shiftMonth(month, -1);
58
+ const next = shiftMonth(month, 1);
59
+ const prevTotal = daysInMonth(prev.year, prev.month);
60
+ const cells = [];
61
+ for (let i = 0; i < lead; i += 1) {
62
+ cells.push({ year: prev.year, month: prev.month, day: prevTotal - lead + 1 + i, inMonth: false });
63
+ }
64
+ for (let d = 1; d <= total; d += 1)
65
+ cells.push({ year, month: m0, day: d, inMonth: true });
66
+ for (let d = 1; cells.length < 42; d += 1) {
67
+ cells.push({ year: next.year, month: next.month, day: d, inMonth: false });
68
+ }
69
+ const weeks = [];
70
+ for (let i = 0; i < cells.length; i += 7)
71
+ weeks.push(cells.slice(i, i + 7));
72
+ // The single roving-tabbable cell: the selection (if this month), else today (if this month), else
73
+ // the 1st — always an in-month day.
74
+ const focusIdx = (() => {
75
+ const bySel = cells.findIndex((c) => c.inMonth && sameDay(selected, c));
76
+ if (bySel >= 0)
77
+ return bySel;
78
+ const byToday = cells.findIndex((c) => c.inMonth && sameDay(today, c));
79
+ if (byToday >= 0)
80
+ return byToday;
81
+ return cells.findIndex((c) => c.inMonth);
82
+ })();
83
+ const weekdayHeader = Array.from({ length: 7 }, (_, i) => {
84
+ const idx = (weekStartsOn + i) % 7;
85
+ return weekdayNames[idx] ?? '';
86
+ });
87
+ const goToMonth = (delta) => onMonthChange?.(shiftMonth(month, delta));
88
+ const goToday = () => {
89
+ if (!today)
90
+ return;
91
+ onMonthChange?.({ year: today.year, month: today.month });
92
+ onSelect?.(today);
93
+ };
94
+ // Arrow-key roving across the 42 day buttons by their grid index.
95
+ const onGridKeyDown = (e) => {
96
+ const nav = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'];
97
+ if (!nav.includes(e.key))
98
+ return;
99
+ e.preventDefault();
100
+ if (e.key === 'PageUp')
101
+ return goToMonth(-1);
102
+ if (e.key === 'PageDown')
103
+ return goToMonth(1);
104
+ const grid = gridRef.current;
105
+ if (!grid)
106
+ return;
107
+ const buttons = Array.from(grid.querySelectorAll('[data-idx]'));
108
+ if (buttons.length === 0)
109
+ return;
110
+ const activeEl = typeof document !== 'undefined' ? document.activeElement : null;
111
+ const cur = activeEl instanceof HTMLButtonElement ? buttons.indexOf(activeEl) : -1;
112
+ const base = cur < 0 ? Math.max(0, focusIdx) : cur;
113
+ let nextIdx = base;
114
+ if (e.key === 'ArrowLeft')
115
+ nextIdx = base - 1;
116
+ else if (e.key === 'ArrowRight')
117
+ nextIdx = base + 1;
118
+ else if (e.key === 'ArrowUp')
119
+ nextIdx = base - 7;
120
+ else if (e.key === 'ArrowDown')
121
+ nextIdx = base + 7;
122
+ else if (e.key === 'Home')
123
+ nextIdx = 0;
124
+ else if (e.key === 'End')
125
+ nextIdx = buttons.length - 1;
126
+ nextIdx = Math.max(0, Math.min(buttons.length - 1, nextIdx));
127
+ buttons[nextIdx]?.focus();
128
+ };
129
+ let idx = -1;
130
+ return (_jsxs("div", { className: cx('xeno-datepicker', className), role: "group", "aria-labelledby": labelId, ...rest, children: [_jsxs("div", { className: "xeno-datepicker-header", children: [_jsx(IconButton, { className: "xeno-datepicker-nav xeno-datepicker-prev", icon: ChevronRight, "aria-label": "Previous month", size: "xs", onClick: () => goToMonth(-1) }), _jsxs("div", { className: "xeno-datepicker-title", id: labelId, "aria-live": "polite", children: [monthName, " ", year] }), _jsx(IconButton, { className: "xeno-datepicker-nav xeno-datepicker-next", icon: ChevronRight, "aria-label": "Next month", size: "xs", onClick: () => goToMonth(1) })] }), _jsx("div", { className: "xeno-datepicker-weekdays", "aria-hidden": "true", children: weekdayHeader.map((wd, i) => (_jsx("div", { className: "xeno-datepicker-weekday", children: wd }, `${wd}-${i}`))) }), _jsx("div", { className: "xeno-datepicker-grid", role: "grid", ref: gridRef, onKeyDown: onGridKeyDown, children: weeks.map((week, wi) => (_jsx("div", { className: "xeno-datepicker-week", role: "row", children: week.map((cell, di) => {
131
+ idx += 1;
132
+ const isSel = sameDay(selected, cell);
133
+ const isToday = sameDay(today, cell);
134
+ const cellIdx = idx;
135
+ return (_jsx("button", { type: "button", className: "xeno-datepicker-cell xeno-datepicker-day", role: "gridcell", "data-idx": String(cellIdx), "data-outside": cell.inMonth ? undefined : '', "data-selection": isSel ? 'on' : 'off', "data-availability": "enabled", "aria-selected": isSel, "aria-current": isToday ? 'date' : undefined, tabIndex: cellIdx === focusIdx ? 0 : -1, onClick: () => onSelect?.({ year: cell.year, month: cell.month, day: cell.day }), children: cell.day }, di));
136
+ }) }, wi))) }), _jsxs("div", { className: "xeno-datepicker-footer", children: [onClear !== undefined && (_jsx(Button, { className: "xeno-datepicker-clear", variant: "ghost", size: "xs", onClick: () => onClear(), children: "Clear" })), _jsx(Button, { className: "xeno-datepicker-today", variant: "ghost", size: "xs", disabled: today === undefined, onClick: goToday, children: "Today" })] })] }));
137
+ }
138
+ const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
139
+ const MERIDIEMS = ['AM', 'PM'];
140
+ const pad2 = (n) => (n < 10 ? `0${n}` : `${n}`);
141
+ /**
142
+ * `<TimePicker>` — three columns (Hour 1–12, Minute 00–59 by `minuteStep`, AM/PM). Hour + Min are quiet
143
+ * scroll lists inside their own hairline box; AM/PM is two flat buttons, not a scroller. The selected
144
+ * option in each column sits on `--xeno-control`. Controlled via `value` / `onChange`; each list is a
145
+ * `listbox` whose options rove with ↑/↓, Home/End jump, Enter/Space select. On change the selected
146
+ * scroll option is centred within its own column (never scrolling the page).
147
+ */
148
+ export function TimePicker({ value, onChange, minuteStep = 5, className, ...rest }) {
149
+ const rootRef = useRef(null);
150
+ const step = minuteStep > 0 ? minuteStep : 5;
151
+ const minutes = [];
152
+ for (let mnt = 0; mnt < 60; mnt += step)
153
+ minutes.push(mnt);
154
+ // Centre the selected option within each SCROLL column's own box (no page scroll).
155
+ useEffect(() => {
156
+ const root = rootRef.current;
157
+ if (!root)
158
+ return;
159
+ root.querySelectorAll('[data-timecol]').forEach((col) => {
160
+ const sel = col.querySelector('[data-selection="on"]');
161
+ if (sel)
162
+ col.scrollTop = sel.offsetTop - col.clientHeight / 2 + sel.offsetHeight / 2;
163
+ });
164
+ }, [value.hour, value.minute, value.meridiem, step]);
165
+ // Roving focus within one column's options.
166
+ const onColumnKeyDown = (e) => {
167
+ if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key))
168
+ return;
169
+ e.preventDefault();
170
+ const options = Array.from(e.currentTarget.querySelectorAll('[data-option]'));
171
+ if (options.length === 0)
172
+ return;
173
+ const activeEl = typeof document !== 'undefined' ? document.activeElement : null;
174
+ const cur = activeEl instanceof HTMLButtonElement ? options.indexOf(activeEl) : -1;
175
+ let next = cur < 0 ? 0 : cur;
176
+ if (e.key === 'ArrowUp')
177
+ next = cur <= 0 ? options.length - 1 : cur - 1;
178
+ else if (e.key === 'ArrowDown')
179
+ next = cur < 0 ? 0 : (cur + 1) % options.length;
180
+ else if (e.key === 'Home')
181
+ next = 0;
182
+ else if (e.key === 'End')
183
+ next = options.length - 1;
184
+ options[next]?.focus();
185
+ };
186
+ const column = (label, items, isSelected, format, onPick, scroll) => (_jsxs("div", { className: "xeno-timepicker-col", children: [_jsx("div", { className: "xeno-timepicker-col-head", "aria-hidden": "true", children: label }), _jsx("div", { className: "xeno-timepicker-list", role: "listbox", "aria-label": label, "data-scroll": scroll ? '' : undefined, "data-timecol": scroll ? '' : undefined, onKeyDown: onColumnKeyDown, children: items.map((item) => {
187
+ const on = isSelected(item);
188
+ return (_jsx("button", { type: "button", className: "xeno-timepicker-option", role: "option", "data-option": "", "data-selection": on ? 'on' : 'off', "data-availability": "enabled", "aria-selected": on, tabIndex: on ? 0 : -1, onClick: () => onPick(item), children: format(item) }, String(item)));
189
+ }) })] }));
190
+ return (_jsxs("div", { className: cx('xeno-timepicker', className), role: "group", "aria-label": "Time", ref: rootRef, ...rest, children: [column('Hour', HOURS, (h) => h === value.hour, (h) => String(h), (h) => onChange?.({ ...value, hour: h }), true), column('Min', minutes, (mnt) => mnt === value.minute, (mnt) => pad2(mnt), (mnt) => onChange?.({ ...value, minute: mnt }), true), column('AM/PM', MERIDIEMS, (mer) => mer === value.meridiem, (mer) => String(mer), (mer) => onChange?.({ ...value, meridiem: mer }), false)] }));
191
+ }