/
listbox.ts
177 lines (146 loc) · 5.49 KB
/
listbox.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import { derived, writable } from "svelte/store";
import { reflectAriaActivedescendent } from "./internal/aria-activedescendent";
import { reflectAriaControls, type Controllable } from './internal/aria-controls';
import { reflectAriaDisabled } from "./internal/aria-disabled";
import { defaultExpanded, focusOnClose, focusOnExpanded, reflectAriaExpanded, type Expandable } from "./internal/aria-expanded";
import { reflectAriaLabel, type Labelable } from "./internal/aria-label";
import { defaultSelected, type Selectable } from "./internal/aria-selected";
import { applyBehaviors } from "./internal/behavior";
import { keyCharacter } from "./internal/key-character";
import { keyEscape } from "./internal/key-escape";
import { keyHomeEnd } from "./internal/key-home-end";
import { keyUpDown } from "./internal/key-up-down";
import { keySpaceEnter } from "./internal/key-space-enter";
import { keyTab } from "./internal/key-tab";
import { activate, active, defaultList, firstActive, getFocuser, getSearch, getUpdater, lastActive, nextActive, onDestroy, onSelect, previousActive, removeItem, type ItemOptions, type List } from "./internal/list";
import { ensureID } from "./internal/new-id";
import { noop } from "./internal/noop";
import { onClick } from "./internal/on-click";
import { onClickOutside } from "./internal/on-click-outside";
import { onKeydown } from "./internal/on-keydown";
import { onPointerMoveChild, onPointerOut } from "./internal/on-pointer-move";
import { setHasPopup } from "./internal/set-has-popup";
import { setRole } from "./internal/set-role";
import { setTabIndex } from "./internal/set-tab-index";
import { setType } from "./internal/set-type";
import { getPrefix } from "./internal/utils";
// TODO: add "value" selector, to pick text value off list item objects
export interface Listbox extends Labelable, Expandable, Controllable, List, Selectable {
button?: HTMLElement
}
export function createListbox(init?: Partial<Listbox>) {
// prefix for generating unique IDs
const prefix = getPrefix('listbox')
// internal state for component
let state: Listbox = {
...defaultList(),
...defaultExpanded,
...defaultSelected,
...init,
}
// wrap with store for reactivity
const store = writable(state)
// update state and notify store of changes for reactivity
const set = (part: Partial<Listbox>) => store.set(state = { ...state, ...part })
// open the menu and set first item active
const open = () => set({ expanded: true, opened: true, active: state.items.findIndex(x => x.value === state.selected) })
// close the menu
const close = () => set({ expanded: false })
// toggle open / closed state
const toggle = () => state.expanded ? close() : open()
// set focused (active) item only if changed
const focus = (active: number) => state.active !== active && set({ active })
// set focus (active) to first
const first = () => focus(firstActive(state))
// set focus (active) to previous
const previous = () => focus(previousActive(state))
// set focus (active) to next
const next = () => focus(nextActive(state))
// set focus (active) to last
const last = () => focus(lastActive(state))
// clear focus
const none = () => focus(-1)
const search = getSearch(() => state, focus)
// set the focus based on the HTMLElement passed which will be a menuitem element or null
const focusNode = getFocuser(() => state, focus)
const remove = (node: HTMLElement) => set(removeItem(state, node))
const select = () => set(onSelect(state, state.button))
// menubutton
function button(node: HTMLElement) {
ensureID(node, prefix)
set({ button: node })
const destroy = applyBehaviors(node, [
setType('button'),
setRole('button'),
setHasPopup(),
setTabIndex(0),
reflectAriaLabel(store),
reflectAriaExpanded(store),
reflectAriaControls(store),
onClick(toggle),
onKeydown(
keySpaceEnter(toggle),
keyUpDown(toggle, toggle),
),
focusOnClose(store),
])
return {
destroy,
}
}
function items(node: HTMLElement) {
ensureID(node, prefix)
set({ controls: node.id })
const destroy = applyBehaviors(node, [
setRole('listbox'),
setTabIndex(0),
onClickOutside(close),
onClick(activate('[role="option"]', focusNode, select)),
onPointerMoveChild('[role="option"]', focusNode),
onPointerOut(none),
onKeydown(
keySpaceEnter(select),
keyEscape(close),
keyHomeEnd(first, last),
keyUpDown(previous, next),
keyTab(noop),
keyCharacter(search),
),
focusOnExpanded(store),
reflectAriaActivedescendent(store),
])
return {
destroy,
}
}
// TODO: allow "any" type of value, as long as a text extractor is supplied (default function is treat as a string)
// NOTE: text value is required for searchability
function item(node: HTMLElement, options?: ItemOptions) {
ensureID(node, prefix)
const update = getUpdater(node, () => state, set)
update(options)
const destroy = applyBehaviors(node, [
setTabIndex(-1),
setRole('option'),
reflectAriaDisabled(store),
onDestroy(remove),
])
return {
update,
destroy,
}
}
// expose a subset of our state, derive the selected value
const { subscribe } = derived(store, $state => {
const { expanded, selected } = $state
return { expanded, selected, active: active($state) }
})
return {
subscribe,
button,
items,
item,
open,
close,
}
}