@svgrid/grid 2.2.0 → 2.2.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 (114) hide show
  1. package/dist/FlexRender.svelte +96 -96
  2. package/dist/GridFooter.svelte +178 -178
  3. package/dist/SvCarousel.svelte +141 -141
  4. package/dist/SvColorInput.svelte +187 -187
  5. package/dist/SvContextMenu.svelte +116 -116
  6. package/dist/SvDrawer.svelte +250 -250
  7. package/dist/SvGrid.svelte +3131 -3131
  8. package/dist/SvGridBoard.svelte +2339 -2339
  9. package/dist/SvGridChart.svelte +1724 -1724
  10. package/dist/SvGridChartPanel.svelte +485 -485
  11. package/dist/SvGridDropdown.svelte +689 -689
  12. package/dist/SvGroupCell.svelte +109 -109
  13. package/dist/SvMenu.svelte +124 -124
  14. package/dist/SvMenuList.svelte +117 -117
  15. package/dist/SvNumberInput.svelte +131 -131
  16. package/dist/SvOtpInput.svelte +158 -158
  17. package/dist/SvPopover.svelte +139 -139
  18. package/dist/SvRowGroupPanel.svelte +170 -170
  19. package/dist/SvScrollArea.svelte +61 -61
  20. package/dist/SvTooltip.svelte +127 -127
  21. package/dist/SvTour.svelte +204 -204
  22. package/dist/SvTree.svelte +377 -377
  23. package/dist/SvTreeSelect.svelte +230 -230
  24. package/dist/cdn/svgrid.js +8 -8
  25. package/dist/cdn/svgrid.svelte-external.js +8 -8
  26. package/dist/chart-export.js +8 -8
  27. package/package.json +10 -10
  28. package/src/FlexRender.svelte +96 -96
  29. package/src/GridFooter.svelte +178 -178
  30. package/src/SvCarousel.svelte +141 -141
  31. package/src/SvColorInput.svelte +187 -187
  32. package/src/SvContextMenu.svelte +116 -116
  33. package/src/SvDrawer.svelte +250 -250
  34. package/src/SvGrid.controller.svelte.ts +3158 -3158
  35. package/src/SvGrid.svelte +3131 -3131
  36. package/src/SvGrid.types.ts +1489 -1489
  37. package/src/SvGridBoard.svelte +2339 -2339
  38. package/src/SvGridChart.svelte +1724 -1724
  39. package/src/SvGridChartPanel.svelte +485 -485
  40. package/src/SvGridDropdown.svelte +689 -689
  41. package/src/SvGroupCell.svelte +109 -109
  42. package/src/SvMenu.svelte +124 -124
  43. package/src/SvMenu.test.ts +97 -97
  44. package/src/SvMenuList.svelte +117 -117
  45. package/src/SvNumberInput.svelte +131 -131
  46. package/src/SvOtpInput.svelte +158 -158
  47. package/src/SvPopover.svelte +139 -139
  48. package/src/SvRowGroupPanel.svelte +170 -170
  49. package/src/SvScrollArea.svelte +61 -61
  50. package/src/SvTooltip.svelte +127 -127
  51. package/src/SvTour.svelte +204 -204
  52. package/src/SvTree.svelte +377 -377
  53. package/src/SvTreeSelect.svelte +230 -230
  54. package/src/a11y.contract.test.ts +49 -49
  55. package/src/a11y.test.ts +59 -59
  56. package/src/a11y.ts +61 -61
  57. package/src/build-api.ts +870 -870
  58. package/src/cell-formatting.ts +169 -169
  59. package/src/cell-render.ts +469 -469
  60. package/src/chart-export.ts +201 -201
  61. package/src/chart.ts +2296 -2296
  62. package/src/collaboration.test.ts +104 -104
  63. package/src/collaboration.ts +167 -167
  64. package/src/core.performance.test.ts +30 -30
  65. package/src/core.ts +1111 -1111
  66. package/src/createAutocomplete.svelte.ts +132 -132
  67. package/src/createCombobox.svelte.ts +179 -179
  68. package/src/createCountryInput.svelte.ts +157 -157
  69. package/src/createDropdownList.svelte.ts +162 -162
  70. package/src/createGrid.svelte.ts +42 -42
  71. package/src/createGrid.test.ts +10 -10
  72. package/src/createGridState.svelte.ts +17 -17
  73. package/src/createMenu.svelte.ts +202 -202
  74. package/src/createPopoverSelect.svelte.ts +206 -206
  75. package/src/createTree.svelte.ts +319 -319
  76. package/src/editing.test.ts +859 -859
  77. package/src/editing.ts +675 -675
  78. package/src/export-data-api.test.ts +126 -126
  79. package/src/export-format.test.ts +107 -107
  80. package/src/export-format.ts +598 -598
  81. package/src/flex-render.ts +3 -3
  82. package/src/index.ts +623 -623
  83. package/src/keyboard.test.ts +59 -59
  84. package/src/keyboard.ts +97 -97
  85. package/src/list-nav.test.ts +49 -49
  86. package/src/list-nav.ts +29 -29
  87. package/src/menus.ts +639 -639
  88. package/src/merge-objects.ts +48 -48
  89. package/src/overlays.test.ts +90 -90
  90. package/src/render-component.ts +28 -28
  91. package/src/selection.test.ts +754 -754
  92. package/src/selection.ts +600 -600
  93. package/src/server-data-source.test.ts +289 -289
  94. package/src/server-data-source.ts +413 -413
  95. package/src/sparkline.test.ts +68 -68
  96. package/src/sparkline.ts +169 -169
  97. package/src/spreadsheet.test.ts +489 -489
  98. package/src/spreadsheet.ts +312 -312
  99. package/src/static-functions.ts +11 -11
  100. package/src/subscribe.ts +38 -38
  101. package/src/svgrid-wrapper.types.ts +501 -501
  102. package/src/svgrid.behavior.test.ts +706 -706
  103. package/src/svgrid.charting.test.ts +527 -527
  104. package/src/svgrid.features.test.ts +157 -157
  105. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  106. package/src/svgrid.wrapper.test.ts +40 -40
  107. package/src/test-setup.ts +62 -62
  108. package/src/themes/index.ts +159 -159
  109. package/src/virtualization/column-virtualizer.test.ts +27 -27
  110. package/src/virtualization/column-virtualizer.ts +30 -30
  111. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  112. package/src/virtualization/types.ts +30 -30
  113. package/src/virtualization/virtualizer.test.ts +47 -47
  114. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,42 +1,42 @@
