@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
@@ -0,0 +1,28 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+ /**
3
+ * `<Menu>` — a {@link Popover} whose panel is a `role="menu"` list of {@link MenuItem} rows. It layers
4
+ * the ARIA menu keyboard model onto Popover's anchoring + dismissal: on open, focus moves to the first
5
+ * enabled item; Arrow Up/Down move focus between items (wrapping), Home/End jump to the ends, Tab and
6
+ * Escape close it, and activating an item (click / Enter / Space) closes the menu. Disabled items are
7
+ * skipped by the roving focus and never dismiss the menu.
8
+ *
9
+ * Controlled: `open` + `onOpenChange`. Wire the `trigger`'s `onClick` to toggle `open` (and set its
10
+ * `aria-haspopup="menu"` + `aria-expanded`); Popover restores focus to it when the menu closes.
11
+ */
12
+ export interface MenuProps {
13
+ /** Whether the menu panel is open. */
14
+ readonly open: boolean;
15
+ /** Called with the next open state (on select, Tab, Escape, and outside click). */
16
+ readonly onOpenChange?: (open: boolean) => void;
17
+ /** The anchor button — wire its `onClick` to toggle `open`. */
18
+ readonly trigger: ReactNode;
19
+ /** The {@link MenuItem} rows. */
20
+ readonly children?: ReactNode;
21
+ /** Which trigger edge the panel aligns to. Default `start`. */
22
+ readonly align?: 'start' | 'end';
23
+ /** Accessible name for the menu list. */
24
+ readonly 'aria-label'?: string;
25
+ /** Extra class on the Popover wrapper. */
26
+ readonly className?: string;
27
+ }
28
+ export declare function Menu({ open, onOpenChange, trigger, children, align, 'aria-label': ariaLabel, className, }: MenuProps): ReactElement;
@@ -0,0 +1,32 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from 'react';
3
+ import { useGooPill } from '../useGooPill.js';
4
+ import { useMenu } from '../useMenu.js';
5
+ import { Popover } from './Popover.js';
6
+ export function Menu({ open, onOpenChange, trigger, children, align = 'start', 'aria-label': ariaLabel, className, }) {
7
+ const panelRef = useRef(null);
8
+ // The travelling highlight is a primitive (`useGooPill` + `goo.css`), not a menu feature: a menu is
9
+ // simply the first list that needed it, and the sidebar, a command palette or a product's own
10
+ // hand-rolled dropdown all want the same behaviour off the same two measurements.
11
+ const { hostProps: gooProps, pill } = useGooPill({ hostRef: panelRef });
12
+ /* Every key and every focus move now comes from `useMenu`. This component was where that behaviour
13
+ was written, and it stayed here long enough for a product to need it and not be able to reach it —
14
+ the same story `Modal` had before `useDialog`. The hook is handed the panel ref this component
15
+ already owns, so the keyboard, the highlight and Popover's placement all address one element. */
16
+ const { menuProps: keyboard } = useMenu({
17
+ open,
18
+ onClose: () => onOpenChange?.(false),
19
+ menuRef: panelRef,
20
+ });
21
+ // `ref` is Popover's to place (`panelRef`), so the hook's copy of it is dropped here rather than
22
+ // spread — the hook was handed that same ref, so both halves are looking at one element.
23
+ const { ref: _gooRef, className: gooClass, ...gooHandlers } = gooProps;
24
+ const { ref: _menuRef, ...keyboardProps } = keyboard;
25
+ const panelProps = {
26
+ className: `xeno-menu ${gooClass}`,
27
+ ...keyboardProps,
28
+ ...(ariaLabel !== undefined ? { 'aria-label': ariaLabel } : {}),
29
+ ...gooHandlers,
30
+ };
31
+ return (_jsxs(Popover, { open: open, ...(onOpenChange ? { onOpenChange } : {}), trigger: trigger, align: align, panelRef: panelRef, panelProps: panelProps, ...(className !== undefined ? { className } : {}), children: [pill, children] }));
32
+ }
@@ -0,0 +1,71 @@
1
+ import type { ButtonHTMLAttributes, ReactElement } from 'react';
2
+ import type { ElementDeclaration, ElementState } from '@xenosystem/elements/schema';
3
+ /**
4
+ * `<MenuItem>` — a single `role="menuitem"` row inside a {@link Menu}. Grammar-consistent with the
5
+ * controls: it honours the `availability` axis (`data-availability`, `disabled`) and — when it is a
6
+ * selectable (checkable) item — the `selection` axis (`data-selection` + `aria-checked`).
7
+ *
8
+ * A row reads left→right as: an optional leading glyph (or the selection check), the label
9
+ * (`children`), an optional right-aligned `value`, an optional right-aligned mono `shortcut`, and a
10
+ * trailing chevron when the row leads somewhere (`submenu` beside, `expanded` below). `variant`
11
+ * `danger` recolours the row to `--xeno-danger`. When `selected` is passed the item becomes a
12
+ * `menuitemcheckbox` and shows a check glyph; the leading slot is reserved so labels stay aligned with
13
+ * checked siblings.
14
+ */
15
+ export type MenuItemVariant = 'default' | 'danger';
16
+ export interface MenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onSelect'> {
17
+ /** Fired on click / Enter / Space (unless the click was defaultPrevented). */
18
+ readonly onSelect?: () => void;
19
+ /** A glyph before the label — imported per-id from `@xenosystem/elements`. */
20
+ readonly leadingIcon?: ElementDeclaration;
21
+ /** A right-aligned keyboard hint, rendered in muted mono (e.g. `⌘C`). */
22
+ readonly shortcut?: string;
23
+ /**
24
+ * The row's CURRENT VALUE, right-aligned and muted — for a row that opens a submenu of choices and
25
+ * wants to show which one is in force ("Type · All ›").
26
+ *
27
+ * Not `shortcut`, though the slot looks the same. `shortcut` is set in mono because it stands for
28
+ * a key the reader will press; a value is a word the reader picked, and mono makes a word read as
29
+ * a key. Products that only had `shortcut` used it for values anyway and got "All" in the same
30
+ * typeface as "⌘C" — this is the slot that was missing, not a second way to write the first.
31
+ */
32
+ readonly value?: string;
33
+ /** When defined the row is a checkable `menuitemcheckbox`; `true` shows the check glyph. */
34
+ readonly selected?: boolean;
35
+ /** Marks the row as opening a submenu — draws a trailing chevron and sets `aria-haspopup`. */
36
+ readonly submenu?: boolean;
37
+ /**
38
+ * Marks the row as a DISCLOSURE: it opens a region inside the menu rather than a menu beside it.
39
+ * Draws the same trailing chevron, quarter-turned when open, and reports `aria-expanded`.
40
+ *
41
+ * Not `submenu`, and the two must not be confused. `submenu` promises a second menu somewhere
42
+ * else on screen and says `aria-haspopup="menu"`; a disclosure grows the menu it is already in,
43
+ * and a screen reader told to expect a popup that never arrives is worse off than one told
44
+ * nothing. The chevron is shared because the gesture is: "there is more behind this row." Where
45
+ * it points is the difference — sideways for a menu beside, downwards for a panel below.
46
+ *
47
+ * Pass `aria-controls` alongside it; this component does not invent the region's id.
48
+ */
49
+ readonly expanded?: boolean;
50
+ /** `danger` recolours the row to `--xeno-danger`. */
51
+ readonly variant?: MenuItemVariant;
52
+ /**
53
+ * The leading glyph's own STATE, for a glyph that has one.
54
+ *
55
+ * `leadingIcon` is a declaration and this component draws it, which left a caller no way to say
56
+ * "that glyph, but on". Products fake it: a chat marking a pinned conversation put `fill-current`
57
+ * on an icon it rendered itself, because it could not reach the row's glyph from outside.
58
+ *
59
+ * It never needed faking. `bookmark` already declares a `selection` axis whose `on` variant is the
60
+ * same silhouette FILLED — the real thing, and one that MORPHS between the two rather than swapping,
61
+ * because both variants are a single path of equal length. This prop is the door to it.
62
+ *
63
+ * Distinct from `selected`, and the difference is what the row MEANS. `selected` says the row is a
64
+ * checkable option and this is the chosen one, so the component draws a check in the leading slot.
65
+ * `iconState` says nothing about choosing — it is the state of the thing the row acts on. A pinned
66
+ * chat is not a selected menu item, and a menu that conflated the two would draw a tick where the
67
+ * pin should be.
68
+ */
69
+ readonly iconState?: Partial<ElementState>;
70
+ }
71
+ export declare function MenuItem({ onSelect, leadingIcon, shortcut, value, selected, submenu, expanded, variant, iconState, disabled, className, children, onClick, type, ...rest }: MenuItemProps): ReactElement;
@@ -0,0 +1,15 @@
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
+ import ChevronRight from '@xenosystem/elements/elements/chevron-right';
6
+ export function MenuItem({ onSelect, leadingIcon, shortcut, value, selected, submenu = false, expanded, variant = 'default', iconState, disabled = false, className, children, onClick, type = 'button', ...rest }) {
7
+ const selectable = selected !== undefined;
8
+ const showLead = selectable || leadingIcon !== undefined;
9
+ const leadGlyph = selected ? Check : leadingIcon;
10
+ return (_jsxs("button", { type: type, role: selectable ? 'menuitemcheckbox' : 'menuitem', tabIndex: -1, className: cx('xeno-menu-item', className), "data-variant": variant, "data-availability": disabled ? 'disabled' : 'enabled', "data-selection": selectable ? (selected ? 'on' : 'off') : undefined, disabled: disabled, "aria-checked": selectable ? selected : undefined, "aria-haspopup": submenu ? 'menu' : undefined, "aria-expanded": expanded, onClick: (e) => {
11
+ onClick?.(e);
12
+ if (!e.defaultPrevented)
13
+ onSelect?.();
14
+ }, ...rest, children: [showLead && (_jsx("span", { className: "xeno-menu-item-lead", "aria-hidden": "true", children: leadGlyph ? _jsx(XenoElement, { decl: leadGlyph, size: 16, ...(iconState && !selected ? { state: iconState } : {}) }) : null })), _jsx("span", { className: "xeno-menu-item-label", children: children }), value !== undefined && _jsx("span", { className: "xeno-menu-item-value", children: value }), shortcut !== undefined && _jsx("span", { className: "xeno-menu-item-shortcut", children: shortcut }), (submenu || expanded !== undefined) && (_jsx("span", { className: "xeno-menu-item-chevron", "data-state": expanded === undefined ? undefined : expanded ? 'open' : 'closed', "aria-hidden": "true", children: _jsx(XenoElement, { decl: ChevronRight, size: 16 }) }))] }));
15
+ }
@@ -0,0 +1,36 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
2
+ /**
3
+ * `<Modal>` — the locked "floating window" (SPEC: dialog, not heavy chrome). One overlay pattern with
4
+ * two placements via the `variant` axis: a `center` dialog and a `sheet` that slides up from the bottom
5
+ * edge (rounding only its top corners). The card is `--xeno-elevated`, a 1px hairline border, a soft
6
+ * (uncoloured) shadow, and `--xeno-radius-md` throughout — a header (title + a close `IconButton` drawn
7
+ * from the `x` glyph), a scrollable body, and an optional right-aligned footer for actions.
8
+ *
9
+ * Controlled: `open` + `onClose`. It renders nothing when closed. While open it is `aria-modal` — Esc
10
+ * closes it, a click on the scrim (never on the card) closes it, focus moves into the dialog and is
11
+ * trapped on Tab, the page scroll is locked, and focus is restored to the opener on close.
12
+ */
13
+ export type ModalVariant = 'center' | 'sheet';
14
+ export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title' | 'children' | 'onClick'> {
15
+ /** Whether the dialog is mounted + visible. Nothing renders when false. */
16
+ readonly open: boolean;
17
+ /** Called on Esc, on a scrim click, and by the header close button. */
18
+ readonly onClose: () => void;
19
+ /** Header heading. When present it labels the dialog via `aria-labelledby`. */
20
+ readonly title?: ReactNode;
21
+ /** The scrollable body content. */
22
+ readonly children?: ReactNode;
23
+ /** Optional actions, laid out right-aligned in a bordered footer. */
24
+ readonly footer?: ReactNode;
25
+ /** `center` (default) floats mid-screen; `sheet` slides up from the bottom. */
26
+ readonly variant?: ModalVariant;
27
+ /** Accessible label for the close button. */
28
+ readonly closeLabel?: string;
29
+ }
30
+ export declare function Modal({ open, onClose, title, children, footer, variant, closeLabel, className, ...rest }: ModalProps): ReactElement | null;
31
+ /**
32
+ * `<Sheet>` — the bottom-sheet placement of {@link Modal}, i.e. `Modal` pinned to `variant="sheet"`.
33
+ * Same controlled contract, same a11y; it slides up from the bottom and rounds only its top corners.
34
+ */
35
+ export type SheetProps = Omit<ModalProps, 'variant'>;
36
+ export declare function Sheet(props: SheetProps): ReactElement | null;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useRef } from 'react';
3
+ import { useDialog } from '../useDialog.js';
4
+ import { IconButton } from '../controls/IconButton.js';
5
+ import { cx } from '../controls/util.js';
6
+ import X from '@xenosystem/elements/elements/x';
7
+ export function Modal({ open, onClose, title, children, footer, variant = 'center', closeLabel = 'Close', className, ...rest }) {
8
+ const titleId = useId();
9
+ const scrimArmed = useRef(false);
10
+ const { panelProps } = useDialog({ open, onClose });
11
+ if (!open)
12
+ return null;
13
+ // Close only when both the press and the release land on the scrim itself — a drag that begins on the
14
+ // card and releases outside must not dismiss.
15
+ const onScrimMouseDown = (e) => {
16
+ scrimArmed.current = e.target === e.currentTarget;
17
+ };
18
+ const onScrimClick = (e) => {
19
+ if (e.target === e.currentTarget && scrimArmed.current)
20
+ onClose();
21
+ scrimArmed.current = false;
22
+ };
23
+ return (_jsx("div", { className: "xeno-modal-overlay", "data-variant": variant, onMouseDown: onScrimMouseDown, onClick: onScrimClick, children: _jsxs("div", { ...rest, ...panelProps, role: "dialog", "aria-modal": "true", ...(title !== undefined ? { 'aria-labelledby': titleId } : {}), className: cx('xeno-modal', className), "data-variant": variant, children: [_jsxs("div", { className: "xeno-modal-header", children: [title !== undefined ? (_jsx("h2", { id: titleId, className: "xeno-modal-title", children: title })) : null, _jsx(IconButton, { icon: X, "aria-label": closeLabel, className: "xeno-modal-close", onClick: onClose })] }), _jsx("div", { className: "xeno-modal-body", children: children }), footer !== undefined ? _jsx("div", { className: "xeno-modal-footer", children: footer }) : null] }) }));
24
+ }
25
+ export function Sheet(props) {
26
+ return _jsx(Modal, { ...props, variant: "sheet" });
27
+ }
@@ -0,0 +1,32 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
2
+ import type { ElementDeclaration } from '@xenosystem/elements/schema';
3
+ /**
4
+ * `<PickerField>` — a form field that REVEALS a panel (a {@link DatePicker}, {@link TimePicker}, or any
5
+ * content) with the chat's "Scheduled" open/close animation, via {@link Reveal}: the panel unfolds
6
+ * in-flow (pushing content down, never floating over it), full-width, its content fading up a beat
7
+ * later. The trigger shows a `label`, the current `value` (or `placeholder`), and a chevron that turns
8
+ * as it opens. Controlled (`open` + `onOpenChange`) or uncontrolled (`defaultOpen`); Esc and an outside
9
+ * click dismiss it.
10
+ *
11
+ * For several fields that should share ONE reveal (so switching between them never dips the layout),
12
+ * compose your own triggers with a single {@link Reveal} instead — see the preview's date/time pair.
13
+ */
14
+ export interface PickerFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {
15
+ /** The value shown in the field (e.g. a formatted date). Falls back to `placeholder` when empty. */
16
+ readonly value?: ReactNode;
17
+ /** Shown, muted, when `value` is empty. */
18
+ readonly placeholder?: string;
19
+ /** A small caption before the value (e.g. "Date"). */
20
+ readonly label?: string;
21
+ /** A leading glyph, drawn by the shared renderer. */
22
+ readonly icon?: ElementDeclaration;
23
+ /** Controlled open state. Omit to use `defaultOpen`. */
24
+ readonly open?: boolean;
25
+ readonly defaultOpen?: boolean;
26
+ readonly onOpenChange?: (open: boolean) => void;
27
+ /** Which field edge the panel aligns to. Default `start`. */
28
+ readonly align?: 'start' | 'end';
29
+ /** The revealed content — a picker, a menu, anything. */
30
+ readonly children?: ReactNode;
31
+ }
32
+ export declare function PickerField({ value, placeholder, label, icon, open: openProp, defaultOpen, onOpenChange, align, className, children, ...rest }: PickerFieldProps): ReactElement;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from 'react';
3
+ import { XenoElement } from '../XenoElement.js';
4
+ import { cx } from '../controls/util.js';
5
+ import ChevronRight from '@xenosystem/elements/elements/chevron-right';
6
+ import { Reveal } from './Reveal.js';
7
+ export function PickerField({ value, placeholder, label, icon, open: openProp, defaultOpen = false, onOpenChange, align = 'start', className, children, ...rest }) {
8
+ const controlled = openProp !== undefined;
9
+ const [openState, setOpenState] = useState(defaultOpen);
10
+ const open = controlled ? openProp : openState;
11
+ const rootRef = useRef(null);
12
+ const panelId = useId();
13
+ const setOpen = (next) => {
14
+ onOpenChange?.(next);
15
+ if (!controlled)
16
+ setOpenState(next);
17
+ };
18
+ // Dismiss on Escape / outside click, only while open.
19
+ useEffect(() => {
20
+ if (!open)
21
+ return;
22
+ const onKey = (e) => {
23
+ if (e.key === 'Escape')
24
+ setOpen(false);
25
+ };
26
+ const onPointer = (e) => {
27
+ if (rootRef.current && !rootRef.current.contains(e.target))
28
+ setOpen(false);
29
+ };
30
+ document.addEventListener('keydown', onKey);
31
+ document.addEventListener('mousedown', onPointer);
32
+ return () => {
33
+ document.removeEventListener('keydown', onKey);
34
+ document.removeEventListener('mousedown', onPointer);
35
+ };
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [open]);
38
+ const empty = value === undefined || value === null || value === '';
39
+ return (_jsxs("div", { ref: rootRef, className: cx('xeno-field', className), "data-align": align, ...rest, children: [_jsxs("button", { type: "button", className: "xeno-field-trigger", "data-open": open ? 'true' : 'false', "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? panelId : undefined, onClick: () => setOpen(!open), children: [icon && (_jsx("span", { className: "xeno-field-icon", "aria-hidden": "true", children: _jsx(XenoElement, { decl: icon, size: 16 }) })), label && _jsx("span", { className: "xeno-field-label", children: label }), _jsx("span", { className: "xeno-field-value", "data-placeholder": empty ? 'true' : 'false', children: empty ? placeholder : value }), _jsx("span", { className: "xeno-field-chevron", "aria-hidden": "true", children: _jsx(XenoElement, { decl: ChevronRight, size: 14 }) })] }), _jsx(Reveal, { open: open, align: align, panelProps: { id: panelId, role: 'dialog', 'aria-modal': false }, children: children })] }));
40
+ }
@@ -0,0 +1,25 @@
1
+ import { type HTMLAttributes, type ReactElement } from 'react';
2
+ import { type ControlSizeToken } from '../controls/util.js';
3
+ /**
4
+ * `<PillFilter>` — a wrapping row of small toggle pills (rounded squares, `radius-md`) for
5
+ * multi-select. Each pill is its own on/off toggle on the selection axis (`aria-pressed` +
6
+ * `data-selection`); the whole row is a `role="group"`. A selected pill fills to
7
+ * `var(--xeno-control)` with a `var(--xeno-muted)` hairline; an optional `count` renders as a small
8
+ * inset badge.
9
+ *
10
+ * Controlled: `value` is the array of selected option values and `onValueChange` receives the next
11
+ * array on every toggle. A disabled option rides the availability axis and cannot be toggled.
12
+ */
13
+ export interface PillOption {
14
+ readonly value: string;
15
+ readonly label: string;
16
+ readonly count?: number;
17
+ readonly disabled?: boolean;
18
+ }
19
+ export interface PillFilterProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'children'> {
20
+ readonly value: readonly string[];
21
+ readonly onValueChange?: (value: string[]) => void;
22
+ readonly options: readonly PillOption[];
23
+ readonly size?: ControlSizeToken;
24
+ }
25
+ export declare function PillFilter({ value, onValueChange, options, size, className, ...rest }: PillFilterProps): ReactElement;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { sizeAttr, cx } from '../controls/util.js';
4
+ export function PillFilter({ value, onValueChange, options, size = 'sm', className, ...rest }) {
5
+ const toggle = (v) => {
6
+ const next = value.includes(v) ? value.filter((x) => x !== v) : [...value, v];
7
+ onValueChange?.(next);
8
+ };
9
+ /**
10
+ * The fill pours in on a keyframe, so a row rendered with pills already selected would pour every one
11
+ * of them on page load. Arming has to be PER PILL, not per row: arming the whole row on the first
12
+ * click would make every already-selected pill flood at that moment too, which is the same bug one
13
+ * interaction later. A pill joins the set the first time its own selection actually flips, and stays.
14
+ *
15
+ * The comparison is on a sorted key, not on the array's identity — a parent that rebuilds `value` on
16
+ * every render would otherwise look like a change every time, and setting state during render on
17
+ * every render does not terminate.
18
+ */
19
+ const key = [...value].sort().join('\u0000');
20
+ const [seen, setSeen] = useState({ key, list: value });
21
+ const [armed, setArmed] = useState(() => new Set());
22
+ if (seen.key !== key) {
23
+ const before = new Set(seen.list);
24
+ const after = new Set(value);
25
+ const flipped = [...new Set([...seen.list, ...value])].filter((v) => before.has(v) !== after.has(v));
26
+ if (flipped.length > 0) {
27
+ setArmed((prev) => {
28
+ const next = new Set(prev);
29
+ for (const v of flipped)
30
+ next.add(v);
31
+ return next;
32
+ });
33
+ }
34
+ setSeen({ key, list: value });
35
+ }
36
+ return (_jsx("div", { role: "group", className: cx('xeno-pillfilter', className), ...sizeAttr(size), ...rest, children: options.map((o) => {
37
+ const selected = value.includes(o.value);
38
+ const disabled = o.disabled ?? false;
39
+ return (_jsxs("button", { type: "button", className: "xeno-pill", "data-selection": selected ? 'on' : 'off', "data-availability": disabled ? 'disabled' : 'enabled', ...(armed.has(o.value) ? { 'data-motion': 'ready' } : {}), "aria-pressed": selected, disabled: disabled, onClick: () => toggle(o.value), children: [_jsx("span", { className: "xeno-pill-label", children: o.label }), o.count !== undefined && (_jsx("span", { className: "xeno-pill-count", "aria-hidden": "true", children: o.count }))] }, o.value));
40
+ }) }));
41
+ }
@@ -0,0 +1,32 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode, Ref } from 'react';
2
+ /**
3
+ * `<Popover>` — a hand-rolled anchored overlay (no portal lib, SPEC §13). A `position:relative` wrapper
4
+ * holds the `trigger`; while `open`, an absolutely-positioned panel (`--xeno-elevated`, a hairline
5
+ * border, one soft uncoloured shadow, `--xeno-radius-md`) is placed below the trigger, aligned to its
6
+ * `start` (default) or `end` edge. Nothing renders inside the panel until it is open.
7
+ *
8
+ * Controlled: `open` + `onOpenChange`. While open it wires two document listeners — a `mousedown`
9
+ * outside the wrapper closes it, and `Escape` closes it — both cleaned up on close/unmount. Focus is
10
+ * captured on open and restored to the opener on close.
11
+ *
12
+ * It is the composition seam for {@link Menu}: pass `panelProps` (spread onto the panel — `role`,
13
+ * `aria-*`, `onKeyDown`, `onClick`, …) and `panelRef` (attached to the panel node) to layer a menu, a
14
+ * listbox, or any anchored surface on top of the same anchoring + dismissal behaviour.
15
+ */
16
+ export interface PopoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
17
+ /** Whether the panel is mounted + visible. Nothing renders in the panel when false. */
18
+ readonly open: boolean;
19
+ /** Called with `false` on an outside click and on Escape. */
20
+ readonly onOpenChange?: (open: boolean) => void;
21
+ /** The anchor — wire its `onClick` to toggle `open` (this is a controlled overlay). */
22
+ readonly trigger: ReactNode;
23
+ /** The panel content. */
24
+ readonly children?: ReactNode;
25
+ /** Which trigger edge the panel aligns to. Default `start`. */
26
+ readonly align?: 'start' | 'end';
27
+ /** Attached to the panel node (composition seam — e.g. {@link Menu} queries its items). */
28
+ readonly panelRef?: Ref<HTMLDivElement>;
29
+ /** Spread onto the panel node — `role`, `aria-*`, `onKeyDown`, `onClick`, … (composition seam). */
30
+ readonly panelProps?: HTMLAttributes<HTMLDivElement>;
31
+ }
32
+ export declare function Popover({ open, onOpenChange, trigger, children, align, panelRef, panelProps, className, ...rest }: PopoverProps): ReactElement;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from 'react';
3
+ import { cx } from '../controls/util.js';
4
+ export function Popover({ open, onOpenChange, trigger, children, align = 'start', panelRef, panelProps, className, ...rest }) {
5
+ const rootRef = useRef(null);
6
+ const restoreFocus = useRef(null);
7
+ // Dismissal — bound on the document only while open, so nothing is listening when closed.
8
+ useEffect(() => {
9
+ if (!open)
10
+ return;
11
+ const onPointer = (e) => {
12
+ if (rootRef.current && !rootRef.current.contains(e.target))
13
+ onOpenChange?.(false);
14
+ };
15
+ const onKey = (e) => {
16
+ if (e.key === 'Escape')
17
+ onOpenChange?.(false);
18
+ };
19
+ document.addEventListener('mousedown', onPointer);
20
+ document.addEventListener('keydown', onKey);
21
+ return () => {
22
+ document.removeEventListener('mousedown', onPointer);
23
+ document.removeEventListener('keydown', onKey);
24
+ };
25
+ }, [open, onOpenChange]);
26
+ // Capture the opener on open; restore focus to it on close.
27
+ useEffect(() => {
28
+ if (open) {
29
+ restoreFocus.current = document.activeElement;
30
+ }
31
+ else if (restoreFocus.current) {
32
+ restoreFocus.current.focus?.();
33
+ restoreFocus.current = null;
34
+ }
35
+ }, [open]);
36
+ const { className: panelClassName, ...restPanel } = panelProps ?? {};
37
+ return (_jsxs("div", { ref: rootRef, className: cx('xeno-popover', className), ...rest, children: [trigger, open && (_jsx("div", { ref: panelRef, className: cx('xeno-popover-panel', panelClassName), "data-align": align, ...restPanel, children: children }))] }));
38
+ }
@@ -0,0 +1,20 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
2
+ /**
3
+ * `<Reveal>` — an IN-FLOW animated disclosure (the XENO chat's "Scheduled" reveal, generalised). When
4
+ * `open`, an in-flow region animates its height (measured from the content) so surrounding content is
5
+ * pushed DOWN — never floated over — while the content itself is clipped panel-WIDTH so a wide child
6
+ * (e.g. a calendar) shows full width and only unfolds VERTICALLY, its content fading up a beat later.
7
+ *
8
+ * Because the region's height tracks the CONTENT, one shared `<Reveal>` swapped between two same-height
9
+ * panels keeps a constant height — so switching panels never dips the layout. Hand-rolled, no portal
10
+ * lib (SPEC §13). Returns nothing while closed + fully exited.
11
+ */
12
+ export interface RevealProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
13
+ readonly open: boolean;
14
+ /** Which edge the clipped panel aligns to. Default `start`. */
15
+ readonly align?: 'start' | 'end';
16
+ readonly children?: ReactNode;
17
+ /** Spread onto the panel node — `id`, `role`, `aria-*` (composition seam for {@link PickerField}). */
18
+ readonly panelProps?: HTMLAttributes<HTMLDivElement>;
19
+ }
20
+ export declare function Reveal({ open, align, children, panelProps, className, ...rest }: RevealProps): ReactElement | null;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { cx } from '../controls/util.js';
4
+ /** Match the CSS reveal/exit duration so the region is unmounted only after it has animated out. */
5
+ const EXIT_MS = 240;
6
+ // useLayoutEffect measures before paint on the client; fall back to useEffect on the server so SSR does
7
+ // not warn. `typeof window` is constant per environment, so the hook choice is stable.
8
+ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
9
+ export function Reveal({ open, align = 'start', children, panelProps, className, ...rest }) {
10
+ const [mounted, setMounted] = useState(open);
11
+ const [shown, setShown] = useState(open);
12
+ const [clipped, setClipped] = useState(true);
13
+ const [height, setHeight] = useState(0);
14
+ const panelRef = useRef(null);
15
+ // The panel's natural height is what the region animates to. Re-measured when the content changes, so
16
+ // a swapped-in panel of a different height re-targets smoothly.
17
+ useIsoLayoutEffect(() => {
18
+ if (mounted && panelRef.current)
19
+ setHeight(panelRef.current.offsetHeight);
20
+ }, [mounted, children]);
21
+ useEffect(() => {
22
+ if (open) {
23
+ setMounted(true);
24
+ setClipped(true);
25
+ let raf2 = 0;
26
+ const raf1 = requestAnimationFrame(() => {
27
+ raf2 = requestAnimationFrame(() => setShown(true));
28
+ });
29
+ const unclip = setTimeout(() => setClipped(false), EXIT_MS + 30);
30
+ return () => {
31
+ cancelAnimationFrame(raf1);
32
+ cancelAnimationFrame(raf2);
33
+ clearTimeout(unclip);
34
+ };
35
+ }
36
+ setShown(false);
37
+ setClipped(true);
38
+ const t = setTimeout(() => setMounted(false), EXIT_MS);
39
+ return () => clearTimeout(t);
40
+ }, [open]);
41
+ if (!mounted)
42
+ return null;
43
+ const { className: panelClassName, ...restPanel } = panelProps ?? {};
44
+ return (_jsx("div", { className: cx('xeno-reveal', className), "data-open": shown ? 'true' : 'false', style: { height: shown ? height : 0 }, ...rest, children: _jsx("div", { className: "xeno-reveal-clip", "data-clip": clipped ? 'true' : 'false', "data-align": align, children: _jsx("div", { className: cx('xeno-reveal-panel', panelClassName), ref: panelRef, ...restPanel, children: _jsx("div", { className: "xeno-reveal-content", children: children }) }) }) }));
45
+ }
@@ -0,0 +1,23 @@
1
+ import { type HTMLAttributes, type ReactElement } from 'react';
2
+ import { type ControlSizeToken } from '../controls/util.js';
3
+ /**
4
+ * `<SegmentedControl>` — a single-select of connected options inside one bordered track (a rounded
5
+ * square, `radius-md`). The chosen option floats on an inset `var(--xeno-control)` fill; the rest are
6
+ * transparent `var(--xeno-muted)` labels. Semantics are a `role="group"` of `aria-pressed` buttons
7
+ * (the selection axis), so exactly one option reads as pressed.
8
+ *
9
+ * Controlled: the caller owns `value`/`onValueChange`. Clicking the already-selected option is a no-op
10
+ * (a segmented control always keeps one option chosen). A disabled option rides the availability axis.
11
+ */
12
+ export interface SegmentedOption {
13
+ readonly value: string;
14
+ readonly label: string;
15
+ readonly disabled?: boolean;
16
+ }
17
+ export interface SegmentedControlProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
18
+ readonly value: string;
19
+ readonly onValueChange?: (value: string) => void;
20
+ readonly options: readonly SegmentedOption[];
21
+ readonly size?: ControlSizeToken;
22
+ }
23
+ export declare function SegmentedControl({ value, onValueChange, options, size, className, ...rest }: SegmentedControlProps): ReactElement;
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { sizeAttr, cx } from '../controls/util.js';
4
+ export function SegmentedControl({ value, onValueChange, options, size = 'md', className, ...rest }) {
5
+ /**
6
+ * The two numbers the stylesheet cannot work out for itself — how many segments there are, and which
7
+ * one is chosen. They are DATA, not animation: CSS derives the thumb's width and offset from them and
8
+ * owns every frame. An index of -1 (a `value` matching no option) parks the thumb and hides it.
9
+ */
10
+ const index = options.findIndex((o) => o.value === value);
11
+ /* The squash is a keyframe, and a keyframe plays the moment its rule matches — so a control that
12
+ mounts with a selection would squash on page load. Arm on the first genuine change of `value`. */
13
+ const [armed, setArmed] = useState(false);
14
+ const [seen, setSeen] = useState(value);
15
+ /**
16
+ * How far the thumb is about to travel, in segments. The goo stretches in proportion to it, so a jump
17
+ * from one end to the other reaches further than a step to the neighbour. Capped at 2: past that the
18
+ * stretch stops adding anything and starts looking like a smear.
19
+ */
20
+ const [distance, setDistance] = useState(1);
21
+ if (seen !== value) {
22
+ const from = options.findIndex((o) => o.value === seen);
23
+ if (from >= 0 && index >= 0)
24
+ setDistance(Math.min(2, Math.abs(index - from)));
25
+ setSeen(value);
26
+ if (!armed)
27
+ setArmed(true);
28
+ }
29
+ // Only the per-instance numbers stay inline; the size metrics moved to `data-xeno-size`.
30
+ const trackVars = {
31
+ '--xeno-seg-count': options.length,
32
+ '--xeno-seg-index': Math.max(0, index),
33
+ '--xeno-seg-dist': distance,
34
+ };
35
+ return (_jsxs("div", { role: "group", className: cx('xeno-segmented', className), "data-selection": index >= 0 ? 'on' : 'off', "data-index": index >= 0 ? index : undefined, ...(armed ? { 'data-motion': 'ready' } : {}), ...sizeAttr(size), style: trackVars, ...rest, children: [_jsx("span", { className: "xeno-segmented-thumb", "aria-hidden": "true" }), options.map((o) => {
36
+ const selected = o.value === value;
37
+ const disabled = o.disabled ?? false;
38
+ return (_jsx("button", { type: "button", className: "xeno-segmented-option", "data-selection": selected ? 'on' : 'off', "data-availability": disabled ? 'disabled' : 'enabled', "aria-pressed": selected, disabled: disabled, onClick: () => {
39
+ if (!selected)
40
+ onValueChange?.(o.value);
41
+ }, children: o.label }, o.value));
42
+ })] }));
43
+ }
@@ -0,0 +1,32 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
2
+ import type { ElementDeclaration } from '@xenosystem/elements/schema';
3
+ import { type ControlSizeToken } from '../controls/util.js';
4
+ /**
5
+ * `<Tabs>` — a horizontal tablist that follows the WAI-ARIA tabs pattern: `role="tablist"` wrapping
6
+ * `role="tab"` buttons with a roving tabIndex (only the selected tab is in the tab order) and
7
+ * Arrow/Home/End keys that move focus *and* selection (automatic activation). The selected tab reads
8
+ * as `var(--xeno-text)` with a hairline underline; the rest sit at `var(--xeno-muted)`.
9
+ *
10
+ * Controlled: the caller owns `value`/`onValueChange`. An optional panel is drawn either from a
11
+ * `renderPanel(value)` callback or from `children`; when present it is a `role="tabpanel"` labelled by
12
+ * the selected tab. A disabled item rides the availability axis (`aria-disabled` + `data-availability`)
13
+ * and is skipped by keyboard traversal — never made unfocusable-then-orphaned.
14
+ */
15
+ export interface TabItem {
16
+ readonly value: string;
17
+ readonly label: string;
18
+ /** A glyph before the label — imported per-id from `@xenosystem/elements`. */
19
+ readonly icon?: ElementDeclaration;
20
+ readonly disabled?: boolean;
21
+ }
22
+ export interface TabsProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'children'> {
23
+ readonly value: string;
24
+ readonly onValueChange?: (value: string) => void;
25
+ readonly items: readonly TabItem[];
26
+ readonly size?: ControlSizeToken;
27
+ /** Draw the panel for the active tab. Wins over `children` when both are given. */
28
+ readonly renderPanel?: (value: string) => ReactNode;
29
+ /** Panel content for the active tab (used when `renderPanel` is absent). */
30
+ readonly children?: ReactNode;
31
+ }
32
+ export declare function Tabs({ value, onValueChange, items, size, renderPanel, children, className, 'aria-label': ariaLabel, ...rest }: TabsProps): ReactElement;