@svgrid/grid 2.2.25 → 2.2.27
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.
- package/README.md +199 -145
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +179 -179
- package/dist/GridMenus.svelte +734 -728
- package/dist/SvAutoComplete.svelte +165 -165
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +101 -101
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +275 -275
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +160 -160
- package/dist/SvDrawer.svelte +250 -250
- package/dist/SvDropDownList.svelte +374 -374
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.css +2723 -2719
- package/dist/SvGrid.svelte +3333 -3333
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +696 -696
- package/dist/SvGridSelect.svelte +259 -259
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvListBox.svelte +287 -287
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +281 -281
- package/dist/SvNumberInput.svelte +168 -168
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +131 -131
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +200 -200
- package/dist/SvSwitchButton.svelte +107 -107
- package/dist/SvTagsInput.svelte +112 -112
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +204 -204
- package/dist/SvTree.svelte +437 -437
- package/dist/SvTreeSelect.svelte +233 -233
- package/dist/a11y/dismissable.d.ts +11 -0
- package/dist/a11y/dismissable.js +21 -0
- package/dist/ai.js +3 -3
- package/dist/cdn/GridMenus-BzxEYNtW.js +421 -0
- package/dist/cdn/GridMenus-Dr-nGEXn.js +417 -0
- package/dist/cdn/{SvDateTimePicker-CYC7gRER.js → SvDateTimePicker-B1sDc9Pz.js} +451 -444
- package/dist/cdn/{SvDateTimePicker-DtQKd4Ns.js → SvDateTimePicker-Bh0FTs1E.js} +114 -107
- package/dist/cdn/{SvGridChart-BhnFdLd0.js → SvGridChart-BKQsO5fZ.js} +1 -1
- package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Bgk4hDs8.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-DSkHIQDd.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-Cmx9DHAS.js → SvGridChartPanel-DkCc2Z7b.js} +1 -1
- package/dist/cdn/{SvGridChartView-B40DTJSX.js → SvGridChartView-BrbVaELm.js} +1 -1
- package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-i9rWvSKg.js} +1 -1
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CGxxopQ4.js} +1 -1
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D08sVmAy.js} +1 -1
- package/dist/cdn/{src-DysW9qbk.js → src-Cw8d9U8j.js} +5753 -5753
- package/dist/cdn/{src-bYfr_7aR.js → src-cgs1wcUS.js} +8102 -8102
- package/dist/cdn/svgrid.js +9 -9
- package/dist/cdn/svgrid.svelte-external.js +9 -9
- package/dist/chart-export.js +8 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +30 -13
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +179 -179
- package/src/GridMenus.svelte +734 -728
- package/src/SvAutoComplete.svelte +165 -165
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +101 -101
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +275 -275
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +160 -160
- package/src/SvDrawer.svelte +250 -250
- package/src/SvDropDownList.svelte +374 -374
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.controller.svelte.ts +3239 -3239
- package/src/SvGrid.css +2723 -2719
- package/src/SvGrid.svelte +3333 -3333
- package/src/SvGrid.types.ts +1613 -1613
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +696 -696
- package/src/SvGridSelect.svelte +259 -259
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvListBox.svelte +287 -287
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +281 -281
- package/src/SvNumberInput.svelte +168 -168
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +131 -131
- package/src/SvPopover.svelte +197 -197
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +200 -200
- package/src/SvSwitchButton.svelte +107 -107
- package/src/SvTagsInput.svelte +112 -112
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +204 -204
- package/src/SvTree.svelte +437 -437
- package/src/SvTreeSelect.svelte +233 -233
- package/src/a11y/dismissable.test.ts +119 -95
- package/src/a11y/dismissable.ts +114 -94
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1391 -1391
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +895 -895
- package/src/builtin-editors.grid.test.ts +85 -85
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2296 -2296
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1111 -1111
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createMenu.svelte.ts +225 -225
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +319 -319
- package/src/editing.test.ts +859 -859
- package/src/editing.ts +675 -675
- package/src/editor-contract.ts +157 -157
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +598 -598
- package/src/filter-operators.ts +157 -157
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/index.ts +713 -712
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +172 -172
- package/src/menus.test.ts +700 -700
- package/src/menus.ts +652 -652
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +872 -872
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +600 -600
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +489 -489
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +524 -524
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-menu-scroll.test.ts +102 -0
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +215 -215
- package/src/toast-store.svelte.ts +246 -246
- package/src/toast-store.test.ts +147 -147
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +296 -296
- package/themes/ember.css +71 -71
- package/dist/cdn/GridMenus-GfCAHgkZ.js +0 -420
- package/dist/cdn/GridMenus-GiVNSeDU.js +0 -416
package/src/createGrid.svelte.ts
CHANGED
|
@@ -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
|
package/src/createGrid.test.ts
CHANGED
|
@@ -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
|
package/src/createMenu.svelte.ts
CHANGED
|
@@ -1,225 +1,225 @@
|
|
|
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
|
-
import { createTypeaheadBuffer, isTypeaheadKey } from './list-option'
|
|
35
|
-
|
|
36
|
-
/** Reactive inputs are getters so the core tracks live prop changes (the same
|
|
37
|
-
* controlled pattern as `createListbox`). */
|
|
38
|
-
export type MenuConfig = {
|
|
39
|
-
items: () => ReadonlyArray<MenuItem>
|
|
40
|
-
/** Report the chosen leaf item (a non-parent menuitem). */
|
|
41
|
-
onSelect: (item: MenuItem) => void
|
|
42
|
-
/** Close the WHOLE menu tree - a leaf was chosen, or Escape / Tab / dismissal. */
|
|
43
|
-
onClose: () => void
|
|
44
|
-
/** This level is a submenu: enables ArrowLeft (or ArrowRight under rtl) to
|
|
45
|
-
* collapse back into the opener. The root level has nowhere to collapse. */
|
|
46
|
-
submenu?: () => boolean
|
|
47
|
-
/** Collapse just THIS submenu level and refocus the item that opened it. */
|
|
48
|
-
onCollapse?: () => void
|
|
49
|
-
/** Text direction; under `rtl` the open/collapse arrow keys swap. */
|
|
50
|
-
dir?: () => EditorDir | undefined
|
|
51
|
-
/** DOM focus hook, invoked ONLY on keyboard navigation (never on hover) so the
|
|
52
|
-
* core stays DOM-free. Receives the item index that should take focus. */
|
|
53
|
-
focusItem?: (index: number) => void
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export type MenuItemProps = {
|
|
57
|
-
role: 'menuitem'
|
|
58
|
-
'data-mi': number
|
|
59
|
-
tabindex: 0 | -1
|
|
60
|
-
'aria-haspopup': 'menu' | undefined
|
|
61
|
-
'aria-expanded': boolean | undefined
|
|
62
|
-
'aria-disabled': true | undefined
|
|
63
|
-
disabled: boolean | undefined
|
|
64
|
-
onkeydown: (event: KeyboardEvent) => void
|
|
65
|
-
onpointerenter: () => void
|
|
66
|
-
onclick: () => void
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let uid = 0
|
|
70
|
-
|
|
71
|
-
export function createMenu(config: MenuConfig) {
|
|
72
|
-
const items = () => config.items()
|
|
73
|
-
const submenu = () => config.submenu?.() ?? false
|
|
74
|
-
const rtl = () => (config.dir?.() ?? 'ltr') === 'rtl'
|
|
75
|
-
|
|
76
|
-
// -1 = nothing focused yet (the first enabled item is the tab stop).
|
|
77
|
-
let active = $state(-1)
|
|
78
|
-
let openSub = $state(-1)
|
|
79
|
-
|
|
80
|
-
// Type-ahead: printable keys jump to the next item whose label starts with the
|
|
81
|
-
// accumulated buffer (WAI-ARIA menu behavior), wrapping past the active item.
|
|
82
|
-
const typeahead = createTypeaheadBuffer()
|
|
83
|
-
function typeaheadMatch(buffer: string): number {
|
|
84
|
-
const b = buffer.trim().toLowerCase()
|
|
85
|
-
if (!b) return -1
|
|
86
|
-
const its = items()
|
|
87
|
-
const n = its.length
|
|
88
|
-
for (let k = 1; k <= n; k++) {
|
|
89
|
-
const i = (active + k + n) % n
|
|
90
|
-
const it = its[i]
|
|
91
|
-
if (it && !it.disabled && !it.separator && (it.label ?? '').toLowerCase().startsWith(b)) return i
|
|
92
|
-
}
|
|
93
|
-
return -1
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Real (non-separator, non-disabled) item indices, for roving focus.
|
|
97
|
-
const focusables = $derived(
|
|
98
|
-
enabledIndices(items().length, (i) => {
|
|
99
|
-
const it = items()[i]
|
|
100
|
-
return !it || it.separator === true || it.disabled === true
|
|
101
|
-
}),
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
/** Move focus to item `i` (keyboard): highlight it, close any open submenu at
|
|
105
|
-
* this level, and drive real DOM focus through the host's hook. */
|
|
106
|
-
function focus(i: number) {
|
|
107
|
-
active = i
|
|
108
|
-
openSub = -1
|
|
109
|
-
config.focusItem?.(i)
|
|
110
|
-
}
|
|
111
|
-
function move(delta: number) {
|
|
112
|
-
const next = wrapMove(focusables, active, delta)
|
|
113
|
-
if (next >= 0) focus(next)
|
|
114
|
-
}
|
|
115
|
-
function first() {
|
|
116
|
-
const f = focusables[0]
|
|
117
|
-
if (f != null) focus(f)
|
|
118
|
-
}
|
|
119
|
-
function last() {
|
|
120
|
-
const l = focusables.at(-1)
|
|
121
|
-
if (l != null) focus(l)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Hover highlight: sets active WITHOUT stealing DOM focus, and opens a parent
|
|
125
|
-
* item's submenu (mirrors the pointerenter behaviour). */
|
|
126
|
-
function hover(i: number) {
|
|
127
|
-
const it = items()[i]
|
|
128
|
-
if (!it || it.disabled || it.separator) return
|
|
129
|
-
active = i
|
|
130
|
-
openSub = it.children?.length ? i : -1
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function choose(item: MenuItem) {
|
|
134
|
-
if (item.disabled || item.separator) return
|
|
135
|
-
if (item.children?.length) {
|
|
136
|
-
openSub = items().indexOf(item)
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
item.onSelect?.()
|
|
140
|
-
config.onSelect(item)
|
|
141
|
-
config.onClose()
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function onItemKeydown(e: KeyboardEvent, i: number) {
|
|
145
|
-
const item = items()[i]
|
|
146
|
-
// Open this item's submenu (ArrowRight, or ArrowLeft under rtl).
|
|
147
|
-
const openThis = () => {
|
|
148
|
-
if (item?.children?.length) {
|
|
149
|
-
e.preventDefault()
|
|
150
|
-
openSub = i
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
// Collapse just THIS list back into its parent (opposite key under rtl).
|
|
154
|
-
const collapseThis = () => {
|
|
155
|
-
if (submenu()) {
|
|
156
|
-
e.preventDefault()
|
|
157
|
-
config.onCollapse?.()
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
switch (e.key) {
|
|
161
|
-
case 'ArrowDown': e.preventDefault(); move(1); break
|
|
162
|
-
case 'ArrowUp': e.preventDefault(); move(-1); break
|
|
163
|
-
case 'Home': e.preventDefault(); first(); break
|
|
164
|
-
case 'End': e.preventDefault(); last(); break
|
|
165
|
-
case 'ArrowRight': (rtl() ? collapseThis : openThis)(); break
|
|
166
|
-
case 'ArrowLeft': (rtl() ? openThis : collapseThis)(); break
|
|
167
|
-
case 'Enter':
|
|
168
|
-
case ' ': e.preventDefault(); if (item) choose(item); break
|
|
169
|
-
case 'Escape': e.preventDefault(); config.onClose(); break
|
|
170
|
-
// Tab leaves the menu entirely (WAI-ARIA menu-button pattern): close the
|
|
171
|
-
// whole tree rather than trapping focus inside a portalled panel.
|
|
172
|
-
case 'Tab': config.onClose(); break
|
|
173
|
-
default:
|
|
174
|
-
// Type-ahead: a bare printable key jumps to the matching item.
|
|
175
|
-
if (isTypeaheadKey(e)) {
|
|
176
|
-
const idx = typeaheadMatch(typeahead.push(e.key))
|
|
177
|
-
if (idx >= 0) { e.preventDefault(); focus(idx) }
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function itemTabIndex(i: number): 0 | -1 {
|
|
183
|
-
return active === i || (active === -1 && i === focusables[0]) ? 0 : -1
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
get active() { return active },
|
|
188
|
-
get openSub() { return openSub },
|
|
189
|
-
/** Enabled (focusable) item indices. */
|
|
190
|
-
get focusables() { return focusables },
|
|
191
|
-
isActive: (i: number) => i === active,
|
|
192
|
-
isSubOpen: (i: number) => i === openSub,
|
|
193
|
-
itemTabIndex,
|
|
194
|
-
move,
|
|
195
|
-
first,
|
|
196
|
-
last,
|
|
197
|
-
/** Highlight + DOM-focus item `i` (e.g. to refocus a submenu's opener). */
|
|
198
|
-
focus,
|
|
199
|
-
hover,
|
|
200
|
-
choose,
|
|
201
|
-
onItemKeydown,
|
|
202
|
-
/** Spread onto the menu container element. */
|
|
203
|
-
listProps: () => ({ role: 'menu' as const }),
|
|
204
|
-
/** Spread onto the menuitem element at `i`. */
|
|
205
|
-
itemProps: (i: number): MenuItemProps => {
|
|
206
|
-
const it = items()[i]
|
|
207
|
-
const hasKids = !!it?.children?.length
|
|
208
|
-
return {
|
|
209
|
-
role: 'menuitem',
|
|
210
|
-
'data-mi': i,
|
|
211
|
-
tabindex: itemTabIndex(i),
|
|
212
|
-
'aria-haspopup': hasKids ? 'menu' : undefined,
|
|
213
|
-
'aria-expanded': hasKids ? openSub === i : undefined,
|
|
214
|
-
'aria-disabled': it?.disabled ? true : undefined,
|
|
215
|
-
disabled: it?.disabled,
|
|
216
|
-
onkeydown: (e: KeyboardEvent) => onItemKeydown(e, i),
|
|
217
|
-
onpointerenter: () => hover(i),
|
|
218
|
-
onclick: () => { if (it) choose(it) },
|
|
219
|
-
}
|
|
220
|
-
},
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export type Menu = ReturnType<typeof createMenu>
|
|
225
|
-
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
|
+
import { createTypeaheadBuffer, isTypeaheadKey } from './list-option'
|
|
35
|
+
|
|
36
|
+
/** Reactive inputs are getters so the core tracks live prop changes (the same
|
|
37
|
+
* controlled pattern as `createListbox`). */
|
|
38
|
+
export type MenuConfig = {
|
|
39
|
+
items: () => ReadonlyArray<MenuItem>
|
|
40
|
+
/** Report the chosen leaf item (a non-parent menuitem). */
|
|
41
|
+
onSelect: (item: MenuItem) => void
|
|
42
|
+
/** Close the WHOLE menu tree - a leaf was chosen, or Escape / Tab / dismissal. */
|
|
43
|
+
onClose: () => void
|
|
44
|
+
/** This level is a submenu: enables ArrowLeft (or ArrowRight under rtl) to
|
|
45
|
+
* collapse back into the opener. The root level has nowhere to collapse. */
|
|
46
|
+
submenu?: () => boolean
|
|
47
|
+
/** Collapse just THIS submenu level and refocus the item that opened it. */
|
|
48
|
+
onCollapse?: () => void
|
|
49
|
+
/** Text direction; under `rtl` the open/collapse arrow keys swap. */
|
|
50
|
+
dir?: () => EditorDir | undefined
|
|
51
|
+
/** DOM focus hook, invoked ONLY on keyboard navigation (never on hover) so the
|
|
52
|
+
* core stays DOM-free. Receives the item index that should take focus. */
|
|
53
|
+
focusItem?: (index: number) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type MenuItemProps = {
|
|
57
|
+
role: 'menuitem'
|
|
58
|
+
'data-mi': number
|
|
59
|
+
tabindex: 0 | -1
|
|
60
|
+
'aria-haspopup': 'menu' | undefined
|
|
61
|
+
'aria-expanded': boolean | undefined
|
|
62
|
+
'aria-disabled': true | undefined
|
|
63
|
+
disabled: boolean | undefined
|
|
64
|
+
onkeydown: (event: KeyboardEvent) => void
|
|
65
|
+
onpointerenter: () => void
|
|
66
|
+
onclick: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let uid = 0
|
|
70
|
+
|
|
71
|
+
export function createMenu(config: MenuConfig) {
|
|
72
|
+
const items = () => config.items()
|
|
73
|
+
const submenu = () => config.submenu?.() ?? false
|
|
74
|
+
const rtl = () => (config.dir?.() ?? 'ltr') === 'rtl'
|
|
75
|
+
|
|
76
|
+
// -1 = nothing focused yet (the first enabled item is the tab stop).
|
|
77
|
+
let active = $state(-1)
|
|
78
|
+
let openSub = $state(-1)
|
|
79
|
+
|
|
80
|
+
// Type-ahead: printable keys jump to the next item whose label starts with the
|
|
81
|
+
// accumulated buffer (WAI-ARIA menu behavior), wrapping past the active item.
|
|
82
|
+
const typeahead = createTypeaheadBuffer()
|
|
83
|
+
function typeaheadMatch(buffer: string): number {
|
|
84
|
+
const b = buffer.trim().toLowerCase()
|
|
85
|
+
if (!b) return -1
|
|
86
|
+
const its = items()
|
|
87
|
+
const n = its.length
|
|
88
|
+
for (let k = 1; k <= n; k++) {
|
|
89
|
+
const i = (active + k + n) % n
|
|
90
|
+
const it = its[i]
|
|
91
|
+
if (it && !it.disabled && !it.separator && (it.label ?? '').toLowerCase().startsWith(b)) return i
|
|
92
|
+
}
|
|
93
|
+
return -1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Real (non-separator, non-disabled) item indices, for roving focus.
|
|
97
|
+
const focusables = $derived(
|
|
98
|
+
enabledIndices(items().length, (i) => {
|
|
99
|
+
const it = items()[i]
|
|
100
|
+
return !it || it.separator === true || it.disabled === true
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
/** Move focus to item `i` (keyboard): highlight it, close any open submenu at
|
|
105
|
+
* this level, and drive real DOM focus through the host's hook. */
|
|
106
|
+
function focus(i: number) {
|
|
107
|
+
active = i
|
|
108
|
+
openSub = -1
|
|
109
|
+
config.focusItem?.(i)
|
|
110
|
+
}
|
|
111
|
+
function move(delta: number) {
|
|
112
|
+
const next = wrapMove(focusables, active, delta)
|
|
113
|
+
if (next >= 0) focus(next)
|
|
114
|
+
}
|
|
115
|
+
function first() {
|
|
116
|
+
const f = focusables[0]
|
|
117
|
+
if (f != null) focus(f)
|
|
118
|
+
}
|
|
119
|
+
function last() {
|
|
120
|
+
const l = focusables.at(-1)
|
|
121
|
+
if (l != null) focus(l)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Hover highlight: sets active WITHOUT stealing DOM focus, and opens a parent
|
|
125
|
+
* item's submenu (mirrors the pointerenter behaviour). */
|
|
126
|
+
function hover(i: number) {
|
|
127
|
+
const it = items()[i]
|
|
128
|
+
if (!it || it.disabled || it.separator) return
|
|
129
|
+
active = i
|
|
130
|
+
openSub = it.children?.length ? i : -1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function choose(item: MenuItem) {
|
|
134
|
+
if (item.disabled || item.separator) return
|
|
135
|
+
if (item.children?.length) {
|
|
136
|
+
openSub = items().indexOf(item)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
item.onSelect?.()
|
|
140
|
+
config.onSelect(item)
|
|
141
|
+
config.onClose()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function onItemKeydown(e: KeyboardEvent, i: number) {
|
|
145
|
+
const item = items()[i]
|
|
146
|
+
// Open this item's submenu (ArrowRight, or ArrowLeft under rtl).
|
|
147
|
+
const openThis = () => {
|
|
148
|
+
if (item?.children?.length) {
|
|
149
|
+
e.preventDefault()
|
|
150
|
+
openSub = i
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Collapse just THIS list back into its parent (opposite key under rtl).
|
|
154
|
+
const collapseThis = () => {
|
|
155
|
+
if (submenu()) {
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
config.onCollapse?.()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
switch (e.key) {
|
|
161
|
+
case 'ArrowDown': e.preventDefault(); move(1); break
|
|
162
|
+
case 'ArrowUp': e.preventDefault(); move(-1); break
|
|
163
|
+
case 'Home': e.preventDefault(); first(); break
|
|
164
|
+
case 'End': e.preventDefault(); last(); break
|
|
165
|
+
case 'ArrowRight': (rtl() ? collapseThis : openThis)(); break
|
|
166
|
+
case 'ArrowLeft': (rtl() ? openThis : collapseThis)(); break
|
|
167
|
+
case 'Enter':
|
|
168
|
+
case ' ': e.preventDefault(); if (item) choose(item); break
|
|
169
|
+
case 'Escape': e.preventDefault(); config.onClose(); break
|
|
170
|
+
// Tab leaves the menu entirely (WAI-ARIA menu-button pattern): close the
|
|
171
|
+
// whole tree rather than trapping focus inside a portalled panel.
|
|
172
|
+
case 'Tab': config.onClose(); break
|
|
173
|
+
default:
|
|
174
|
+
// Type-ahead: a bare printable key jumps to the matching item.
|
|
175
|
+
if (isTypeaheadKey(e)) {
|
|
176
|
+
const idx = typeaheadMatch(typeahead.push(e.key))
|
|
177
|
+
if (idx >= 0) { e.preventDefault(); focus(idx) }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function itemTabIndex(i: number): 0 | -1 {
|
|
183
|
+
return active === i || (active === -1 && i === focusables[0]) ? 0 : -1
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
get active() { return active },
|
|
188
|
+
get openSub() { return openSub },
|
|
189
|
+
/** Enabled (focusable) item indices. */
|
|
190
|
+
get focusables() { return focusables },
|
|
191
|
+
isActive: (i: number) => i === active,
|
|
192
|
+
isSubOpen: (i: number) => i === openSub,
|
|
193
|
+
itemTabIndex,
|
|
194
|
+
move,
|
|
195
|
+
first,
|
|
196
|
+
last,
|
|
197
|
+
/** Highlight + DOM-focus item `i` (e.g. to refocus a submenu's opener). */
|
|
198
|
+
focus,
|
|
199
|
+
hover,
|
|
200
|
+
choose,
|
|
201
|
+
onItemKeydown,
|
|
202
|
+
/** Spread onto the menu container element. */
|
|
203
|
+
listProps: () => ({ role: 'menu' as const }),
|
|
204
|
+
/** Spread onto the menuitem element at `i`. */
|
|
205
|
+
itemProps: (i: number): MenuItemProps => {
|
|
206
|
+
const it = items()[i]
|
|
207
|
+
const hasKids = !!it?.children?.length
|
|
208
|
+
return {
|
|
209
|
+
role: 'menuitem',
|
|
210
|
+
'data-mi': i,
|
|
211
|
+
tabindex: itemTabIndex(i),
|
|
212
|
+
'aria-haspopup': hasKids ? 'menu' : undefined,
|
|
213
|
+
'aria-expanded': hasKids ? openSub === i : undefined,
|
|
214
|
+
'aria-disabled': it?.disabled ? true : undefined,
|
|
215
|
+
disabled: it?.disabled,
|
|
216
|
+
onkeydown: (e: KeyboardEvent) => onItemKeydown(e, i),
|
|
217
|
+
onpointerenter: () => hover(i),
|
|
218
|
+
onclick: () => { if (it) choose(it) },
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export type Menu = ReturnType<typeof createMenu>
|
|
225
|
+
export type { MenuItem }
|