@gtivr4/a1-design-system-react 0.12.0 → 0.13.3

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 (65) hide show
  1. package/package.json +1 -1
  2. package/src/components/accordion/Accordion.jsx +2 -0
  3. package/src/components/banner/Banner.jsx +4 -1
  4. package/src/components/blockquote/blockquote.css +0 -2
  5. package/src/components/bottom-drawer/BottomDrawer.jsx +2 -2
  6. package/src/components/button/Button.d.ts +4 -0
  7. package/src/components/button/Button.jsx +15 -3
  8. package/src/components/button/button.css +39 -0
  9. package/src/components/calendar/calendar.css +0 -2
  10. package/src/components/card/card.css +1 -0
  11. package/src/components/checkbox-group/CheckboxGroup.jsx +1 -1
  12. package/src/components/checkbox-group/checkbox-group.css +3 -3
  13. package/src/components/choice-group/ChoiceGroup.d.ts +23 -0
  14. package/src/components/choice-group/ChoiceGroup.jsx +22 -10
  15. package/src/components/choice-group/choice-group.css +53 -8
  16. package/src/components/code/Code.d.ts +4 -0
  17. package/src/components/code/Code.jsx +44 -8
  18. package/src/components/code/code.css +29 -0
  19. package/src/components/context-menu/ContextMenu.d.ts +56 -0
  20. package/src/components/context-menu/ContextMenu.jsx +146 -0
  21. package/src/components/context-menu/context-menu.css +107 -0
  22. package/src/components/data-table/DataTable.jsx +1 -1
  23. package/src/components/definition-list/definition-list.css +15 -0
  24. package/src/components/divider/Divider.d.ts +4 -2
  25. package/src/components/divider/Divider.jsx +6 -1
  26. package/src/components/divider/divider.css +9 -5
  27. package/src/components/field/DateField.jsx +17 -2
  28. package/src/components/field/SelectField.jsx +1 -1
  29. package/src/components/field/TextField.d.ts +2 -0
  30. package/src/components/field/TextField.jsx +1 -1
  31. package/src/components/field/TextareaField.jsx +1 -1
  32. package/src/components/field/TimeField.jsx +17 -2
  33. package/src/components/field/field.css +12 -5
  34. package/src/components/field/textarea-field.css +1 -2
  35. package/src/components/fieldset/fieldset.css +2 -0
  36. package/src/components/icon-button/IconButton.d.ts +8 -0
  37. package/src/components/icon-button/IconButton.jsx +9 -4
  38. package/src/components/inline-editable/InlineEditable.d.ts +25 -0
  39. package/src/components/inline-editable/InlineEditable.jsx +77 -1
  40. package/src/components/inline-editable/inline-editable.css +44 -1
  41. package/src/components/message/Message.jsx +15 -9
  42. package/src/components/page-layout/page-layout.css +13 -0
  43. package/src/components/page-nav/page-nav.css +0 -2
  44. package/src/components/pagination/Pagination.jsx +3 -1
  45. package/src/components/radio-group/RadioGroup.jsx +1 -1
  46. package/src/components/radio-group/radio-group.css +3 -3
  47. package/src/components/section/Section.d.ts +8 -0
  48. package/src/components/section/Section.jsx +24 -0
  49. package/src/components/section/section.css +28 -0
  50. package/src/components/snackbar/Snackbar.d.ts +24 -0
  51. package/src/components/snackbar/Snackbar.jsx +11 -8
  52. package/src/components/snackbar/snackbar.css +7 -22
  53. package/src/components/stack/Stack.jsx +2 -1
  54. package/src/components/sticky-actions/StickyActions.d.ts +7 -0
  55. package/src/components/sticky-actions/StickyActions.jsx +23 -4
  56. package/src/components/sticky-actions/sticky-actions.css +5 -3
  57. package/src/components/tabs/Tabs.d.ts +2 -0
  58. package/src/components/tabs/Tabs.jsx +3 -3
  59. package/src/components/tabs/tabs.css +95 -0
  60. package/src/components/top-header/TopHeader.jsx +2 -0
  61. package/src/components/tree-menu/TreeMenu.d.ts +54 -0
  62. package/src/components/tree-menu/TreeMenu.jsx +500 -0
  63. package/src/components/tree-menu/tree-menu.css +254 -0
  64. package/src/index.js +2 -0
  65. package/src/tokens.css +16 -0