1
- import { createSvGridCore } from './core'
2
- import type {
3
- RowData,
4
- SvGrid,
5
- SvGridOptions,
6
- TableFeatures,
7
- } from './core'
8
-
9
- export type SvelteGrid<
10
- TFeatures extends TableFeatures,
11
- TData extends RowData,
12
- TSelected = {},
13
- > = SvGrid<TData> & {
14
- readonly state: Readonly<TSelected>
15
- }
16
-
17
- export function createSvGrid<
18
- TFeatures extends TableFeatures,
19
- TData extends RowData,
20
- TSelected = {},
21
- >(
22
- gridOptions: SvGridOptions<TFeatures, TData>,
23
- selector: (state: Record<string, any>) => TSelected = () => ({}) as TSelected,
24
- ): SvelteGrid<TFeatures, TData, TSelected> {
25
- const grid = createSvGridCore(gridOptions) as SvelteGrid<TFeatures, TData, TSelected>
26
- let selected = $state(selector(grid.store.state))
27
- grid.store.subscribe(() => {
28
- selected = selector(grid.store.state)
29
- })
30
-
31
- Object.defineProperty(grid, 'state', {
32
- get() {
33
- return selected
34
- },
35
- configurable: true,
36
- enumerable: true,
37
- })
38
-
39
- return grid
40
- }
41
-
42
- export const createGrid = createSvGrid
1
+ import { createSvGridCore } from './core'
2
+ import type {
3
+ RowData,
4
+ SvGrid,
5
+ SvGridOptions,
6
+ TableFeatures,
7
+ } from './core'
8
+
9
+ export type SvelteGrid<
10
+ TFeatures extends TableFeatures,
11
+ TData extends RowData,
12
+ TSelected = {},
13
+ > = SvGrid<TData> & {
14
+ readonly state: Readonly<TSelected>
15
+ }
16
+
17
+ export function createSvGrid<
18
+ TFeatures extends TableFeatures,
19
+ TData extends RowData,
20
+ TSelected = {},
21
+ >(
22
+ gridOptions: SvGridOptions<TFeatures, TData>,
23
+ selector: (state: Record<string, any>) => TSelected = () => ({}) as TSelected,
24
+ ): SvelteGrid<TFeatures, TData, TSelected> {
25
+ const grid = createSvGridCore(gridOptions) as SvelteGrid<TFeatures, TData, TSelected>
26
+ let selected = $state(selector(grid.store.state))
27
+ grid.store.subscribe(() => {
28
+ selected = selector(grid.store.state)
29
+ })
30
+
31
+ Object.defineProperty(grid, 'state', {
32
+ get() {
33
+ return selected
34
+ },
35
+ configurable: true,
36
+ enumerable: true,
37
+ })
38
+
39
+ return grid
40
+ }
41
+
42
+ export const createGrid = createSvGrid
@@ -1,10 +1,10 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { createGridState } from './index'
3
-
4
- describe('createGrid', () => {
5
- it('createGridState updates controlled values', () => {
6
- const [sorting, setSorting] = createGridState([{ id: 'age', desc: false }])
7
- setSorting((prev) => prev.map((entry) => ({ ...entry, desc: true })))
8
- expect(sorting()[0]?.desc).toBe(true)
9
- })
10
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createGridState } from './index'
3
+
4
+ describe('createGrid', () => {
5
+ it('createGridState updates controlled values', () => {
6
+ const [sorting, setSorting] = createGridState([{ id: 'age', desc: false }])
7
+ setSorting((prev) => prev.map((entry) => ({ ...entry, desc: true })))
8
+ expect(sorting()[0]?.desc).toBe(true)
9
+ })
10
+ })
@@ -1,17 +1,17 @@
1
- import type { Updater } from './core'
2
-
3
- export function createGridState<TState>(
4
- initialValue: TState,
5
- ): [() => TState, (updater: Updater<TState>) => void] {
6
- let value = $state(initialValue)
7
-
8
- return [
9
- () => value,
10
- (updater: Updater<TState>) => {
11
- if (updater instanceof Function) value = updater(value)
12
- else value = updater
13
- },
14
- ]
15
- }
16
-
17
- export const createSvGridState = createGridState
1
+ import type { Updater } from './core'
2
+
3
+ export function createGridState<TState>(
4
+ initialValue: TState,
5
+ ): [() => TState, (updater: Updater<TState>) => void] {
6
+ let value = $state(initialValue)
7
+
8
+ return [
9
+ () => value,
10
+ (updater: Updater<TState>) => {
11
+ if (updater instanceof Function) value = updater(value)
12
+ else value = updater
13
+ },
14
+ ]
15
+ }
16
+
17
+ export const createSvGridState = createGridState
@@ -1,202 +1,202 @@
1
- /**
2
- * createMenu - the HEADLESS core behind <SvMenuList> / <SvMenu>, in the same
3
- * spirit as `createListbox`: a runes-based state machine for ONE menu level -
4
- * roving focus over enabled items, submenu open/collapse, and the full WAI-ARIA
5
- * menu keyboard contract (arrows, Home/End, Enter/Space, Escape, Tab, and
6
- * rtl-aware ArrowLeft/Right) - with **prop-getters** you spread onto your own
7
- * markup. No styles, no DOM assumptions.
8
- *
9
- * A menu tree is recursive, so you create one core per nesting level (exactly
10
- * what the recursive <SvMenuList> does). The styled component is just one
11
- * renderer over this core.
12
- *
13
- * ```svelte
14
- * <script lang="ts">
15
- * import { createMenu, type MenuItem } from '@svgrid/grid'
16
- * let listEl = $state<HTMLElement | null>(null)
17
- * const menu = createMenu({
18
- * items: () => items,
19
- * onSelect: (it) => run(it),
20
- * onClose: () => close(),
21
- * focusItem: (i) => queueMicrotask(() => listEl?.querySelector(`[data-mi="${i}"]`)?.focus()),
22
- * })
23
- * </script>
24
- * <div bind:this={listEl} {...menu.listProps()}>
25
- * {#each items as item, i}
26
- * <button {...menu.itemProps(i)}>{item.label}</button>
27
- * {/each}
28
- * </div>
29
- * ```
30
- */
31
- import type { MenuItem } from './menu-item'
32
- import type { EditorDir } from './editor-contract'
33
- import { enabledIndices, wrapMove } from './list-nav'
34
-
35
- /** Reactive inputs are getters so the core tracks live prop changes (the same
36
- * controlled pattern as `createListbox`). */
37
- export type MenuConfig = {
38
- items: () => ReadonlyArray<MenuItem>
39
- /** Report the chosen leaf item (a non-parent menuitem). */
40
- onSelect: (item: MenuItem) => void
41
- /** Close the WHOLE menu tree - a leaf was chosen, or Escape / Tab / dismissal. */
42
- onClose: () => void
43
- /** This level is a submenu: enables ArrowLeft (or ArrowRight under rtl) to
44
- * collapse back into the opener. The root level has nowhere to collapse. */
45
- submenu?: () => boolean
46
- /** Collapse just THIS submenu level and refocus the item that opened it. */
47
- onCollapse?: () => void
48
- /** Text direction; under `rtl` the open/collapse arrow keys swap. */
49
- dir?: () => EditorDir | undefined
50
- /** DOM focus hook, invoked ONLY on keyboard navigation (never on hover) so the
51
- * core stays DOM-free. Receives the item index that should take focus. */
52
- focusItem?: (index: number) => void
53
- }
54
-
55
- export type MenuItemProps = {
56
- role: 'menuitem'
57
- 'data-mi': number
58
- tabindex: 0 | -1
59
- 'aria-haspopup': 'menu' | undefined
60
- 'aria-expanded': boolean | undefined
61
- 'aria-disabled': true | undefined
62
- disabled: boolean | undefined
63
- onkeydown: (event: KeyboardEvent) => void
64
- onpointerenter: () => void
65
- onclick: () => void
66
- }
67
-
68
- let uid = 0
69
-
70
- export function createMenu(config: MenuConfig) {
71
- const items = () => config.items()
72
- const submenu = () => config.submenu?.() ?? false
73
- const rtl = () => (config.dir?.() ?? 'ltr') === 'rtl'
74
-
75
- // -1 = nothing focused yet (the first enabled item is the tab stop).
76
- let active = $state(-1)
77
- let openSub = $state(-1)
78
-
79
- // Real (non-separator, non-disabled) item indices, for roving focus.
80
- const focusables = $derived(
81
- enabledIndices(items().length, (i) => {
82
- const it = items()[i]
83
- return !it || it.separator === true || it.disabled === true
84
- }),
85
- )
86
-
87
- /** Move focus to item `i` (keyboard): highlight it, close any open submenu at
88
- * this level, and drive real DOM focus through the host's hook. */
89
- function focus(i: number) {
90
- active = i
91
- openSub = -1
92
- config.focusItem?.(i)
93
- }
94
- function move(delta: number) {
95
- const next = wrapMove(focusables, active, delta)
96
- if (next >= 0) focus(next)
97
- }
98
- function first() {
99
- const f = focusables[0]
100
- if (f != null) focus(f)
101
- }
102
- function last() {
103
- const l = focusables.at(-1)
104
- if (l != null) focus(l)
105
- }
106
-
107
- /** Hover highlight: sets active WITHOUT stealing DOM focus, and opens a parent
108
- * item's submenu (mirrors the pointerenter behaviour). */
109
- function hover(i: number) {
110
- const it = items()[i]
111
- if (!it || it.disabled || it.separator) return
112
- active = i
113
- openSub = it.children?.length ? i : -1
114
- }
115
-
116
- function choose(item: MenuItem) {
117
- if (item.disabled || item.separator) return
118
- if (item.children?.length) {
119
- openSub = items().indexOf(item)
120
- return
121
- }
122
- item.onSelect?.()
123
- config.onSelect(item)
124
- config.onClose()
125
- }
126
-
127
- function onItemKeydown(e: KeyboardEvent, i: number) {
128
- const item = items()[i]
129
- // Open this item's submenu (ArrowRight, or ArrowLeft under rtl).
130
- const openThis = () => {
131
- if (item?.children?.length) {
132
- e.preventDefault()
133
- openSub = i
134
- }
135
- }
136
- // Collapse just THIS list back into its parent (opposite key under rtl).
137
- const collapseThis = () => {
138
- if (submenu()) {
139
- e.preventDefault()
140
- config.onCollapse?.()
141
- }
142
- }
143
- switch (e.key) {
144
- case 'ArrowDown': e.preventDefault(); move(1); break
145
- case 'ArrowUp': e.preventDefault(); move(-1); break
146
- case 'Home': e.preventDefault(); first(); break
147
- case 'End': e.preventDefault(); last(); break
148
- case 'ArrowRight': (rtl() ? collapseThis : openThis)(); break
149
- case 'ArrowLeft': (rtl() ? openThis : collapseThis)(); break
150
- case 'Enter':
151
- case ' ': e.preventDefault(); if (item) choose(item); break
152
- case 'Escape': e.preventDefault(); config.onClose(); break
153
- // Tab leaves the menu entirely (WAI-ARIA menu-button pattern): close the
154
- // whole tree rather than trapping focus inside a portalled panel.
155
- case 'Tab': config.onClose(); break
156
- }
157
- }
158
-
159
- function itemTabIndex(i: number): 0 | -1 {
160
- return active === i || (active === -1 && i === focusables[0]) ? 0 : -1
161
- }
162
-
163
- return {
164
- get active() { return active },
165
- get openSub() { return openSub },
166
- /** Enabled (focusable) item indices. */
167
- get focusables() { return focusables },
168
- isActive: (i: number) => i === active,
169
- isSubOpen: (i: number) => i === openSub,
170
- itemTabIndex,
171
- move,
172
- first,
173
- last,
174
- /** Highlight + DOM-focus item `i` (e.g. to refocus a submenu's opener). */
175
- focus,
176
- hover,
177
- choose,
178
- onItemKeydown,
179
- /** Spread onto the menu container element. */
180
- listProps: () => ({ role: 'menu' as const }),
181
- /** Spread onto the menuitem element at `i`. */
182
- itemProps: (i: number): MenuItemProps => {
183
- const it = items()[i]
184
- const hasKids = !!it?.children?.length
185
- return {
186
- role: 'menuitem',
187
- 'data-mi': i,
188
- tabindex: itemTabIndex(i),
189
- 'aria-haspopup': hasKids ? 'menu' : undefined,
190
- 'aria-expanded': hasKids ? openSub === i : undefined,
191
- 'aria-disabled': it?.disabled ? true : undefined,
192
- disabled: it?.disabled,
193
- onkeydown: (e: KeyboardEvent) => onItemKeydown(e, i),
194
- onpointerenter: () => hover(i),
195
- onclick: () => { if (it) choose(it) },
196
- }
197
- },
198
- }
199
- }
200
-
201
- export type Menu = ReturnType<typeof createMenu>
202
- export type { MenuItem }
1
+ /**
2
+ * createMenu - the HEADLESS core behind <SvMenuList> / <SvMenu>, in the same
3
+ * spirit as `createListbox`: a runes-based state machine for ONE menu level -
4
+ * roving focus over enabled items, submenu open/collapse, and the full WAI-ARIA
5
+ * menu keyboard contract (arrows, Home/End, Enter/Space, Escape, Tab, and
6
+ * rtl-aware ArrowLeft/Right) - with **prop-getters** you spread onto your own
7
+ * markup. No styles, no DOM assumptions.
8
+ *
9
+ * A menu tree is recursive, so you create one core per nesting level (exactly
10
+ * what the recursive <SvMenuList> does). The styled component is just one
11
+ * renderer over this core.
12
+ *
13
+ * ```svelte
14
+ * <script lang="ts">
15
+ * import { createMenu, type MenuItem } from '@svgrid/grid'
16
+ * let listEl = $state<HTMLElement | null>(null)
17
+ * const menu = createMenu({
18
+ * items: () => items,
19
+ * onSelect: (it) => run(it),
20
+ * onClose: () => close(),
21
+ * focusItem: (i) => queueMicrotask(() => listEl?.querySelector(`[data-mi="${i}"]`)?.focus()),
22
+ * })
23
+ * </script>
24
+ * <div bind:this={listEl} {...menu.listProps()}>
25
+ * {#each items as item, i}
26
+ * <button {...menu.itemProps(i)}>{item.label}</button>
27
+ * {/each}
28
+ * </div>
29
+ * ```
30
+ */
31
+ import type { MenuItem } from './menu-item'
32
+ import type { EditorDir } from './editor-contract'
33
+ import { enabledIndices, wrapMove } from './list-nav'
34
+
35
+ /** Reactive inputs are getters so the core tracks live prop changes (the same
36
+ * controlled pattern as `createListbox`). */
37
+ export type MenuConfig = {
38
+ items: () => ReadonlyArray<MenuItem>
39
+ /** Report the chosen leaf item (a non-parent menuitem). */
40
+ onSelect: (item: MenuItem) => void
41
+ /** Close the WHOLE menu tree - a leaf was chosen, or Escape / Tab / dismissal. */
42
+ onClose: () => void
43
+ /** This level is a submenu: enables ArrowLeft (or ArrowRight under rtl) to
44
+ * collapse back into the opener. The root level has nowhere to collapse. */
45
+ submenu?: () => boolean
46
+ /** Collapse just THIS submenu level and refocus the item that opened it. */
47
+ onCollapse?: () => void
48
+ /** Text direction; under `rtl` the open/collapse arrow keys swap. */
49
+ dir?: () => EditorDir | undefined
50
+ /** DOM focus hook, invoked ONLY on keyboard navigation (never on hover) so the
51
+ * core stays DOM-free. Receives the item index that should take focus. */
52
+ focusItem?: (index: number) => void
53
+ }
54
+
55
+ export type MenuItemProps = {
56
+ role: 'menuitem'
57
+ 'data-mi': number
58
+ tabindex: 0 | -1
59
+ 'aria-haspopup': 'menu' | undefined
60
+ 'aria-expanded': boolean | undefined
61
+ 'aria-disabled': true | undefined
62
+ disabled: boolean | undefined
63
+ onkeydown: (event: KeyboardEvent) => void
64
+ onpointerenter: () => void
65
+ onclick: () => void
66
+ }
67
+
68
+ let uid = 0
69
+
70
+ export function createMenu(config: MenuConfig) {
71
+ const items = () => config.items()
72
+ const submenu = () => config.submenu?.() ?? false
73
+ const rtl = () => (config.dir?.() ?? 'ltr') === 'rtl'
74
+
75
+ // -1 = nothing focused yet (the first enabled item is the tab stop).
76
+ let active = $state(-1)
77
+ let openSub = $state(-1)
78
+
79
+ // Real (non-separator, non-disabled) item indices, for roving focus.
80
+ const focusables = $derived(
81
+ enabledIndices(items().length, (i) => {
82
+ const it = items()[i]
83
+ return !it || it.separator === true || it.disabled === true
84
+ }),
85
+ )
86
+
87
+ /** Move focus to item `i` (keyboard): highlight it, close any open submenu at
88
+ * this level, and drive real DOM focus through the host's hook. */
89
+ function focus(i: number) {
90
+ active = i
91
+ openSub = -1
92
+ config.focusItem?.(i)
93
+ }
94
+ function move(delta: number) {
95
+ const next = wrapMove(focusables, active, delta)
96
+ if (next >= 0) focus(next)
97
+ }
98
+ function first() {
99
+ const f = focusables[0]
100
+ if (f != null) focus(f)
101
+ }
102
+ function last() {
103
+ const l = focusables.at(-1)
104
+ if (l != null) focus(l)
105
+ }
106
+
107
+ /** Hover highlight: sets active WITHOUT stealing DOM focus, and opens a parent
108
+ * item's submenu (mirrors the pointerenter behaviour). */
109
+ function hover(i: number) {
110
+ const it = items()[i]
111
+ if (!it || it.disabled || it.separator) return
112
+ active = i
113
+ openSub = it.children?.length ? i : -1
114
+ }
115
+
116
+ function choose(item: MenuItem) {
117
+ if (item.disabled || item.separator) return
118
+ if (item.children?.length) {
119
+ openSub = items().indexOf(item)
120
+ return
121
+ }
122
+ item.onSelect?.()
123
+ config.onSelect(item)
124
+ config.onClose()
125
+ }
126
+
127
+ function onItemKeydown(e: KeyboardEvent, i: number) {
128
+ const item = items()[i]
129
+ // Open this item's submenu (ArrowRight, or ArrowLeft under rtl).
130
+ const openThis = () => {
131
+ if (item?.children?.length) {
132
+ e.preventDefault()
133
+ openSub = i
134
+ }
135
+ }
136
+ // Collapse just THIS list back into its parent (opposite key under rtl).
137
+ const collapseThis = () => {
138
+ if (submenu()) {
139
+ e.preventDefault()
140
+ config.onCollapse?.()
141
+ }
142
+ }
143
+ switch (e.key) {
144
+ case 'ArrowDown': e.preventDefault(); move(1); break
145
+ case 'ArrowUp': e.preventDefault(); move(-1); break
146
+ case 'Home': e.preventDefault(); first(); break
147
+ case 'End': e.preventDefault(); last(); break
148
+ case 'ArrowRight': (rtl() ? collapseThis : openThis)(); break
149
+ case 'ArrowLeft': (rtl() ? openThis : collapseThis)(); break
150
+ case 'Enter':
151
+ case ' ': e.preventDefault(); if (item) choose(item); break
152
+ case 'Escape': e.preventDefault(); config.onClose(); break
153
+ // Tab leaves the menu entirely (WAI-ARIA menu-button pattern): close the
154
+ // whole tree rather than trapping focus inside a portalled panel.
155
+ case 'Tab': config.onClose(); break
156
+ }
157
+ }
158
+
159
+ function itemTabIndex(i: number): 0 | -1 {
160
+ return active === i || (active === -1 && i === focusables[0]) ? 0 : -1
161
+ }
162
+
163
+ return {
164
+ get active() { return active },
165
+ get openSub() { return openSub },
166
+ /** Enabled (focusable) item indices. */
167
+ get focusables() { return focusables },
168
+ isActive: (i: number) => i === active,
169
+ isSubOpen: (i: number) => i === openSub,
170
+ itemTabIndex,
171
+ move,
172
+ first,
173
+ last,
174
+ /** Highlight + DOM-focus item `i` (e.g. to refocus a submenu's opener). */
175
+ focus,
176
+ hover,
177
+ choose,
178
+ onItemKeydown,
179
+ /** Spread onto the menu container element. */
180
+ listProps: () => ({ role: 'menu' as const }),
181
+ /** Spread onto the menuitem element at `i`. */
182
+ itemProps: (i: number): MenuItemProps => {
183
+ const it = items()[i]
184
+ const hasKids = !!it?.children?.length
185
+ return {
186
+ role: 'menuitem',
187
+ 'data-mi': i,
188
+ tabindex: itemTabIndex(i),
189
+ 'aria-haspopup': hasKids ? 'menu' : undefined,
190
+ 'aria-expanded': hasKids ? openSub === i : undefined,
191
+ 'aria-disabled': it?.disabled ? true : undefined,
192
+ disabled: it?.disabled,
193
+ onkeydown: (e: KeyboardEvent) => onItemKeydown(e, i),
194
+ onpointerenter: () => hover(i),
195
+ onclick: () => { if (it) choose(it) },
196
+ }
197
+ },
198
+ }
199
+ }
200
+
201
+ export type Menu = ReturnType<typeof createMenu>
202
+ export type { MenuItem }