@zag-js/combobox 0.49.0 → 0.50.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.
@@ -6,7 +6,7 @@ import {
6
6
  isLeftClick,
7
7
  type EventKeyMap,
8
8
  } from "@zag-js/dom-event"
9
- import { ariaAttr, dataAttr, isDownloadingEvent, isOpeningInNewTab, raf } from "@zag-js/dom-query"
9
+ import { ariaAttr, dataAttr, isDownloadingEvent, isOpeningInNewTab } from "@zag-js/dom-query"
10
10
  import { getPlacementStyles } from "@zag-js/popper"
11
11
  import type { NormalizeProps, PropTypes } from "@zag-js/types"
12
12
  import { parts } from "./combobox.anatomy"
@@ -28,7 +28,8 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
28
28
 
29
29
  const open = state.hasTag("open")
30
30
  const focused = state.hasTag("focused")
31
- const isDialogPopup = state.context.popup === "dialog"
31
+ const composite = state.context.composite
32
+ const highlightedValue = state.context.highlightedValue
32
33
 
33
34
  const popperStyles = getPlacementStyles({
34
35
  ...state.context.positioning,
@@ -42,7 +43,7 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
42
43
  return {
43
44
  value,
44
45
  disabled: Boolean(disabled || disabled),
45
- highlighted: state.context.highlightedValue === value,
46
+ highlighted: highlightedValue === value,
46
47
  selected: state.context.value.includes(value),
47
48
  }
48
49
  }
@@ -51,8 +52,7 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
51
52
  focused,
52
53
  open,
53
54
  inputValue: state.context.inputValue,
54
- inputEmpty: state.context.isInputValueEmpty,
55
- highlightedValue: state.context.highlightedValue,
55
+ highlightedValue,
56
56
  highlightedItem: state.context.highlightedItem,
57
57
  value: state.context.value,
58
58
  valueAsString: state.context.valueAsString,
@@ -65,7 +65,7 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
65
65
  setCollection(collection) {
66
66
  send({ type: "COLLECTION.SET", value: collection })
67
67
  },
68
- highlightValue(value) {
68
+ setHighlightValue(value) {
69
69
  send({ type: "HIGHLIGHTED_VALUE.SET", value })
70
70
  },
71
71
  selectValue(value) {
@@ -87,9 +87,9 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
87
87
  focus() {
88
88
  dom.getInputEl(state.context)?.focus()
89
89
  },
90
- setOpen(_open) {
91
- if (_open === open) return
92
- send(_open ? "OPEN" : "CLOSE")
90
+ setOpen(nextOpen) {
91
+ if (nextOpen === open) return
92
+ send(nextOpen ? "OPEN" : "CLOSE")
93
93
  },
94
94
  rootProps: normalize.element({
95
95
  ...parts.root.attrs,
@@ -109,7 +109,7 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
109
109
  "data-invalid": dataAttr(invalid),
110
110
  "data-focus": dataAttr(focused),
111
111
  onClick(event) {
112
- if (!isDialogPopup) return
112
+ if (composite) return
113
113
  event.preventDefault()
114
114
  dom.getTriggerEl(state.context)?.focus({ preventScroll: true })
115
115
  },
@@ -152,13 +152,12 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
152
152
  role: "combobox",
153
153
  defaultValue: state.context.inputValue,
154
154
  "aria-autocomplete": state.context.autoComplete ? "both" : "list",
155
- "aria-controls": isDialogPopup ? dom.getListId(state.context) : dom.getContentId(state.context),
155
+ "aria-controls": dom.getContentId(state.context),
156
156
  "aria-expanded": open,
157
157
  "data-state": open ? "open" : "closed",
158
- "aria-activedescendant": state.context.highlightedValue
159
- ? dom.getItemId(state.context, state.context.highlightedValue)
160
- : undefined,
161
- onClick() {
158
+ "aria-activedescendant": highlightedValue ? dom.getItemId(state.context, highlightedValue) : undefined,
159
+ onClick(event) {
160
+ if (event.defaultPrevented) return
162
161
  if (!state.context.openOnClick) return
163
162
  if (!interactive) return
164
163
  send("INPUT.CLICK")
@@ -231,86 +230,85 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
231
230
  },
232
231
  }),
233
232
 
234
- triggerProps: normalize.button({
235
- ...parts.trigger.attrs,
236
- dir: state.context.dir,
237
- id: dom.getTriggerId(state.context),
238
- "aria-haspopup": isDialogPopup ? "dialog" : "listbox",
239
- type: "button",
240
- tabIndex: isDialogPopup ? 0 : -1,
241
- "aria-label": translations.triggerLabel,
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) {
249
- const evt = getNativeEvent(event)
250
- if (!interactive) return
251
- if (!isLeftClick(evt)) return
252
- send("TRIGGER.CLICK")
253
- },
254
- onPointerDown(event) {
255
- if (!interactive) return
256
- if (event.pointerType === "touch") return
257
- event.preventDefault()
258
- queueMicrotask(() => {
259
- dom.getInputEl(state.context)?.focus({ preventScroll: true })
260
- })
261
- },
262
- onKeyDown(event) {
263
- if (event.defaultPrevented) return
264
- if (!isDialogPopup) return
233
+ getTriggerProps(props = {}) {
234
+ return normalize.button({
235
+ ...parts.trigger.attrs,
236
+ dir: state.context.dir,
237
+ id: dom.getTriggerId(state.context),
238
+ "aria-haspopup": composite ? "listbox" : "dialog",
239
+ type: "button",
240
+ tabIndex: props.focusable ? undefined : -1,
241
+ "aria-label": translations.triggerLabel,
242
+ "aria-expanded": open,
243
+ "data-state": open ? "open" : "closed",
244
+ "aria-controls": open ? dom.getContentId(state.context) : undefined,
245
+ disabled,
246
+ "data-focusable": dataAttr(props.focusable),
247
+ "data-readonly": dataAttr(readOnly),
248
+ "data-disabled": dataAttr(disabled),
249
+ onFocus() {
250
+ if (!props.focusable) return
251
+ send({ type: "INPUT.FOCUS", src: "trigger" })
252
+ },
253
+ onClick(event) {
254
+ if (event.defaultPrevented) return
255
+ const evt = getNativeEvent(event)
256
+ if (!interactive) return
257
+ if (!isLeftClick(evt)) return
258
+ send("TRIGGER.CLICK")
259
+ },
260
+ onPointerDown(event) {
261
+ if (!interactive) return
262
+ if (event.pointerType === "touch") return
263
+ event.preventDefault()
264
+ queueMicrotask(() => {
265
+ dom.getInputEl(state.context)?.focus({ preventScroll: true })
266
+ })
267
+ },
268
+ onKeyDown(event) {
269
+ if (event.defaultPrevented) return
270
+ if (composite) return
265
271
 
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
- }
272
+ const keyMap: EventKeyMap = {
273
+ ArrowDown() {
274
+ send({ type: "INPUT.ARROW_DOWN", src: "trigger" })
275
+ },
276
+ ArrowUp() {
277
+ send({ type: "INPUT.ARROW_UP", src: "trigger" })
278
+ },
279
+ }
282
280
 
283
- const key = getEventKey(event, state.context)
284
- const exec = keyMap[key]
281
+ const key = getEventKey(event, state.context)
282
+ const exec = keyMap[key]
285
283
 
286
- if (exec) {
287
- exec(event)
288
- event.preventDefault()
289
- }
290
- },
291
- }),
284
+ if (exec) {
285
+ exec(event)
286
+ event.preventDefault()
287
+ }
288
+ },
289
+ })
290
+ },
292
291
 
293
292
  contentProps: normalize.element({
294
293
  ...parts.content.attrs,
295
294
  dir: state.context.dir,
296
295
  id: dom.getContentId(state.context),
297
- role: isDialogPopup ? "dialog" : "listbox",
296
+ role: !composite ? "dialog" : "listbox",
298
297
  tabIndex: -1,
299
298
  hidden: !open,
300
299
  "data-state": open ? "open" : "closed",
301
300
  "aria-labelledby": dom.getLabelId(state.context),
302
- "aria-multiselectable": state.context.multiple && !isDialogPopup ? true : undefined,
301
+ "aria-multiselectable": state.context.multiple && composite ? true : undefined,
303
302
  onPointerDown(event) {
304
303
  // prevent options or elements within listbox from taking focus
305
304
  event.preventDefault()
306
305
  },
307
306
  }),
308
307
 
309
- // only used when triggerOnly: true
310
308
  listProps: normalize.element({
311
- id: dom.getListId(state.context),
312
- role: isDialogPopup ? "listbox" : undefined,
313
- "aria-multiselectable": isDialogPopup && state.context.multiple ? true : undefined,
309
+ role: !composite ? "listbox" : undefined,
310
+ "aria-labelledby": dom.getLabelId(state.context),
311
+ "aria-multiselectable": state.context.multiple && !composite ? true : undefined,
314
312
  }),
315
313
 
316
314
  clearTriggerProps: normalize.button({
@@ -323,7 +321,11 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
323
321
  "aria-label": translations.clearTriggerLabel,
324
322
  "aria-controls": dom.getInputId(state.context),
325
323
  hidden: !state.context.value.length,
326
- onClick() {
324
+ onPointerDown(event) {
325
+ event.preventDefault()
326
+ },
327
+ onClick(event) {
328
+ if (event.defaultPrevented) return
327
329
  if (!interactive) return
328
330
  send({ type: "VALUE.CLEAR", src: "clear-trigger" })
329
331
  },
@@ -349,12 +351,13 @@ export function connect<T extends PropTypes, V extends CollectionItem>(
349
351
  "data-value": itemState.value,
350
352
  onPointerMove() {
351
353
  if (itemState.disabled) return
354
+ if (itemState.highlighted) return
352
355
  send({ type: "ITEM.POINTER_MOVE", value })
353
356
  },
354
357
  onPointerLeave() {
355
358
  if (props.persistFocus) return
356
359
  if (itemState.disabled) return
357
- const mouseMoved = state.previousEvent.type === "ITEM.POINTER_MOVE"
360
+ const mouseMoved = state.previousEvent.type.includes("POINTER")
358
361
  if (!mouseMoved) return
359
362
  send({ type: "ITEM.POINTER_LEAVE", value })
360
363
  },
@@ -1,4 +1,4 @@
1
- import { createScope } from "@zag-js/dom-query"
1
+ import { createScope, query } from "@zag-js/dom-query"
2
2
  import type { MachineContext as Ctx } from "./combobox.types"
3
3
 
4
4
  export const dom = createScope({
@@ -7,7 +7,6 @@ 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`,
11
10
  getPositionerId: (ctx: Ctx) => ctx.ids?.positioner ?? `combobox:${ctx.id}:popper`,
12
11
  getTriggerId: (ctx: Ctx) => ctx.ids?.trigger ?? `combobox:${ctx.id}:toggle-btn`,
13
12
  getClearTriggerId: (ctx: Ctx) => ctx.ids?.clearTrigger ?? `combobox:${ctx.id}:clear-btn`,
@@ -17,17 +16,25 @@ export const dom = createScope({
17
16
  getItemId: (ctx: Ctx, id: string) => `combobox:${ctx.id}:option:${id}`,
18
17
 
19
18
  getContentEl: (ctx: Ctx) => dom.getById(ctx, dom.getContentId(ctx)),
20
- getListEl: (ctx: Ctx) => dom.getById(ctx, dom.getListId(ctx)),
21
19
  getInputEl: (ctx: Ctx) => dom.getById<HTMLInputElement>(ctx, dom.getInputId(ctx)),
22
20
  getPositionerEl: (ctx: Ctx) => dom.getById(ctx, dom.getPositionerId(ctx)),
23
21
  getControlEl: (ctx: Ctx) => dom.getById(ctx, dom.getControlId(ctx)),
24
22
  getTriggerEl: (ctx: Ctx) => dom.getById(ctx, dom.getTriggerId(ctx)),
25
23
  getClearTriggerEl: (ctx: Ctx) => dom.getById(ctx, dom.getClearTriggerId(ctx)),
26
-
27
- isInputFocused: (ctx: Ctx) => dom.getDoc(ctx).activeElement === dom.getInputEl(ctx),
28
24
  getHighlightedItemEl: (ctx: Ctx) => {
29
25
  const value = ctx.highlightedValue
30
26
  if (value == null) return
31
- return dom.getContentEl(ctx)?.querySelector<HTMLElement>(`[role=option][data-value="${CSS.escape(value)}"`)
27
+ return query(dom.getContentEl(ctx), `[role=option][data-value="${CSS.escape(value)}"`)
28
+ },
29
+
30
+ focusInputEl: (ctx: Ctx) => {
31
+ const inputEl = dom.getInputEl(ctx)
32
+ if (dom.getActiveElement(ctx) === inputEl) return
33
+ inputEl?.focus({ preventScroll: true })
34
+ },
35
+ focusTriggerEl: (ctx: Ctx) => {
36
+ const triggerEl = dom.getTriggerEl(ctx)
37
+ if (dom.getActiveElement(ctx) === triggerEl) return
38
+ triggerEl?.focus({ preventScroll: true })
32
39
  },
33
40
  })