@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,76 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useRef } from 'react';
3
+ const DEFAULT_ROW_SELECTOR = 'button[role^="menuitem"]:not(:disabled)';
4
+ /**
5
+ * Where a row starts inside the host, in LAYOUT pixels.
6
+ *
7
+ * Offsets rather than `getBoundingClientRect()` because hosts animate in: a panel that scales or
8
+ * translates on entrance reports transformed rects, and a pill positioned from those would be off by
9
+ * whatever the entrance was doing on that frame. Offsets are untransformed.
10
+ *
11
+ * Walking the offset chain (rather than reading `row.offsetTop` once) does two jobs at once: it survives
12
+ * a row wrapped in a positioned div, and it returns `null` for a row that lives in a nested list —
13
+ * exactly the row this pill should refuse.
14
+ */
15
+ function offsetWithin(row, host) {
16
+ let top = 0;
17
+ let node = row;
18
+ while (node && node !== host) {
19
+ top += node.offsetTop;
20
+ node = node.offsetParent;
21
+ }
22
+ return node === host ? top : null;
23
+ }
24
+ export function useGooPill(options = {}) {
25
+ const { rowSelector = DEFAULT_ROW_SELECTOR } = options;
26
+ const ownRef = useRef(null);
27
+ const hostRef = options.hostRef ?? ownRef;
28
+ const rows = useCallback(() => {
29
+ const host = hostRef.current;
30
+ if (!host)
31
+ return [];
32
+ return Array.from(host.querySelectorAll(rowSelector));
33
+ }, [hostRef, rowSelector]);
34
+ /**
35
+ * Written straight to the DOM rather than through state: this fires on every row the pointer crosses,
36
+ * and re-rendering a list to move a highlight is work for nothing.
37
+ */
38
+ const moveGoo = useCallback((row) => {
39
+ const host = hostRef.current;
40
+ if (!host)
41
+ return;
42
+ if (!row) {
43
+ host.removeAttribute('data-goo');
44
+ return;
45
+ }
46
+ const top = offsetWithin(row, host);
47
+ if (top === null)
48
+ return;
49
+ host.style.setProperty('--xeno-goo-y', `${top}px`);
50
+ host.style.setProperty('--xeno-goo-h', `${row.offsetHeight}px`);
51
+ // The index only exists to give the stretch a distinct animation name per row; a browser will not
52
+ // replay an animation whose name did not change, so without it the pill would deform once and then
53
+ // glide silently for the rest of the list.
54
+ const index = rows().indexOf(row);
55
+ host.setAttribute('data-goo-index', String(Math.max(0, Math.min(9, index))));
56
+ host.setAttribute('data-goo', 'on');
57
+ }, [hostRef, rows]);
58
+ const rowFrom = useCallback((target) => target?.closest(rowSelector) ?? null, [rowSelector]);
59
+ const onMouseOver = useCallback((e) => moveGoo(rowFrom(e.target)), [moveGoo, rowFrom]);
60
+ const onMouseLeave = useCallback(() => moveGoo(null), [moveGoo]);
61
+ // Keyboard too: a list like this is a keyboard surface first, and a highlight that only answers a
62
+ // mouse is half a highlight.
63
+ const onFocus = useCallback((e) => moveGoo(rowFrom(e.target)), [moveGoo, rowFrom]);
64
+ return {
65
+ hostRef,
66
+ hostProps: {
67
+ ref: hostRef,
68
+ className: 'xeno-goo-host',
69
+ onMouseOver,
70
+ onMouseLeave,
71
+ onFocus,
72
+ },
73
+ pill: _jsx("span", { className: "xeno-goo-pill", "aria-hidden": "true" }),
74
+ moveGoo,
75
+ };
76
+ }
@@ -0,0 +1,50 @@
1
+ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, MutableRefObject } from 'react';
2
+ /**
3
+ * `useMenu()` — everything a menu has to DO, for a menu this library did not build.
4
+ *
5
+ * Focus lands on the first item when it opens, Arrow Up/Down walk the list and wrap, Home and End jump
6
+ * to the ends, Tab leaves and closes, and choosing an item closes it. None of that is visual, and all
7
+ * of it is what separates a menu from a floating list of buttons.
8
+ *
9
+ * It is the third hook of its kind and it exists for the third time for the same reason. {@link Menu}
10
+ * has all of this and a product cannot reach it: adopting the component means adopting its `Popover`
11
+ * placement and its looks in the same breath, so a product with its own dropdown design writes the
12
+ * behaviour again — or, as measured on one, does not write it at all. Twelve menus in that chat, and
13
+ * every row was its own Tab stop with the arrow keys doing nothing.
14
+ *
15
+ * ```tsx
16
+ * const { menuProps } = useMenu({ open, onClose: () => setOpen(false) })
17
+ * return open ? <div {...menuProps} className="my-dropdown">{rows}</div> : null
18
+ * ```
19
+ *
20
+ * Composes with {@link useGooPill}: hand it the same ref and one element carries the highlight and the
21
+ * keyboard.
22
+ */
23
+ export interface UseMenuOptions<T extends HTMLElement> {
24
+ /** Whether the menu is open. Everything here is inert while false. */
25
+ readonly open: boolean;
26
+ /** Called when the menu should close — an item was chosen, or Tab left it. */
27
+ readonly onClose?: () => void;
28
+ /** An existing ref for the panel, if the caller already has one (e.g. from `useGooPill`). */
29
+ readonly menuRef?: MutableRefObject<T | null>;
30
+ /**
31
+ * Which rows the keyboard walks. Default is the ARIA menu family — `menuitem`, `menuitemcheckbox`,
32
+ * `menuitemradio` — and never a disabled one: a row you cannot choose is a row the arrows must skip
33
+ * rather than land on and strand you.
34
+ */
35
+ readonly itemSelector?: string;
36
+ }
37
+ export interface UseMenuResult<T extends HTMLElement> {
38
+ readonly menuRef: MutableRefObject<T | null>;
39
+ /** Spread on the element that carries `role="menu"`. */
40
+ readonly menuProps: {
41
+ readonly role: 'menu';
42
+ readonly 'aria-orientation': 'vertical';
43
+ /** So the panel itself can hold focus for the instant before the first item takes it. */
44
+ readonly tabIndex: -1;
45
+ readonly ref: (node: T | null) => void;
46
+ readonly onKeyDown: (e: ReactKeyboardEvent<T>) => void;
47
+ readonly onClick: (e: ReactMouseEvent<T>) => void;
48
+ };
49
+ }
50
+ export declare function useMenu<T extends HTMLElement = HTMLDivElement>({ open, onClose, menuRef: providedRef, itemSelector, }: UseMenuOptions<T>): UseMenuResult<T>;
@@ -0,0 +1,119 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ const DEFAULT_ITEMS = 'button[role^="menuitem"]:not(:disabled), [role^="menuitem"]:not([aria-disabled="true"])';
3
+ export function useMenu({ open, onClose, menuRef: providedRef, itemSelector = DEFAULT_ITEMS, }) {
4
+ const ownRef = useRef(null);
5
+ const menuRef = providedRef ?? ownRef;
6
+ const openRef = useRef(open);
7
+ openRef.current = open;
8
+ const items = useCallback(() => {
9
+ const root = menuRef.current;
10
+ if (!root)
11
+ return [];
12
+ return Array.from(root.querySelectorAll(itemSelector));
13
+ }, [menuRef, itemSelector]);
14
+ /**
15
+ * Put focus on the first row, and the guard is what took three tries to get right.
16
+ *
17
+ * The obvious latch — focus once per opening — is wrong, and wrong in a way that only shows on a real
18
+ * menu. Panels that animate their exit stay mounted and toggle `aria-hidden`, and several key their
19
+ * root on the animation state, so React REMOUNTS them as they open. The sequence measured on one: the
20
+ * effect focuses the first row, the key flips, React replaces the element the focus was on, and focus
21
+ * falls back to the document. A latch that has already fired cannot recover from that.
22
+ *
23
+ * The honest condition is not "have I done this yet" but "is focus where it belongs": while the menu
24
+ * is open, if nothing inside it has focus, put focus on the first row. That survives a remount, and it
25
+ * also refuses to steal focus from a row the user has arrowed to, which a re-fire otherwise would.
26
+ */
27
+ const focusFirst = useCallback((node) => {
28
+ if (!node || !openRef.current)
29
+ return;
30
+ if (node.contains(document.activeElement))
31
+ return;
32
+ Array.from(node.querySelectorAll(itemSelector))[0]?.focus();
33
+ }, [itemSelector]);
34
+ const setMenu = useCallback((node) => {
35
+ menuRef.current = node;
36
+ focusFirst(node);
37
+ }, [menuRef, focusFirst]);
38
+ /**
39
+ * The other way a menu opens: ALREADY MOUNTED.
40
+ *
41
+ * The callback ref above covers the panel that is rendered only while open. Plenty are not — a menu
42
+ * that animates its exit has to stay in the tree and toggle `aria-hidden`, and for those the ref
43
+ * fires once, on first mount, while the menu is still shut. Measured on a real one: the arrow keys
44
+ * worked and focus never entered.
45
+ *
46
+ * So both, guarded by the same latch. Whichever happens first wins and the other does nothing.
47
+ */
48
+ useEffect(() => {
49
+ if (!open)
50
+ return;
51
+ focusFirst(menuRef.current);
52
+ }, [open, menuRef, focusFirst]);
53
+ const focusAt = useCallback((index) => {
54
+ const list = items();
55
+ if (list.length === 0)
56
+ return;
57
+ // Wrap in both directions. A menu is a ring; stopping dead at the last row is a behaviour people
58
+ // only notice as the control feeling stuck.
59
+ list[((index % list.length) + list.length) % list.length]?.focus();
60
+ }, [items]);
61
+ const onKeyDown = useCallback((e) => {
62
+ const list = items();
63
+ if (list.length === 0)
64
+ return;
65
+ const current = list.findIndex((el) => el === document.activeElement);
66
+ switch (e.key) {
67
+ case 'ArrowDown':
68
+ e.preventDefault();
69
+ focusAt(current < 0 ? 0 : current + 1);
70
+ break;
71
+ case 'ArrowUp':
72
+ e.preventDefault();
73
+ focusAt(current < 0 ? list.length - 1 : current - 1);
74
+ break;
75
+ case 'Home':
76
+ e.preventDefault();
77
+ focusAt(0);
78
+ break;
79
+ case 'End':
80
+ e.preventDefault();
81
+ focusAt(list.length - 1);
82
+ break;
83
+ case 'Tab':
84
+ /* Tab CLOSES rather than moving on. A menu is a modal little world: leaving it by tabbing into
85
+ the page behind, with the panel still open over that page, is the state nobody wants. */
86
+ e.preventDefault();
87
+ onClose?.();
88
+ break;
89
+ default:
90
+ break;
91
+ }
92
+ }, [items, focusAt, onClose]);
93
+ const onClick = useCallback((e) => {
94
+ const item = e.target.closest('[role^="menuitem"]');
95
+ if (!item)
96
+ return;
97
+ if (item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true')
98
+ return;
99
+ // A DISCLOSURE row keeps the menu. Activating an item normally dismisses the menu, because the
100
+ // item's job is elsewhere — but a row reporting `aria-expanded` opens a region INSIDE this
101
+ // menu, and closing on it destroys the panel in the same tick it was asked for. The symptom is
102
+ // a row that looks dead: click "Theme", the whole menu vanishes, and the accordion under it is
103
+ // never seen.
104
+ if (item.hasAttribute('aria-expanded'))
105
+ return;
106
+ onClose?.();
107
+ }, [onClose]);
108
+ return {
109
+ menuRef,
110
+ menuProps: {
111
+ role: 'menu',
112
+ 'aria-orientation': 'vertical',
113
+ tabIndex: -1,
114
+ ref: setMenu,
115
+ onKeyDown,
116
+ onClick,
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,88 @@
1
+ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
2
+ /**
3
+ * `useTabs()` — everything a tablist has to DO, for a tablist this library did not build.
4
+ *
5
+ * Arrow keys move between tabs, one Tab key press leaves the whole set, each tab names the panel it
6
+ * controls and the panel names the tab that labels it. None of that is visual, and all of it is what
7
+ * separates a row of tabs from a row of buttons that happen to sit next to each other.
8
+ *
9
+ * It exists for the same reason {@link useDialog} does, and the evidence is the same shape. Measured on
10
+ * one product's chat: FIVE tablists, and they disagreed with each other.
11
+ *
12
+ * - one had roving `tabIndex` and arrow keys, and pointed at no panel
13
+ * - three had neither — every tab was a Tab stop, so getting past a seven-section settings header meant
14
+ * seven presses, and the arrow keys did nothing at all
15
+ * - one had a `role="tabpanel"` and nothing tying it to the tab that opened it
16
+ *
17
+ * None of them was wrong on purpose. The behaviour is just long enough that it gets written once,
18
+ * properly, and then the next tablist is copied from the markup rather than from the working one.
19
+ *
20
+ * ```tsx
21
+ * const tabs = useTabs({ ids: SECTIONS.map((s) => s.id), activeId: section, onChange: setSection })
22
+ * return (
23
+ * <>
24
+ * <div {...tabs.tablistProps} aria-label="Settings sections">
25
+ * {SECTIONS.map((s) => <button key={s.id} {...tabs.tabProps(s.id)}>{s.label}</button>)}
26
+ * </div>
27
+ * <div {...tabs.panelProps}>…</div>
28
+ * </>
29
+ * )
30
+ * ```
31
+ */
32
+ export interface UseTabsOptions<Id extends string> {
33
+ /** Every tab, in the order they are rendered. Arrow keys walk this array. */
34
+ readonly ids: readonly Id[];
35
+ /** The selected tab. */
36
+ readonly activeId: Id;
37
+ /** Called with the tab the user moved to. */
38
+ readonly onChange: (id: Id) => void;
39
+ /**
40
+ * Which arrow keys walk the list. `horizontal` (default) is Left/Right; `vertical` is Up/Down.
41
+ * A vertical tablist must also say so to assistive technology, and `tablistProps` sets
42
+ * `aria-orientation` to match.
43
+ */
44
+ readonly orientation?: 'horizontal' | 'vertical';
45
+ /**
46
+ * Wrap from the last tab to the first. Default `true` — a tab strip is a ring, and stopping dead at
47
+ * the end is a behaviour people notice only as the control feeling stuck.
48
+ */
49
+ readonly wrap?: boolean;
50
+ /**
51
+ * Share one panel between two tablists.
52
+ *
53
+ * Normally the hook mints the panel's id and nobody has to know it. The case that needs this is a
54
+ * RESPONSIVE duplicate: the same sections rendered twice, once for wide screens and once for narrow,
55
+ * with CSS hiding one — both are in the document, and both must point at the single panel below them.
56
+ * Two hook instances would otherwise each invent an id and each claim to own the panel.
57
+ *
58
+ * Pass the same string to both; give the panel to whichever instance you like. The tab ids stay
59
+ * distinct either way, because those still come from `useId`.
60
+ */
61
+ readonly panelId?: string;
62
+ }
63
+ export interface UseTabsResult<Id extends string> {
64
+ /** Spread on the element that holds the tabs. */
65
+ readonly tablistProps: {
66
+ readonly role: 'tablist';
67
+ readonly 'aria-orientation': 'horizontal' | 'vertical';
68
+ readonly onKeyDown: (e: ReactKeyboardEvent<HTMLElement>) => void;
69
+ };
70
+ /** Spread on each tab. Pass the same id you passed in `ids`. */
71
+ readonly tabProps: (id: Id) => {
72
+ readonly role: 'tab';
73
+ readonly id: string;
74
+ readonly 'aria-selected': boolean;
75
+ readonly 'aria-controls': string;
76
+ readonly tabIndex: 0 | -1;
77
+ readonly ref: (node: HTMLElement | null) => void;
78
+ };
79
+ /** Spread on the panel the active tab controls. */
80
+ readonly panelProps: {
81
+ readonly role: 'tabpanel';
82
+ readonly id: string;
83
+ readonly 'aria-labelledby': string;
84
+ /** So a panel that scrolls can be reached and scrolled from the keyboard. */
85
+ readonly tabIndex: 0;
86
+ };
87
+ }
88
+ export declare function useTabs<Id extends string>({ ids, activeId, onChange, orientation, wrap, panelId: givenPanelId, }: UseTabsOptions<Id>): UseTabsResult<Id>;
@@ -0,0 +1,93 @@
1
+ import { useCallback, useId, useRef } from 'react';
2
+ export function useTabs({ ids, activeId, onChange, orientation = 'horizontal', wrap = true, panelId: givenPanelId, }) {
3
+ /* One id per hook instance, so two tablists on the same screen — a settings modal over a settings
4
+ page is the ordinary case — cannot both claim `#tab-general`. Duplicate ids do not throw; they
5
+ make `aria-controls` resolve to whichever came first, which is a bug with no symptom until
6
+ someone is using a screen reader. */
7
+ const uid = useId();
8
+ const tabId = useCallback((id) => `${uid}tab-${id}`, [uid]);
9
+ /* ONE panel id, not one per tab. Every tablist this was written for has a single panel element whose
10
+ CONTENTS swap — a settings page with one scroll container, a modal with one body — rather than N
11
+ panels of which N-1 are hidden. So every tab controls that one element, and the panel names the tab
12
+ currently labelling it. Minting an id per tab would describe a structure that is not there. */
13
+ const panelId = givenPanelId ?? `${uid}panel`;
14
+ /* The tabs, by id, so the hook can move focus without going through the document. `querySelector`
15
+ works and is what hand-rolled versions reach for; it also finds a tab from a DIFFERENT tablist
16
+ when both are open, because the selector it can write has nothing to scope by. */
17
+ const nodes = useRef(new Map());
18
+ /* Ref callbacks memoised PER ID. Returning a fresh closure from `tabProps` would give React a new ref
19
+ identity on every render, which it answers by calling the old one with null and the new one with
20
+ the node — every render, for every tab. Harmless and pointless; the map costs one lookup. */
21
+ const refs = useRef(new Map());
22
+ const setNode = useCallback((id) => {
23
+ let fn = refs.current.get(id);
24
+ if (!fn) {
25
+ fn = (node) => {
26
+ if (node)
27
+ nodes.current.set(id, node);
28
+ else
29
+ nodes.current.delete(id);
30
+ };
31
+ refs.current.set(id, fn);
32
+ }
33
+ return fn;
34
+ }, []);
35
+ const onKeyDown = useCallback((e) => {
36
+ const [prev, next] = orientation === 'vertical' ? ['ArrowUp', 'ArrowDown'] : ['ArrowLeft', 'ArrowRight'];
37
+ const at = ids.indexOf(activeId);
38
+ if (at < 0)
39
+ return;
40
+ let to = null;
41
+ if (e.key === next)
42
+ to = at + 1;
43
+ else if (e.key === prev)
44
+ to = at - 1;
45
+ else if (e.key === 'Home')
46
+ to = 0;
47
+ else if (e.key === 'End')
48
+ to = ids.length - 1;
49
+ if (to === null)
50
+ return;
51
+ if (to < 0 || to >= ids.length) {
52
+ if (!wrap)
53
+ return;
54
+ to = (to + ids.length) % ids.length;
55
+ }
56
+ const target = ids[to];
57
+ if (target === undefined || target === activeId)
58
+ return;
59
+ e.preventDefault();
60
+ onChange(target);
61
+ /* Focus AFTER the change has painted. Selection follows focus here — the ARIA pattern calls it
62
+ automatic activation, and it is right for tabs whose panels are already rendered — which means
63
+ the tab we want to focus is about to become the only one with `tabIndex: 0`. Focusing it in
64
+ this handler focuses the node as it is now, and React then re-renders and can replace it.
65
+
66
+ Two frames, not one. A single `requestAnimationFrame` fires BEFORE the commit that React
67
+ schedules in response to `onChange`, so on a slow section it lands on the old node. */
68
+ requestAnimationFrame(() => {
69
+ requestAnimationFrame(() => nodes.current.get(target)?.focus());
70
+ });
71
+ }, [ids, activeId, onChange, orientation, wrap]);
72
+ const tabProps = useCallback((id) => ({
73
+ role: 'tab',
74
+ id: tabId(id),
75
+ 'aria-selected': id === activeId,
76
+ 'aria-controls': panelId,
77
+ /* Roving: exactly one tab is a Tab stop, and it is the selected one. Without this every tab is
78
+ a stop, and a seven-section header costs seven presses to walk past — which is the difference
79
+ between a control and an obstacle. */
80
+ tabIndex: (id === activeId ? 0 : -1),
81
+ ref: setNode(id),
82
+ }), [activeId, tabId, panelId, setNode]);
83
+ return {
84
+ tablistProps: { role: 'tablist', 'aria-orientation': orientation, onKeyDown },
85
+ tabProps,
86
+ panelProps: {
87
+ role: 'tabpanel',
88
+ id: panelId,
89
+ 'aria-labelledby': tabId(activeId),
90
+ tabIndex: 0,
91
+ },
92
+ };
93
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@xenosystem/elements-react",
3
+ "version": "0.0.1",
4
+ "license": "UNLICENSED",
5
+ "description": "The XENO web renderer \u2014 interprets element declarations as React/SVG. CSS-first, 0 kb runtime.",
6
+ "author": "XENO Corporation <emilian@bnkrsys.com>",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "sideEffects": [
11
+ "*.css"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./xeno-element.css": "./src/xeno-element.css",
19
+ "./xeno-theme.css": "./src/xeno-theme.css",
20
+ "./controls.css": "./src/controls.css",
21
+ "./xeno-elements.css": "./src/xeno-elements.css",
22
+ "./fonts.css": "./src/fonts.css"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src/xeno-element.css",
27
+ "src/xeno-theme.css",
28
+ "src/controls.css",
29
+ "src/xeno-elements.css",
30
+ "src/**/*.css",
31
+ "LICENSE"
32
+ ],
33
+ "scripts": {
34
+ "prebuild": "npm run build -w @xenosystem/elements && npm run build -w @xenosystem/generate",
35
+ "build": "tsc -p tsconfig.json",
36
+ "pretypecheck": "npm run build -w @xenosystem/elements && npm run build -w @xenosystem/generate",
37
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
38
+ "test": "vitest run"
39
+ },
40
+ "peerDependencies": {
41
+ "react": ">=18"
42
+ },
43
+ "dependencies": {
44
+ "@fontsource-variable/inter": "^5.3.0",
45
+ "@xenosystem/elements": "0.0.1",
46
+ "@xenosystem/generate": "0.0.1"
47
+ },
48
+ "devDependencies": {
49
+ "@types/react": "^19.0.0",
50
+ "@types/react-dom": "^19.0.0",
51
+ "react": "^19.0.0",
52
+ "react-dom": "^19.0.0",
53
+ "typescript": "^5.4.0"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
@@ -0,0 +1,132 @@
1
+ /*
2
+ * ══════════════════════════════════════════════════════════════════════════════════════════════
3
+ * OWNED FILE — INDUSTRIAL (`separated`)
4
+ * ══════════════════════════════════════════════════════════════════════════════════════════════
5
+ *
6
+ * One construction, one file, one owner, so the two can be worked on in parallel without either
7
+ * side touching the other's values. Everything a construction decides lives HERE — colours, box
8
+ * metrics, typography, radii. Nothing construction-specific belongs in a component stylesheet or
9
+ * in `xeno-theme.css`, because those are shared and a change there hits BOTH looks.
10
+ *
11
+ * THE CONTRACT BETWEEN THE TWO FILES, enforced by `theme.test.ts`:
12
+ * 1. Every token declared in `chrome-separated.css` must also be declared here, and vice versa.
13
+ * Adding one on your side and not telling the other side is the failure mode — the missing
14
+ * one silently inherits the other construction's value and half-renders.
15
+ * 2. Every token must actually DIFFER between the two files. If a value is the same in both, it
16
+ * is not a construction decision: move it to the shared base.
17
+ *
18
+ * So adding a token is a two-file change and the suite will say so. That is deliberate: it is the
19
+ * cheapest possible coordination between two people editing in parallel.
20
+ */
21
+
22
+ /* ── CHROME: construction, not colour ──────────────────────────────────────────────────────────
23
+ * A THIRD axis, orthogonal to `data-theme` (colour ramp) and `data-surface` (density). It needs its
24
+ * own attribute: `data-theme` already carries `light`, and one attribute cannot hold two orthogonal
25
+ * values — you would lose "industrial + light".
26
+ *
27
+ * 🔴 DECLARED ON `:root`, NOT ON `.xeno`, and that is load-bearing rather than tidy.
28
+ *
29
+ * `.xeno` is a SCOPE MARKER — an app nests it (a section that wants the light ramp is a `.xeno` with
30
+ * `data-theme`), and the playground nests seven. A custom property resolves from the NEAREST
31
+ * declaring ancestor, so while these lived on `.xeno` every nested scope silently re-declared them at
32
+ * the default and the root's `data-chrome` never reached a single component. The axis appeared to do
33
+ * nothing, which is exactly what it did.
34
+ *
35
+ * On `:root` the defaults are declared once. `[data-chrome=…]` is attribute-only so it applies to
36
+ * whatever element carries it — html, an app wrapper, or one subtree — and wins for its descendants
37
+ * by proximity without needing to out-specify anything.
38
+ *
39
+ * DEFAULT = separated, because `DESIGN_SYSTEM.md` §"Panel Structure" requires it.
40
+ */
41
+ :root {
42
+ /* ── CHROME: construction, not colour ──────────────────────────────────────────────────────
43
+ * A THIRD axis, orthogonal to `data-theme` (colour ramp) and `data-surface` (density). It has to
44
+ * be its own attribute: `data-theme` already carries `light`, and one attribute cannot hold two
45
+ * orthogonal values — you would lose "industrial + light". Same lesson as RFC 0003.
46
+ *
47
+ * Every chrome-bearing element reads these instead of hardcoding. That is what lets ONE DOM render
48
+ * as either construction: no component knows a theme exists, and nothing is built twice.
49
+ *
50
+ * DEFAULT = separated, because `DESIGN_SYSTEM.md` §"Panel Structure" requires it ("They are NOT
51
+ * merged into a single container"). `unified` is the opt-in.
52
+ */
53
+ /*
54
+ * 6 / 2 / 6 / 4, from `xeno-tools/packages/tool-kit/src/ui/Plate.tsx` — "the canonical panel chrome,
55
+ * ported from xeno-motion's dialogs, the pattern the design language actually resolves to across 10
56
+ * of Motion's 12 dialogs."
57
+ *
58
+ * ⚠️ FIXED, not responsive. Motion and Pixel DO use `clamp()` — but only in `index.css`, for the
59
+ * DOCKVIEW panel chrome, where a docked panel scales with the viewport. A floating dialog or a tool
60
+ * panel does not, and `Plate` pins 6/4/2. An earlier version of this file generalised the wrong one.
61
+ */
62
+ --xeno-chrome-gap: 2px;
63
+ --xeno-chrome-inset: 6px;
64
+ /* What the SHELL paints. Separated: the page shows between plates. */
65
+ --xeno-chrome-shell: var(--xeno-canvas);
66
+ /* What a header/footer PLATE paints. */
67
+ --xeno-chrome-plate: var(--xeno-panel-header);
68
+ /* What the BODY plate paints. */
69
+ --xeno-chrome-body: var(--xeno-viewport);
70
+ /* Separation is the gap, so no divider line is drawn. */
71
+ --xeno-chrome-divider: none;
72
+ /* §"Modals / Overlays": "the backdrop provides the separation." */
73
+ --xeno-chrome-shadow: none;
74
+ --xeno-chrome-radius: 6px;
75
+ /*
76
+ * DERIVED, not picked from the scale — `Plate` rounds its shell 6px and its children 4px. Two
77
+ * rounded rects offset by a constant stay parallel only if the inner radius drops by that offset,
78
+ * so this is geometry rather than taste. An earlier version chose `sm` off the radius scale because
79
+ * "4 is not in the scale"; the scale was never the right source for this number.
80
+ */
81
+ --xeno-chrome-radius-plate: calc(var(--xeno-chrome-radius) - 2px);
82
+ /* The glass on the header plate. Both reference apps, identical values. */
83
+ --xeno-chrome-plate-filter: blur(14px) saturate(120%);
84
+ /*
85
+ * Button emphasis. The playbook: "Emphasis comes from surface lightness and text brightness. Never
86
+ * from hue." Separated follows the reference — primary is the lightest SURFACE with bright text,
87
+ * not an inverted fill. On the chrome axis rather than the base so `unified` keeps the inverted
88
+ * treatment this set already shipped; a button is a button, but which reading of "emphasis" applies
89
+ * is part of the construction.
90
+ */
91
+ /*
92
+ * The readout / detail block — `PlateDetail` in the reference: "raised back to plate colour against
93
+ * the recessed body. This is Motion's readout group and is where label-value pairs belong."
94
+ *
95
+ * So in `separated` a card is NOT a soft surface with a big radius and a shadow. It is a plate-
96
+ * coloured block, 4px, borderless, flat. `xeno-tools/DESIGN_SYSTEM.md` bans the alternative
97
+ * outright: "❌ Gradients in chrome, drop shadows for depth".
98
+ */
99
+ --xeno-chrome-card-bg: var(--xeno-panel-header);
100
+ --xeno-chrome-card-border: none;
101
+ --xeno-chrome-card-radius: var(--xeno-chrome-radius-plate);
102
+ --xeno-chrome-card-pad: 12px;
103
+ --xeno-chrome-card-shadow: none;
104
+ /* Hover "lifts alpha, never bounces or scales dramatically" — tools DESIGN_SYSTEM, Motion. */
105
+ --xeno-chrome-card-lift: none;
106
+
107
+ /*
108
+ * Typography and box metrics DIFFER between the constructions, so they belong on the axis too. An
109
+ * earlier version of this pass changed them in the BASE, which silently altered `unified` — the one
110
+ * thing this axis exists to prevent. Everything a construction decides lives here or it leaks.
111
+ */
112
+ --xeno-chrome-border: var(--xeno-border-subtle);
113
+ --xeno-chrome-panel-header-pad: 7px 12px;
114
+ --xeno-chrome-panel-header-min-h: 34px;
115
+ --xeno-chrome-panel-body-pad: 12px;
116
+ --xeno-chrome-title-size: 11px;
117
+ --xeno-chrome-title-color: var(--xeno-muted);
118
+ --xeno-chrome-title-transform: uppercase;
119
+ --xeno-chrome-title-spacing: 0.08em;
120
+ --xeno-chrome-dialog-header-pad: 6px 12px;
121
+ --xeno-chrome-dialog-title-size: 11px;
122
+ --xeno-chrome-dialog-title-weight: 500;
123
+ --xeno-chrome-dialog-body-pad: 12px;
124
+ --xeno-chrome-dialog-body-size: 12px;
125
+ --xeno-chrome-dialog-footer-pad: 6px;
126
+ --xeno-chrome-dialog-footer-gap: 6px;
127
+ --xeno-chrome-card-elevated-bg: var(--xeno-chrome-card-bg);
128
+
129
+ --xeno-chrome-btn-primary-bg: var(--xeno-control);
130
+ --xeno-chrome-btn-primary-fg: var(--xeno-text);
131
+ --xeno-chrome-btn-radius: var(--xeno-radius-xs);
132
+ }