@@ -225,6 +225,77 @@
225
225
  border-bottom-left-radius: var(--base-radius-lg);
226
226
  }
227
227
 
228
+ /* ── Pills variant ──────────────────────────────────────────────────────── */
229
+
230
+ .a1-tab-list-wrapper--pills {
231
+ align-items: center;
232
+ }
233
+
234
+ .a1-tab-list--pills {
235
+ gap: var(--base-spacing-8);
236
+ flex-wrap: wrap;
237
+ }
238
+
239
+ .a1-tab--pills {
240
+ padding: var(--component-tab-padding-block) var(--component-tab-padding-inline);
241
+ font-size: var(--semantic-font-size-body-sm);
242
+ font-weight: var(--base-font-weight-medium);
243
+ color: var(--semantic-color-text-muted);
244
+ background: var(--semantic-color-surface-raised);
245
+ border-radius: var(--base-radius-pill);
246
+ }
247
+
248
+ .a1-tab--pills:hover:not([aria-selected="true"]) {
249
+ color: var(--semantic-color-text-default);
250
+ background: var(--semantic-color-surface-panel);
251
+ }
252
+
253
+ .a1-tab--pills[aria-selected="true"] {
254
+ color: var(--semantic-color-action-foreground);
255
+ background: var(--semantic-color-action-background);
256
+ cursor: default;
257
+ }
258
+
259
+ .a1-tab--pills[aria-selected="true"] .a1-tab__count {
260
+ background: color-mix(in srgb, var(--semantic-color-action-foreground) 24%, transparent);
261
+ color: var(--semantic-color-action-foreground);
262
+ }
263
+
264
+ /* ── Segment variant (mirrors SegmentedControl) ─────────────────────────── */
265
+
266
+ .a1-tab-list-wrapper--segment {
267
+ align-items: center;
268
+ }
269
+
270
+ .a1-tab-list--segment {
271
+ gap: var(--component-segmented-gap);
272
+ padding: var(--component-segmented-padding);
273
+ background: var(--semantic-color-surface-raised);
274
+ border: var(--component-segmented-border-width) solid var(--semantic-color-border-default);
275
+ border-radius: var(--base-radius-control);
276
+ }
277
+
278
+ .a1-tab--segment {
279
+ padding: var(--component-segmented-segment-padding-block) var(--component-segmented-segment-padding-inline);
280
+ font-size: var(--semantic-font-size-body-sm);
281
+ font-weight: var(--base-font-weight-medium);
282
+ color: var(--semantic-color-text-muted);
283
+ border-radius: calc(var(--base-radius-control) - var(--component-segmented-padding));
284
+ justify-content: center;
285
+ }
286
+
287
+ .a1-tab--segment:hover:not([aria-selected="true"]) {
288
+ color: var(--semantic-color-text-default);
289
+ background: var(--semantic-color-surface-panel);
290
+ }
291
+
292
+ .a1-tab--segment[aria-selected="true"] {
293
+ color: var(--semantic-color-text-default);
294
+ background: var(--semantic-color-surface-page);
295
+ box-shadow: var(--semantic-shadow-xs);
296
+ cursor: default;
297
+ }
298
+
228
299
  /* ── Progress variant ───────────────────────────────────────────────────── */
229
300
 
