@zag-js/combobox 0.46.0 → 0.48.0

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.
@@ -1,10 +1,17 @@
1
- import { getEventKey, getNativeEvent, isContextMenuEvent, isLeftClick, type EventKeyMap } from "@zag-js/dom-event"
2
- import { ariaAttr, dataAttr, raf } from "@zag-js/dom-query"
1
+ import {
2
+ clickIfLink,
3
+ getEventKey,
4
+ getNativeEvent,
5
+ isContextMenuEvent,
6
+ isLeftClick,
7
+ type EventKeyMap,
8
+ } from "@zag-js/dom-event"
9
+ import { ariaAttr, dataAttr, isDownloadingEvent, isOpeningInNewTab, raf } from "@zag-js/dom-query"
3
10
  import { getPlacementStyles } from "@zag-js/popper"
4
11
  import type { NormalizeProps, PropTypes } from "@zag-js/types"
5
12
  import { parts } from "./combobox.anatomy"
6
13
  import { dom } from "./combobox.dom"
7
- import type { CollectionItem, ItemProps, MachineApi, Send, State } from "./combobox.types"
14
+ import type { CollectionItem, ItemProps, ItemState, MachineApi, Send, State } from "./combobox.types"
8
15
 
9
16
  export function connect<T extends PropTypes, V extends CollectionItem>(
10
17
  state: State,
@@ -14,36 +21,37 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
14
21
  const translations = state.context.translations
15
22
  const collection = state.context.collection
16
23
 
17
- const isDisabled = state.context.disabled
18
- const isInteractive = state.context.isInteractive
19
- const isInvalid = state.context.invalid
20
- const isReadOnly = state.context.readOnly
24
+ const disabled = state.context.disabled
25
+ const interactive = state.context.isInteractive
26
+ const invalid = state.context.invalid
27
+ const readOnly = state.context.readOnly
21
28
 
22
- const isOpen = state.hasTag("open")
23
- const isFocused = state.hasTag("focused")
29
+ const open = state.hasTag("open")
30
+ const focused = state.hasTag("focused")
31
+ const isDialogPopup = state.context.popup === "dialog"
24
32
 
25
33
  const popperStyles = getPlacementStyles({
26
34
  ...state.context.positioning,
27
35
  placement: state.context.currentPlacement,
28
36
  })
29
37
 
30
- function getItemState(props: ItemProps) {
38
+ function getItemState(props: ItemProps): ItemState {
31
39
  const { item } = props
32
40
  const disabled = collection.isItemDisabled(item)
33
41
  const value = collection.itemToValue(item)
34
42
  return {
35
43
  value,
36
- isDisabled: Boolean(disabled || isDisabled),
37
- isHighlighted: state.context.highlightedValue === value,
38
- isSelected: state.context.value.includes(value),
44
+ disabled: Boolean(disabled || disabled),
45
+ highlighted: state.context.highlightedValue === value,
46
+ selected: state.context.value.includes(value),
39
47
  }
40
48
  }
41
49
 
42
50
  return {
43
- isFocused,
44
- isOpen,
51
+ focused,
52
+ open,
45
53
  inputValue: state.context.inputValue,
46
- isInputValueEmpty: state.context.isInputValueEmpty,
54
+ inputEmpty: state.context.isInputValueEmpty,
47
55
  highlightedValue: state.context.highlightedValue,
48
56
  highlightedItem: state.context.highlightedItem,
49
57
  value: state.context.value,
@@ -79,19 +87,16 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
79
87
  focus() {
80
88
  dom.getInputEl(state.context)?.focus()
81
89
  },
82
- open() {
83
- send("OPEN")
90
+ setOpen(_open) {
91
+ if (_open === open) return
92
+ send(_open ? "OPEN" : "CLOSE")
84
93
  },
85
- close() {
86
- send("CLOSE")
87
- },
88
-
89
94
  rootProps: normalize.element({
90
95
  ...parts.root.attrs,
91
96
  dir: state.context.dir,
92
97
  id: dom.getRootId(state.context),
93
- "data-invalid": dataAttr(isInvalid),
94
- "data-readonly": dataAttr(isReadOnly),
98
+ "data-invalid": dataAttr(invalid),
99
+ "data-readonly": dataAttr(readOnly),
95
100
  }),
96
101
 
97
102
  labelProps: normalize.label({
@@ -99,20 +104,25 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
99
104
  dir: state.context.dir,
100
105
  htmlFor: dom.getInputId(state.context),
101
106
  id: dom.getLabelId(state.context),
102
- "data-readonly": dataAttr(isReadOnly),
103
- "data-disabled": dataAttr(isDisabled),
104
- "data-invalid": dataAttr(isInvalid),
105
- "data-focus": dataAttr(isFocused),
107
+ "data-readonly": dataAttr(readOnly),
108
+ "data-disabled": dataAttr(disabled),
109
+ "data-invalid": dataAttr(invalid),
110
+ "data-focus": dataAttr(focused),
111
+ onClick(event) {
112
+ if (!isDialogPopup) return
113
+ event.preventDefault()
114
+ dom.getTriggerEl(state.context)?.focus({ preventScroll: true })
115
+ },
106
116
  }),
107
117
 
108
118
  controlProps: normalize.element({
109
119
  ...parts.control.attrs,
110
120
  dir: state.context.dir,
111
121
  id: dom.getControlId(state.context),
112
- "data-state": isOpen ? "open" : "closed",
113
- "data-focus": dataAttr(isFocused),
114
- "data-disabled": dataAttr(isDisabled),
115
- "data-invalid": dataAttr(isInvalid),
122
+ "data-state": open ? "open" : "closed",
123
+ "data-focus": dataAttr(focused),
124
+ "data-disabled": dataAttr(disabled),
125
+ "data-invalid": dataAttr(invalid),
116
126
  }),
117
127
 
118
128
  positionerProps: normalize.element({
@@ -125,97 +135,93 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
125
135
  inputProps: normalize.input({
126
136
  ...parts.input.attrs,
127
137
  dir: state.context.dir,
128
- "aria-invalid": ariaAttr(isInvalid),
129
- "data-invalid": dataAttr(isInvalid),
138
+ "aria-invalid": ariaAttr(invalid),
139
+ "data-invalid": dataAttr(invalid),
130
140
  name: state.context.name,
131
141
  form: state.context.form,
132
- disabled: isDisabled,
142
+ disabled: disabled,
133
143
  autoFocus: state.context.autoFocus,
134
144
  autoComplete: "off",
135
145
  autoCorrect: "off",
136
146
  autoCapitalize: "none",
137
147
  spellCheck: "false",
138
- readOnly: isReadOnly,
148
+ readOnly: readOnly,
139
149
  placeholder: state.context.placeholder,
140
150
  id: dom.getInputId(state.context),
141
151
  type: "text",
142
152
  role: "combobox",
143
153
  defaultValue: state.context.inputValue,
144
154
  "aria-autocomplete": state.context.autoComplete ? "both" : "list",
145
- "aria-controls": isOpen ? dom.getContentId(state.context) : undefined,
146
- "aria-expanded": isOpen,
147
- "data-state": isOpen ? "open" : "closed",
155
+ "aria-controls": isDialogPopup ? dom.getListId(state.context) : dom.getContentId(state.context),
156
+ "aria-expanded": open,
157
+ "data-state": open ? "open" : "closed",
148
158
  "aria-activedescendant": state.context.highlightedValue
149
159
  ? dom.getItemId(state.context, state.context.highlightedValue)
150
160
  : undefined,
151
- onCompositionStart() {
152
- send("INPUT.COMPOSITION_START")
153
- },
154
- onCompositionEnd() {
155
- raf(() => {
156
- send("INPUT.COMPOSITION_END")
157
- })
158
- },
159
161
  onClick() {
160
- if (!isInteractive) return
162
+ if (!state.context.openOnClick) return
163
+ if (!interactive) return
161
164
  send("INPUT.CLICK")
162
165
  },
163
166
  onFocus() {
164
- if (isDisabled) return
167
+ if (disabled) return
165
168
  send("INPUT.FOCUS")
166
169
  },
167
170
  onBlur() {
168
- if (isDisabled) return
171
+ if (disabled) return
169
172
  send("INPUT.BLUR")
170
173
  },
171
174
  onChange(event) {
172
175
  send({ type: "INPUT.CHANGE", value: event.currentTarget.value })
173
176
  },
174
177
  onKeyDown(event) {
175
- if (!isInteractive) return
178
+ if (event.defaultPrevented) return
179
+ if (!interactive) return
176
180
 
177
181
  const evt = getNativeEvent(event)
178
182
  if (evt.ctrlKey || evt.shiftKey || evt.isComposing) return
179
183
 
184
+ const openOnKeyPress = state.context.openOnKeyPress
185
+ const isModifierKey = event.ctrlKey || event.metaKey || event.shiftKey
186
+ const keypress = true
187
+
180
188
  const keymap: EventKeyMap = {
181
189
  ArrowDown(event) {
182
- send({ type: event.altKey ? "INPUT.ARROW_DOWN+ALT" : "INPUT.ARROW_DOWN" })
190
+ if (!openOnKeyPress && !open) return
191
+ send({ type: event.altKey ? "OPEN" : "INPUT.ARROW_DOWN", keypress })
183
192
  event.preventDefault()
184
- event.stopPropagation()
185
193
  },
186
194
  ArrowUp() {
187
- send(event.altKey ? "INPUT.ARROW_UP+ALT" : "INPUT.ARROW_UP")
195
+ if (!openOnKeyPress && !open) return
196
+ send({ type: event.altKey ? "CLOSE" : "INPUT.ARROW_UP", keypress })
188
197
  event.preventDefault()
189
- event.stopPropagation()
190
198
  },
191
199
  Home(event) {
192
- const isModified = event.ctrlKey || event.metaKey || event.shiftKey
193
- if (isModified) return
194
- send("INPUT.HOME")
195
- if (isOpen) {
200
+ if (isModifierKey) return
201
+ send({ type: "INPUT.HOME", keypress })
202
+ if (open) {
196
203
  event.preventDefault()
197
- event.stopPropagation()
198
204
  }
199
205
  },
200
206
  End(event) {
201
- const isModified = event.ctrlKey || event.metaKey || event.shiftKey
202
- if (isModified) return
203
- send("INPUT.END")
204
- if (isOpen) {
207
+ if (isModifierKey) return
208
+ send({ type: "INPUT.END", keypress })
209
+ if (open) {
205
210
  event.preventDefault()
206
- event.stopPropagation()
207
211
  }
208
212
  },
209
- Enter() {
210
- if (state.context.composing) return
211
- send("INPUT.ENTER")
212
- event.preventDefault()
213
- event.stopPropagation()
213
+ Enter(event) {
214
+ if (evt.isComposing) return
215
+ send({ type: "INPUT.ENTER", keypress })
216
+ if (open) {
217
+ event.preventDefault()
218
+ }
219
+ const itemEl = dom.getHighlightedItemEl(state.context)
220
+ clickIfLink(itemEl)
214
221
  },
215
222
  Escape() {
216
- send("INPUT.ESCAPE")
223
+ send({ type: "INPUT.ESCAPE", keypress })
217
224
  event.preventDefault()
218
- // don't stop propagation. this is handled by the dismissable layer
219
225
  },
220
226
  }
221
227
 
@@ -229,59 +235,97 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
229
235
  ...parts.trigger.attrs,
230
236
  dir: state.context.dir,
231
237
  id: dom.getTriggerId(state.context),
232
- "aria-haspopup": "listbox",
238
+ "aria-haspopup": isDialogPopup ? "dialog" : "listbox",
233
239
  type: "button",
234
- tabIndex: -1,
240
+ tabIndex: isDialogPopup ? 0 : -1,
235
241
  "aria-label": translations.triggerLabel,
236
- "aria-expanded": isOpen,
237
- "data-state": isOpen ? "open" : "closed",
238
- "aria-controls": isOpen ? dom.getContentId(state.context) : undefined,
239
- disabled: isDisabled,
240
- "data-readonly": dataAttr(isReadOnly),
241
- "data-disabled": dataAttr(isDisabled),
242
- onPointerDown(event) {
242
+ "aria-expanded": open,
243
+ "data-state": open ? "open" : "closed",
244
+ "aria-controls": open ? dom.getContentId(state.context) : undefined,
245
+ disabled: disabled,
246
+ "data-readonly": dataAttr(readOnly),
247
+ "data-disabled": dataAttr(disabled),
248
+ onClick(event) {
243
249
  const evt = getNativeEvent(event)
244
- if (!isInteractive || !isLeftClick(evt) || evt.pointerType === "touch") return
250
+ if (!interactive) return
251
+ if (!isLeftClick(evt)) return
245
252
  send("TRIGGER.CLICK")
253
+ },
254
+ onPointerDown(event) {
255
+ if (!interactive) return
256
+ if (event.pointerType === "touch") return
246
257
  event.preventDefault()
258
+ queueMicrotask(() => {
259
+ dom.getInputEl(state.context)?.focus({ preventScroll: true })
260
+ })
247
261
  },
248
- onPointerUp(event) {
249
- if (event.pointerType !== "touch") return
250
- send("TRIGGER.CLICK")
262
+ onKeyDown(event) {
263
+ if (event.defaultPrevented) return
264
+ if (!isDialogPopup) return
265
+
266
+ const keyMap: EventKeyMap = {
267
+ ArrowDown() {
268
+ send("INPUT.FOCUS")
269
+ send("INPUT.ARROW_DOWN")
270
+ raf(() => {
271
+ dom.getInputEl(state.context)?.focus({ preventScroll: true })
272
+ })
273
+ },
274
+ ArrowUp() {
275
+ send("INPUT.FOCUS")
276
+ send("INPUT.ARROW_UP")
277
+ raf(() => {
278
+ dom.getInputEl(state.context)?.focus({ preventScroll: true })
279
+ })
280
+ },
281
+ }
282
+
283
+ const key = getEventKey(event, state.context)
284
+ const exec = keyMap[key]
285
+
286
+ if (exec) {
287
+ exec(event)
288
+ event.preventDefault()
289
+ }
251
290
  },
252
- style: { outline: 0 },
253
291
  }),
254
292
 
255
293
  contentProps: normalize.element({
256
294
  ...parts.content.attrs,
257
295
  dir: state.context.dir,
258
296
  id: dom.getContentId(state.context),
259
- role: "listbox",
297
+ role: isDialogPopup ? "dialog" : "listbox",
260
298
  tabIndex: -1,
261
- hidden: !isOpen,
262
- "data-state": isOpen ? "open" : "closed",
299
+ hidden: !open,
300
+ "data-state": open ? "open" : "closed",
263
301
  "aria-labelledby": dom.getLabelId(state.context),
264
- "aria-multiselectable": state.context.multiple ? true : undefined,
302
+ "aria-multiselectable": state.context.multiple && !isDialogPopup ? true : undefined,
265
303
  onPointerDown(event) {
266
304
  // prevent options or elements within listbox from taking focus
267
305
  event.preventDefault()
268
306
  },
269
307
  }),
270
308
 
309
+ // only used when triggerOnly: true
310
+ listProps: normalize.element({
311
+ id: dom.getListId(state.context),
312
+ role: isDialogPopup ? "listbox" : undefined,
313
+ "aria-multiselectable": isDialogPopup && state.context.multiple ? true : undefined,
314
+ }),
315
+
271
316
  clearTriggerProps: normalize.button({
272
317
  ...parts.clearTrigger.attrs,
273
318
  dir: state.context.dir,
274
319
  id: dom.getClearTriggerId(state.context),
275
320
  type: "button",
276
321
  tabIndex: -1,
277
- disabled: isDisabled,
322
+ disabled: disabled,
278
323
  "aria-label": translations.clearTriggerLabel,
324
+ "aria-controls": dom.getInputId(state.context),
279
325
  hidden: !state.context.value.length,
280
- onPointerDown(event) {
281
- const evt = getNativeEvent(event)
282
- if (!isInteractive || !isLeftClick(evt)) return
326
+ onClick() {
327
+ if (!interactive) return
283
328
  send({ type: "VALUE.CLEAR", src: "clear-trigger" })
284
- event.preventDefault()
285
329
  },
286
330
  }),
287
331
 
@@ -297,22 +341,28 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
297
341
  id: dom.getItemId(state.context, value),
298
342
  role: "option",
299
343
  tabIndex: -1,
300
- "data-highlighted": dataAttr(itemState.isHighlighted),
301
- "data-state": itemState.isSelected ? "checked" : "unchecked",
302
- "aria-selected": itemState.isHighlighted,
303
- "aria-disabled": itemState.isDisabled,
304
- "data-disabled": dataAttr(itemState.isDisabled),
344
+ "data-highlighted": dataAttr(itemState.highlighted),
345
+ "data-state": itemState.selected ? "checked" : "unchecked",
346
+ "aria-selected": itemState.highlighted,
347
+ "aria-disabled": itemState.disabled,
348
+ "data-disabled": dataAttr(itemState.disabled),
305
349
  "data-value": itemState.value,
306
350
  onPointerMove() {
307
- if (itemState.isDisabled) return
308
- send({ type: "ITEM.POINTER_OVER", value })
351
+ if (itemState.disabled) return
352
+ send({ type: "ITEM.POINTER_MOVE", value })
309
353
  },
310
354
  onPointerLeave() {
311
- if (itemState.isDisabled) return
355
+ if (props.persistFocus) return
356
+ if (itemState.disabled) return
357
+ const mouseMoved = state.previousEvent.type === "ITEM.POINTER_MOVE"
358
+ if (!mouseMoved) return
312
359
  send({ type: "ITEM.POINTER_LEAVE", value })
313
360
  },
314
361
  onPointerUp(event) {
315
- if (itemState.isDisabled || isContextMenuEvent(event)) return
362
+ if (isDownloadingEvent(event)) return
363
+ if (isOpeningInNewTab(event)) return
364
+ if (isContextMenuEvent(event)) return
365
+ if (itemState.disabled) return
316
366
  send({ type: "ITEM.CLICK", src: "pointerup", value })
317
367
  },
318
368
  onTouchEnd(event) {
@@ -328,8 +378,8 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
328
378
  return normalize.element({
329
379
  ...parts.itemText.attrs,
330
380
  dir: state.context.dir,
331
- "data-disabled": dataAttr(itemState.isDisabled),
332
- "data-highlighted": dataAttr(itemState.isHighlighted),
381
+ "data-disabled": dataAttr(itemState.disabled),
382
+ "data-highlighted": dataAttr(itemState.highlighted),
333
383
  })
334
384
  },
335
385
  getItemIndicatorProps(props) {
@@ -338,8 +388,8 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
338
388
  "aria-hidden": true,
339
389
  ...parts.itemIndicator.attrs,
340
390
  dir: state.context.dir,
341
- "data-state": itemState.isSelected ? "checked" : "unchecked",
342
- hidden: !itemState.isSelected,
391
+ "data-state": itemState.selected ? "checked" : "unchecked",
392
+ hidden: !itemState.selected,
343
393
  })
344
394
  },
345
395
 
@@ -7,6 +7,7 @@ export const dom = createScope({
7
7
  getControlId: (ctx: Ctx) => ctx.ids?.control ?? `combobox:${ctx.id}:control`,
8
8
  getInputId: (ctx: Ctx) => ctx.ids?.input ?? `combobox:${ctx.id}:input`,
9
9
  getContentId: (ctx: Ctx) => ctx.ids?.content ?? `combobox:${ctx.id}:content`,
10
+ getListId: (ctx: Ctx) => `combobox:${ctx.id}:listbox`,
10
11
  getPositionerId: (ctx: Ctx) => ctx.ids?.positioner ?? `combobox:${ctx.id}:popper`,
11
12
  getTriggerId: (ctx: Ctx) => ctx.ids?.trigger ?? `combobox:${ctx.id}:toggle-btn`,
12
13
  getClearTriggerId: (ctx: Ctx) => ctx.ids?.clearTrigger ?? `combobox:${ctx.id}:clear-btn`,
@@ -16,6 +17,7 @@ export const dom = createScope({
16
17
  getItemId: (ctx: Ctx, id: string) => `combobox:${ctx.id}:option:${id}`,
17
18
 
18
19
  getContentEl: (ctx: Ctx) => dom.getById(ctx, dom.getContentId(ctx)),
20
+ getListEl: (ctx: Ctx) => dom.getById(ctx, dom.getListId(ctx)),
19
21
  getInputEl: (ctx: Ctx) => dom.getById<HTMLInputElement>(ctx, dom.getInputId(ctx)),
20
22
  getPositionerEl: (ctx: Ctx) => dom.getById(ctx, dom.getPositionerId(ctx)),
21
23
  getControlEl: (ctx: Ctx) => dom.getById(ctx, dom.getControlId(ctx)),