230
301
  .a1-tab-list-wrapper--progress {
@@ -470,3 +541,27 @@
470
541
  .a1-tab-panel--progress {
471
542
  padding: var(--base-spacing-24) 0;
472
543
  }
544
+
545
+ /* ─── Compact size ──────────────────────────────────────────────────────────── */
546
+
547
+ .a1-tabs--compact .a1-tab {
548
+ padding: var(--base-spacing-6) var(--base-spacing-8);
549
+ font-size: var(--semantic-font-size-body-sm);
550
+ gap: var(--base-spacing-4);
551
+ }
552
+
553
+ .a1-tabs--compact .a1-tab--line {
554
+ padding: var(--base-spacing-6) var(--base-spacing-8);
555
+ }
556
+
557
+ .a1-tabs--compact .a1-tab--folder {
558
+ padding: var(--base-spacing-6) var(--base-spacing-8);
559
+ }
560
+
561
+ .a1-tabs--compact .a1-tab--pills {
562
+ padding: var(--base-spacing-4) var(--base-spacing-12);
563
+ }
564
+
565
+ .a1-tabs--compact .a1-tab--segment {
566
+ padding: var(--component-segmented-segment-padding-block-sm) var(--component-segmented-segment-padding-inline-sm);
567
+ }
@@ -588,6 +588,7 @@ export function TopHeader({
588
588
  loginButton,
589
589
  navIconPosition = "start",
590
590
  className = "",
591
+ ...rest
591
592
  }) {
592
593
  const [navMode, setNavMode] = useState(() => resolveNavMode(navIconPosition));
593
594
  const [openSubmenu, setOpenSubmenu] = useState(null);
@@ -641,6 +642,7 @@ export function TopHeader({
641
642
  navMode === "hidden" && "a1-top-header--nav-hidden",
642
643
  className,
643
644
  ].filter(Boolean).join(" ")}
645
+ {...rest}
644
646
  >
645
647
  <button
646
648
  type="button"
@@ -0,0 +1,54 @@
1
+ import * as React from 'react';
2
+
3
+ export interface TreeItem {
4
+ /** Unique identifier for this node. */
5
+ id: string;
6
+ /** Display label. */
7
+ label: string;
8
+ /** Material Symbols icon name. */
9
+ icon?: string;
10
+ /** Renders the item as an `<a>` when provided; otherwise a `<button>`. */
11
+ href?: string;
12
+ /** Prevents interaction and applies a muted style. Default: false */
13
+ disabled?: boolean;
14
+ /** Nested child items — supports unlimited depth. */
15
+ children?: TreeItem[];
16
+ }
17
+
18
+ export interface TreeMenuProps {
19
+ /** Tree data. Supports any depth of nesting. */
20
+ items: TreeItem[];
21
+ /** ID of the currently selected item (controlled). */
22
+ selectedId?: string | null;
23
+ /** Called with the id of the item the user activates. */
24
+ onSelect?: (id: string) => void;
25
+ /** IDs of items that are expanded on initial render (uncontrolled). */
26
+ defaultExpandedIds?: string[];
27
+ /** IDs of currently expanded items (controlled). */
28
+ expandedIds?: string[];
29
+ /** Called with the new array of expanded IDs when expansion changes. */
30
+ onExpandedChange?: (ids: string[]) => void;
31
+ /** Renders "Expand all" and "Collapse all" buttons above the tree. Default: false */
32
+ showExpandControls?: boolean;
33
+ /** Called with the id of the item the user is hovering, or null when hover ends. */
34
+ onHoverChange?: (id: string | null) => void;
35
+ /** Called when the user right-clicks a tree item label. Fires with the item id and the originating mouse event. */
36
+ onItemContextMenu?: (id: string, event: React.MouseEvent) => void;
37
+ /** Enables drag-and-drop reordering and reparenting of items. Default: false */
38
+ draggable?: boolean;
39
+ /**
40
+ * Called when the user drops a dragged item onto a target.
41
+ * Only fires when `draggable` is true.
42
+ * - `position: "before"` — insert before the target.
43
+ * - `position: "after"` — insert after the target.
44
+ * - `position: "into"` — make the dragged item the last child of the target (branch nodes only).
45
+ */
46
+ onMove?: (params: { draggedId: string; targetId: string; position: 'before' | 'into' | 'after' }) => void;
47
+ /** Accessible name for the tree. Required when no visible label references the tree. */
48
+ 'aria-label'?: string;
49
+ /** ID of an element that labels the tree. */
50
+ 'aria-labelledby'?: string;
51
+ className?: string;
52
+ }
53
+
54
+ export declare function TreeMenu(props: TreeMenuProps): React.ReactElement;
@@ -0,0 +1,500 @@
1
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
2
+ import { Icon } from '../icon/Icon.jsx';
3
+ import './tree-menu.css';
4
+
5
+ // ── Context ───────────────────────────────────────────────────────────────────
6
+
7
+ const TreeCtx = createContext({
8
+ selectedId: null,
9
+ rovingId: null,
10
+ onSelect: () => {},
11
+ expandedIds: new Set(),
12
+ onToggle: () => {},
13
+ onRoving: () => {},
14
+ onHoverChange: () => {},
15
+ onItemContextMenu: () => {},
16
+ nodeRefs: { current: new Map() },
17
+ items: [],
18
+ isDraggable: false,
19
+ dragState: null,
20
+ forbiddenIds: new Set(),
21
+ onDragStart: () => {},
22
+ onDragOver: () => {},
23
+ onDragLeave: () => {},
24
+ onDrop: () => {},
25
+ onDragEnd: () => {},
26
+ });
27
+
28
+ // ── Helpers ───────────────────────────────────────────────────────────────────
29
+
30
+ function getVisibleFlat(items, expandedIds, result = []) {
31
+ for (const item of items) {
32
+ result.push(item);
33
+ if (item.children?.length && expandedIds.has(item.id)) {
34
+ getVisibleFlat(item.children, expandedIds, result);
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+
40
+ function findParent(items, targetId, parent = null) {
41
+ for (const item of items) {
42
+ if (item.id === targetId) return parent;
43
+ if (item.children?.length) {
44
+ const found = findParent(item.children, targetId, item);
45
+ if (found !== undefined) return found;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function getAllBranchIds(items, result = []) {
52
+ for (const item of items) {
53
+ if (item.children?.length) {
54
+ result.push(item.id);
55
+ getAllBranchIds(item.children, result);
56
+ }
57
+ }
58
+ return result;
59
+ }
60
+
61
+ function collectAllIds(items, result = []) {
62
+ for (const item of items) {
63
+ result.push(item.id);
64
+ if (item.children?.length) collectAllIds(item.children, result);
65
+ }
66
+ return result;
67
+ }
68
+
69
+ // Returns all descendant IDs of the item with targetId (not including itself).
70
+ function getDescendantIds(items, targetId, result = []) {
71
+ for (const item of items) {
72
+ if (item.id === targetId) {
73
+ collectAllIds(item.children ?? [], result);
74
+ return result;
75
+ }
76
+ if (item.children?.length) {
77
+ getDescendantIds(item.children, targetId, result);
78
+ }
79
+ }
80
+ return result;
81
+ }
82
+
83
+ // ── TreeItem ──────────────────────────────────────────────────────────────────
84
+
85
+ function TreeItem({ item, depth }) {
86
+ const {
87
+ selectedId, rovingId, onSelect, expandedIds, onToggle, onRoving, onHoverChange, onItemContextMenu, nodeRefs,
88
+ isDraggable, dragState, forbiddenIds,
89
+ onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd,
90
+ } = useContext(TreeCtx);
91
+ const uid = useId();
92
+ const groupId = `${uid}-group`;
93
+ const onToggleRef = useRef(onToggle);
94
+ onToggleRef.current = onToggle;
95
+
96
+ const isBranch = Array.isArray(item.children);
97
+ const hasChildren = !!item.children?.length;
98
+ const isExpanded = expandedIds.has(item.id);
99
+ const isSelected = item.id === selectedId;
100
+ const isRoving = item.id === rovingId;
101
+ const isForbidden = forbiddenIds.has(item.id);
102
+ const isDragging = dragState?.draggingId === item.id;
103
+ const isOver = !isForbidden && dragState?.overId === item.id;
104
+ const dropPosition = isOver ? dragState.position : null;
105
+
106
+ const Component = item.href ? 'a' : 'button';
107
+ const extraProps = item.href
108
+ ? { href: item.disabled ? undefined : item.href, draggable: false }
109
+ : { type: 'button', disabled: item.disabled };
110
+
111
+ // Auto-expand collapsed branches when the user holds a drag over them for 600 ms.
112
+ useEffect(() => {
113
+ if (isOver && dropPosition === 'into' && hasChildren && !isExpanded) {
114
+ const timer = setTimeout(() => onToggleRef.current(item.id), 600);
115
+ return () => clearTimeout(timer);
116
+ }
117
+ }, [isOver, dropPosition, hasChildren, isExpanded, item.id]);
118
+
119
+ function handleToggle(e) {
120
+ e.stopPropagation();
121
+ onToggle(item.id);
122
+ // Return focus to the label button so roving tabindex stays coherent.
123
+ nodeRefs.current.get(item.id)?.focus();
124
+ }
125
+
126
+ function handleSelect(e) {
127
+ if (item.disabled) return;
128
+ e.stopPropagation();
129
+ onSelect(item.id);
130
+ onRoving(item.id);
131
+ }
132
+
133
+ function handleFocus() {
134
+ onRoving(item.id);
135
+ }
136
+
137
+ // ── Drag handlers ──────────────────────────────────────────────────────────
138
+
139
+ function handleDragStart(e) {
140
+ if (!isDraggable || item.disabled) return;
141
+ e.stopPropagation();
142
+ e.dataTransfer.effectAllowed = 'move';
143
+ e.dataTransfer.setData('text/plain', item.id);
144
+ onDragStart(item.id);
145
+ }
146
+
147
+ function handleDragOver(e) {
148
+ if (!dragState || isForbidden) return;
149
+ e.preventDefault();
150
+ e.stopPropagation();
151
+ e.dataTransfer.dropEffect = 'move';
152
+ const rect = e.currentTarget.getBoundingClientRect();
153
+ const ratio = (e.clientY - rect.top) / rect.height;
154
+ let position;
155
+ if (ratio < 0.3) {
156
+ position = 'before';
157
+ } else if (ratio > 0.7) {
158
+ position = 'after';
159
+ } else {
160
+ // Middle zone: reparent if the target is a branch (children array present, even empty), otherwise split evenly.
161
+ position = isBranch ? 'into' : (ratio < 0.5 ? 'before' : 'after');
162
+ }
163
+ onDragOver(item.id, position);
164
+ }
165
+
166
+ function handleDragLeave(e) {
167
+ // Only fire when the drag actually leaves this element (not just moving to a child).
168
+ if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget)) {
169
+ onDragLeave(item.id);
170
+ }
171
+ }
172
+
173
+ function handleDrop(e) {
174
+ e.preventDefault();
175
+ e.stopPropagation();
176
+ if (!isForbidden) onDrop(item.id);
177
+ }
178
+
179
+ function handleDragEnd(e) {
180
+ e.stopPropagation();
181
+ onDragEnd();
182
+ }
183
+
184
+ return (
185
+ <li
186
+ role="treeitem"
187
+ aria-selected={isSelected}
188
+ aria-expanded={hasChildren ? isExpanded : undefined}
189
+ aria-disabled={item.disabled || undefined}
190
+ >
191
+ <div
192
+ className={[
193
+ 'a1-tree-menu__row',
194
+ isSelected && 'a1-tree-menu__row--selected',
195
+ item.disabled && 'a1-tree-menu__row--disabled',
196
+ isDragging && 'a1-tree-menu__row--dragging',
197
+ isOver && dropPosition === 'before' && 'a1-tree-menu__row--drop-before',
198
+ isOver && dropPosition === 'into' && 'a1-tree-menu__row--drop-into',
199
+ isOver && dropPosition === 'after' && 'a1-tree-menu__row--drop-after',
200
+ ].filter(Boolean).join(' ')}
201
+ style={{ '--a1-tree-depth': depth }}
202
+ draggable={isDraggable && !item.disabled}
203
+ onDragStart={handleDragStart}
204
+ onDragOver={handleDragOver}
205
+ onDragLeave={handleDragLeave}
206
+ onDrop={handleDrop}
207
+ onDragEnd={handleDragEnd}
208
+ >
209
+ {/* Expand/collapse — mouse only. Keyboard uses Arrow Right/Left. */}
210
+ {hasChildren ? (
211
+ <button
212
+ type="button"
213
+ className="a1-tree-menu__toggle"
214
+ aria-label={isExpanded ? 'Collapse' : 'Expand'}
215
+ tabIndex={-1}
216
+ onMouseDown={(e) => e.preventDefault()}
217
+ onClick={handleToggle}
218
+ >
219
+ <Icon name={isExpanded ? 'indeterminate_check_box' : 'add_box'} />
220
+ </button>
221
+ ) : (
222
+ <span className="a1-tree-menu__toggle-spacer" aria-hidden="true" />
223
+ )}
224
+
225
+ {/* Select trigger */}
226
+ <Component
227
+ data-tree-id={item.id}
228
+ className={[
229
+ 'a1-tree-menu__label-btn',
230
+ item.disabled && 'a1-tree-menu__label-btn--disabled',
231
+ ].filter(Boolean).join(' ')}
232
+ tabIndex={isRoving ? 0 : -1}
233
+ ref={(el) => {
234
+ if (el) nodeRefs.current.set(item.id, el);
235
+ else nodeRefs.current.delete(item.id);
236
+ }}
237
+ onClick={handleSelect}
238
+ onFocus={handleFocus}
239
+ onMouseEnter={() => !item.disabled && onHoverChange(item.id)}
240
+ onMouseLeave={() => onHoverChange(null)}
241
+ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onItemContextMenu(item.id, e); }}
242
+ {...extraProps}
243
+ >
244
+ {item.icon && (
245
+ <Icon name={item.icon} className="a1-tree-menu__icon" aria-hidden="true" />
246
+ )}
247
+ <span className="a1-tree-menu__label">{item.label}</span>
248
+ </Component>
249
+ </div>
250
+
251
+ {hasChildren && (
252
+ <div
253
+ className={[
254
+ 'a1-tree-menu__group-wrapper',
255
+ isExpanded && 'a1-tree-menu__group-wrapper--open',
256
+ ].filter(Boolean).join(' ')}
257
+ >
258
+ <div className="a1-tree-menu__group-inner">
259
+ <ul id={groupId} role="group" className="a1-tree-menu__group">
260
+ {item.children.map((child) => (
261
+ <TreeItem key={child.id} item={child} depth={depth + 1} />
262
+ ))}
263
+ </ul>
264
+ </div>
265
+ </div>
266
+ )}
267
+ </li>
268
+ );
269
+ }
270
+
271
+ // ── TreeMenu ──────────────────────────────────────────────────────────────────
272
+
273
+ export function TreeMenu({
274
+ items = [],
275
+ selectedId = null,
276
+ onSelect,
277
+ defaultExpandedIds = [],
278
+ expandedIds: controlledExpandedIds,
279
+ onExpandedChange,
280
+ showExpandControls = false,
281
+ onHoverChange,
282
+ onItemContextMenu,
283
+ draggable = false,
284
+ onMove,
285
+ 'aria-label': ariaLabel,
286
+ 'aria-labelledby': ariaLabelledBy,
287
+ className = '',
288
+ }) {
289
+ const isControlled = controlledExpandedIds !== undefined;
290
+
291
+ const [uncontrolledExpandedIds, setUncontrolledExpandedIds] = useState(
292
+ () => new Set(defaultExpandedIds)
293
+ );
294
+ const expandedIds = isControlled
295
+ ? new Set(controlledExpandedIds)
296
+ : uncontrolledExpandedIds;
297
+
298
+ const [rovingId, setRovingId] = useState(
299
+ () => selectedId ?? items[0]?.id ?? null
300
+ );
301
+
302
+ const [dragState, setDragState] = useState(null);
303
+
304
+ const nodeRefs = useRef(new Map());
305
+
306
+ const setExpanded = useCallback((next) => {
307
+ if (!isControlled) setUncontrolledExpandedIds(next);
308
+ onExpandedChange?.(Array.from(next));
309
+ }, [isControlled, onExpandedChange]);
310
+
311
+ const onToggle = useCallback((id) => {
312
+ const next = new Set(expandedIds);
313
+ if (next.has(id)) next.delete(id);
314
+ else next.add(id);
315
+ setExpanded(next);
316
+ }, [expandedIds, setExpanded]);
317
+
318
+ // IDs that cannot receive a drop: the dragged item itself and all its descendants.
319
+ const forbiddenIds = useMemo(() => {
320
+ if (!dragState?.draggingId) return new Set();
321
+ const ids = getDescendantIds(items, dragState.draggingId);
322
+ ids.push(dragState.draggingId);
323
+ return new Set(ids);
324
+ }, [items, dragState?.draggingId]);
325
+
326
+ // ── Drag callbacks ────────────────────────────────────────────────────────
327
+
328
+ const handleDragStart = useCallback((id) => {
329
+ setDragState({ draggingId: id, overId: null, position: 'after' });
330
+ }, []);
331
+
332
+ const handleDragOver = useCallback((id, position) => {
333
+ setDragState((prev) => {
334
+ if (!prev) return prev;
335
+ if (prev.overId === id && prev.position === position) return prev;
336
+ return { ...prev, overId: id, position };
337
+ });
338
+ }, []);
339
+
340
+ const handleDragLeave = useCallback((id) => {
341
+ setDragState((prev) => {
342
+ if (!prev || prev.overId !== id) return prev;
343
+ return { ...prev, overId: null };
344
+ });
345
+ }, []);
346
+
347
+ const handleDrop = useCallback((targetId) => {
348
+ setDragState((prev) => {
349
+ if (!prev) return null;
350
+ onMove?.({ draggedId: prev.draggingId, targetId, position: prev.position ?? 'after' });
351
+ return null;
352
+ });
353
+ }, [onMove]);
354
+
355
+ const handleDragEnd = useCallback(() => {
356
+ setDragState(null);
357
+ }, []);
358
+
359
+ // ── Expand controls ───────────────────────────────────────────────────────
360
+
361
+ function handleExpandAll() {
362
+ setExpanded(new Set(getAllBranchIds(items)));
363
+ }
364
+
365
+ function handleCollapseAll() {
366
+ setExpanded(new Set());
367
+ }
368
+
369
+ // ── Keyboard navigation ───────────────────────────────────────────────────
370
+
371
+ function focusNode(id) {
372
+ setRovingId(id);
373
+ requestAnimationFrame(() => nodeRefs.current.get(id)?.focus());
374
+ }
375
+
376
+ function handleKeyDown(e) {
377
+ const visible = getVisibleFlat(items, expandedIds);
378
+ const currentId = e.target.closest('[data-tree-id]')?.dataset?.treeId;
379
+ const currentIndex = currentId ? visible.findIndex((v) => v.id === currentId) : -1;
380
+
381
+ switch (e.key) {
382
+ case 'ArrowDown': {
383
+ e.preventDefault();
384
+ const next = visible[currentIndex + 1];
385
+ if (next) focusNode(next.id);
386
+ break;
387
+ }
388
+ case 'ArrowUp': {
389
+ e.preventDefault();
390
+ const prev = visible[currentIndex - 1];
391
+ if (prev) focusNode(prev.id);
392
+ break;
393
+ }
394
+ case 'ArrowRight': {
395
+ e.preventDefault();
396
+ if (currentIndex >= 0) {
397
+ const cur = visible[currentIndex];
398
+ if (cur.children?.length) {
399
+ if (!expandedIds.has(cur.id)) {
400
+ onToggle(cur.id);
401
+ } else {
402
+ const first = cur.children[0];
403
+ if (first) focusNode(first.id);
404
+ }
405
+ }
406
+ }
407
+ break;
408
+ }
409
+ case 'ArrowLeft': {
410
+ e.preventDefault();
411
+ if (currentIndex >= 0) {
412
+ const cur = visible[currentIndex];
413
+ if (cur.children?.length && expandedIds.has(cur.id)) {
414
+ onToggle(cur.id);
415
+ } else {
416
+ const parent = findParent(items, cur.id);
417
+ if (parent) focusNode(parent.id);
418
+ }
419
+ }
420
+ break;
421
+ }
422
+ case 'Home': {
423
+ e.preventDefault();
424
+ const first = visible[0];
425
+ if (first) focusNode(first.id);
426
+ break;
427
+ }
428
+ case 'End': {
429
+ e.preventDefault();
430
+ const last = visible[visible.length - 1];
431
+ if (last) focusNode(last.id);
432
+ break;
433
+ }
434
+ case 'Enter':
435
+ case ' ': {
436
+ e.preventDefault();
437
+ if (currentIndex >= 0) {
438
+ const cur = visible[currentIndex];
439
+ if (!cur.disabled) onSelect?.(cur.id);
440
+ }
441
+ break;
442
+ }
443
+ }
444
+ }
445
+
446
+ const tree = (
447
+ <TreeCtx.Provider
448
+ value={{
449
+ selectedId,
450
+ rovingId: rovingId ?? items[0]?.id ?? null,
451
+ onSelect: onSelect ?? (() => {}),
452
+ expandedIds,
453
+ onToggle,
454
+ onRoving: setRovingId,
455
+ onHoverChange: onHoverChange ?? (() => {}),
456
+ onItemContextMenu: onItemContextMenu ?? (() => {}),
457
+ nodeRefs,
458
+ items,
459
+ isDraggable: draggable,
460
+ dragState,
461
+ forbiddenIds,
462
+ onDragStart: handleDragStart,
463
+ onDragOver: handleDragOver,
464
+ onDragLeave: handleDragLeave,
465
+ onDrop: handleDrop,
466
+ onDragEnd: handleDragEnd,
467
+ }}
468
+ >
469
+ <ul
470
+ role="tree"
471
+ aria-label={ariaLabel}
472
+ aria-labelledby={ariaLabelledBy}
473
+ className={['a1-tree-menu', className].filter(Boolean).join(' ')}
474
+ onKeyDown={handleKeyDown}
475
+ >
476
+ {items.map((item) => (
477
+ <TreeItem key={item.id} item={item} depth={0} />
478
+ ))}
479
+ </ul>
480
+ </TreeCtx.Provider>
481
+ );
482
+
483
+ if (!showExpandControls) return tree;
484
+
485
+ return (
486
+ <div className="a1-tree-menu-root">
487
+ <div className="a1-tree-menu__controls">
488
+ <button type="button" className="a1-tree-menu__control-btn" onClick={handleExpandAll}>
489
+ <Icon name="unfold_more" />
490
+ Expand all
491
+ </button>
492
+ <button type="button" className="a1-tree-menu__control-btn" onClick={handleCollapseAll}>
493
+ <Icon name="unfold_less" />
494
+ Collapse all
495
+ </button>
496
+ </div>
497
+ {tree}
498
+ </div>
499
+ );
500
+ }