Skip to content

Instantly share code, notes, and snippets.

@segunadebayo
Last active March 25, 2024 13:30
Show Gist options
  • Save segunadebayo/17f4fac8c76aca59bd455d70678f03d3 to your computer and use it in GitHub Desktop.
Save segunadebayo/17f4fac8c76aca59bd455d70678f03d3 to your computer and use it in GitHub Desktop.
zag: virtualized select
const items = ['React', 'Solid', 'Vue']

const Basic = () => {
  const contentRef = useRef(null)

  const api = useVirtualizer({
    count: items.length,
    getScrollElement: () => contentRef.current,
    estimateSize: () => 35,
  })

  const virtualItems = api.getVirtualItems()

  return (
    <Select.Root
      scrollFn={(index) => api.scrollToIndex(index, { align: 'center' })}
      items={items}
    >
      <Select.Label>Framework</Select.Label>

      <Select.Control>
        <Select.Trigger>
          <Select.ValueText placeholder="Select a Framework" />
          <Select.Indicator>
            <ChevronDownIcon />
          </Select.Indicator>
        </Select.Trigger>
        <Select.ClearTrigger>Clear</Select.ClearTrigger>
      </Select.Control>

      <Select.Positioner>
        <Select.Content ref={contentRef}>
          <Virtualizer.Root value={api}>
            <Select.ItemGroup id="framework">
              <Select.ItemGroupLabel htmlFor="framework">
                Frameworks
              </Select.ItemGroupLabel>
              {virtualItems.map((virtualItem) => {
                const item = items[virtualItem.index]
                return (
                  <Virtualizer.Item key={item} item={virtualItem} asChild>
                    <Select.Item item={item}>
                      <Select.ItemText>{item}</Select.ItemText>
                      <Select.ItemIndicator></Select.ItemIndicator>
                    </Select.Item>
                  </Virtualizer.Item>
                )
              })}
            </Select.ItemGroup>
          </Virtualizer.Root>
        </Select.Content>
      </Select.Positioner>
    </Select.Root>
  )
}
import { getEventKey, getNativeEvent, type EventKeyMap } from "@zag-js/dom-event"
import { ariaAttr, dataAttr, getByTypeahead, isEditableElement, isSelfEvent } from "@zag-js/dom-query"
import { getPlacementStyles } from "@zag-js/popper"
import type { NormalizeProps, PropTypes } from "@zag-js/types"
import { visuallyHiddenStyle } from "@zag-js/visually-hidden"
import { parts } from "./select.anatomy"
import { dom } from "./select.dom"
import type { CollectionItem, ItemProps, MachineApi, Send, State } from "./select.types"
export function connect<T extends PropTypes, V extends CollectionItem = CollectionItem>(
state: State,
send: Send,
normalize: NormalizeProps<T>,
): MachineApi<T, V> {
const isDisabled = state.context.isDisabled
const isInvalid = state.context.invalid
const isReadOnly = state.context.readOnly
const isInteractive = state.context.isInteractive
const isOpen = state.hasTag("open")
const isFocused = state.matches("focused")
const highlightedItem = state.context.highlightedItem
const selectedItems = state.context.selectedItems
const isTypingAhead = state.context.isTypingAhead
function getItemState(props: ItemProps) {
const { item } = props
const disabled = state.context.collection.isItemDisabled(item)
const value = state.context.collection.itemToValue(item)
return {
value,
isDisabled: Boolean(disabled || isDisabled),
isHighlighted: state.context.highlightedValue === value,
isSelected: state.context.value.includes(value),
}
}
const popperStyles = getPlacementStyles({
...state.context.positioning,
placement: state.context.currentPlacement,
})
const virtual = state.context.virtualizer
return {
virtualItems: virtual?.getVirtualItems() ?? [],
isOpen,
isFocused,
isValueEmpty: state.context.value.length === 0,
highlightedItem,
highlightedValue: state.context.highlightedValue,
selectedItems,
hasSelectedItems: state.context.hasSelectedItems,
value: state.context.value,
valueAsString: state.context.valueAsString,
collection: state.context.collection,
setCollection(collection) {
send({ type: "COLLECTION.SET", value: collection })
},
reposition(options = {}) {
send({ type: "POSITIONING.SET", options })
},
focus() {
dom.getTriggerEl(state.context)?.focus({ preventScroll: true })
},
open() {
send("OPEN")
},
close() {
send("CLOSE")
},
selectValue(value) {
send({ type: "ITEM.SELECT", value })
},
setValue(value) {
send({ type: "VALUE.SET", value })
},
highlightValue(value) {
send({ type: "HIGHLIGHTED_VALUE.SET", value })
},
clearValue(value) {
if (value) {
send({ type: "ITEM.CLEAR", value })
} else {
send({ type: "VALUE.CLEAR" })
}
},
getItemState,
rootProps: normalize.element({
...parts.root.attrs,
dir: state.context.dir,
id: dom.getRootId(state.context),
"data-invalid": dataAttr(isInvalid),
"data-readonly": dataAttr(isReadOnly),
}),
labelProps: normalize.label({
dir: state.context.dir,
id: dom.getLabelId(state.context),
...parts.label.attrs,
"data-disabled": dataAttr(isDisabled),
"data-invalid": dataAttr(isInvalid),
"data-readonly": dataAttr(isReadOnly),
htmlFor: dom.getHiddenSelectId(state.context),
onClick() {
if (isDisabled) return
dom.getTriggerEl(state.context)?.focus({ preventScroll: true })
},
}),
controlProps: normalize.element({
...parts.control.attrs,
dir: state.context.dir,
id: dom.getControlId(state.context),
"data-state": isOpen ? "open" : "closed",
"data-focus": dataAttr(isFocused),
"data-disabled": dataAttr(isDisabled),
"data-invalid": dataAttr(isInvalid),
}),
triggerProps: normalize.button({
id: dom.getTriggerId(state.context),
disabled: isDisabled,
dir: state.context.dir,
type: "button",
"aria-controls": dom.getContentId(state.context),
"aria-expanded": isOpen,
"data-state": isOpen ? "open" : "closed",
"aria-haspopup": "listbox",
"aria-labelledby": dom.getLabelId(state.context),
...parts.trigger.attrs,
"data-disabled": dataAttr(isDisabled),
"data-invalid": dataAttr(isInvalid),
"aria-invalid": isInvalid,
"data-readonly": dataAttr(isReadOnly),
"data-placement": state.context.currentPlacement,
"data-placeholder-shown": dataAttr(!state.context.hasSelectedItems),
onPointerDown(event) {
if (event.button || event.ctrlKey || !isInteractive) return
event.currentTarget.dataset.pointerType = event.pointerType
if (isDisabled || event.pointerType === "touch") return
send({ type: "TRIGGER.CLICK" })
},
onClick(event) {
if (!isInteractive || event.button) return
if (event.currentTarget.dataset.pointerType === "touch") {
send({ type: "TRIGGER.CLICK" })
}
},
onFocus() {
send("TRIGGER.FOCUS")
},
onBlur() {
send("TRIGGER.BLUR")
},
onKeyDown(event) {
if (!isInteractive) return
const keyMap: EventKeyMap = {
ArrowUp() {
send({ type: "TRIGGER.ARROW_UP" })
},
ArrowDown(event) {
send({ type: event.altKey ? "OPEN" : "TRIGGER.ARROW_DOWN" })
},
ArrowLeft() {
send({ type: "TRIGGER.ARROW_LEFT" })
},
ArrowRight() {
send({ type: "TRIGGER.ARROW_RIGHT" })
},
Home() {
send({ type: "TRIGGER.HOME" })
},
End() {
send({ type: "TRIGGER.END" })
},
Enter() {
send({ type: "TRIGGER.ENTER" })
},
Space(event) {
if (isTypingAhead) {
send({ type: "TRIGGER.TYPEAHEAD", key: event.key })
} else {
send({ type: "TRIGGER.ENTER" })
}
},
}
const exec = keyMap[getEventKey(event, state.context)]
if (exec) {
exec(event)
event.preventDefault()
return
}
if (getByTypeahead.isValidEvent(event)) {
send({ type: "TRIGGER.TYPEAHEAD", key: event.key })
event.preventDefault()
}
},
}),
indicatorProps: normalize.element({
...parts.indicator.attrs,
dir: state.context.dir,
"aria-hidden": true,
"data-state": isOpen ? "open" : "closed",
"data-disabled": dataAttr(isDisabled),
"data-invalid": dataAttr(isInvalid),
"data-readonly": dataAttr(isReadOnly),
}),
getItemProps(props) {
const itemState = getItemState(props)
return normalize.element({
id: dom.getItemId(state.context, itemState.value),
role: "option",
...parts.item.attrs,
dir: state.context.dir,
"data-value": itemState.value,
"aria-selected": itemState.isSelected,
"data-state": itemState.isSelected ? "checked" : "unchecked",
"data-highlighted": dataAttr(itemState.isHighlighted),
"data-disabled": dataAttr(itemState.isDisabled),
"aria-disabled": ariaAttr(itemState.isDisabled),
onPointerMove(event) {
if (itemState.isDisabled || event.pointerType !== "mouse") return
if (itemState.value === state.context.highlightedValue) return
send({ type: "ITEM.POINTER_MOVE", value: itemState.value })
},
onPointerUp() {
if (itemState.isDisabled) return
send({ type: "ITEM.CLICK", src: "pointerup", value: itemState.value })
},
onPointerLeave(event) {
const isKeyboardNavigationEvent = ["CONTENT.ARROW_UP", "CONTENT.ARROW_DOWN"].includes(state.event.type)
if (itemState.isDisabled || event.pointerType !== "mouse" || isKeyboardNavigationEvent) return
send({ type: "ITEM.POINTER_LEAVE" })
},
onTouchEnd(event) {
// prevent clicking elements behind content
event.preventDefault()
event.stopPropagation()
},
"aria-setsize": virtual?.getTotalSize(),
"aria-posinset": props.virtual ? props.virtual.index + 1 : undefined,
style: {
...(virtual && {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${props.virtual!.size}px`,
transform: `translateY(${props.virtual!.start}px)`,
}),
},
})
},
getItemTextProps(props) {
const itemState = getItemState(props)
return normalize.element({
...parts.itemText.attrs,
"data-disabled": dataAttr(itemState.isDisabled),
"data-highlighted": dataAttr(itemState.isHighlighted),
})
},
getItemIndicatorProps(props) {
const itemState = getItemState(props)
return normalize.element({
"aria-hidden": true,
...parts.itemIndicator.attrs,
"data-state": itemState.isSelected ? "checked" : "unchecked",
hidden: !itemState.isSelected,
})
},
getItemGroupLabelProps(props) {
const { htmlFor } = props
return normalize.element({
...parts.itemGroupLabel.attrs,
id: dom.getItemGroupLabelId(state.context, htmlFor),
role: "group",
dir: state.context.dir,
})
},
getItemGroupProps(props) {
const { id } = props
return normalize.element({
...parts.itemGroup.attrs,
"data-disabled": dataAttr(isDisabled),
id: dom.getItemGroupId(state.context, id),
"aria-labelledby": dom.getItemGroupLabelId(state.context, id),
dir: state.context.dir,
})
},
clearTriggerProps: normalize.button({
...parts.clearTrigger.attrs,
id: dom.getClearTriggerId(state.context),
type: "button",
"aria-label": "Clear value",
disabled: isDisabled,
hidden: !state.context.hasSelectedItems,
dir: state.context.dir,
onClick() {
send("VALUE.CLEAR")
},
}),
hiddenSelectProps: normalize.select({
name: state.context.name,
form: state.context.form,
disabled: !isInteractive,
multiple: state.context.multiple,
"aria-hidden": true,
id: dom.getHiddenSelectId(state.context),
// defaultValue: state.context.selectedOption?.value,
style: visuallyHiddenStyle,
tabIndex: -1,
// Some browser extensions will focus the hidden select.
// Let's forward the focus to the trigger.
onFocus() {
dom.getTriggerEl(state.context)?.focus({ preventScroll: true })
},
"aria-labelledby": dom.getLabelId(state.context),
}),
positionerProps: normalize.element({
...parts.positioner.attrs,
dir: state.context.dir,
id: dom.getPositionerId(state.context),
style: popperStyles.floating,
}),
virtualizerProps: normalize.element({
id: dom.getVirtualizerId(state.context),
style: {
height: virtual ? `${virtual.getTotalSize()}px` : undefined,
width: "100%",
position: "relative",
},
}),
contentProps: normalize.element({
hidden: !isOpen,
dir: state.context.dir,
id: dom.getContentId(state.context),
role: "listbox",
...parts.content.attrs,
"data-state": isOpen ? "open" : "closed",
"aria-activedescendant": state.context.highlightedValue
? dom.getItemId(state.context, state.context.highlightedValue)
: undefined,
"aria-multiselectable": state.context.multiple ? "true" : undefined,
"aria-labelledby": dom.getLabelId(state.context),
tabIndex: 0,
onKeyDown(event) {
const evt = getNativeEvent(event)
if (!isInteractive || !isSelfEvent(evt)) return
const keyMap: EventKeyMap = {
ArrowUp() {
send({ type: "CONTENT.ARROW_UP" })
},
ArrowDown() {
send({ type: "CONTENT.ARROW_DOWN" })
},
Home() {
send({ type: "CONTENT.HOME" })
},
End() {
send({ type: "CONTENT.END" })
},
Enter() {
send({ type: "ITEM.CLICK", src: "keydown.enter" })
},
Space(event) {
if (isTypingAhead) {
send({ type: "CONTENT.TYPEAHEAD", key: event.key })
} else {
keyMap.Enter?.(event)
}
},
}
const exec = keyMap[getEventKey(event)]
if (exec) {
exec(event)
event.preventDefault()
return
}
if (isEditableElement(event.target)) {
return
}
if (getByTypeahead.isValidEvent(event)) {
send({ type: "CONTENT.TYPEAHEAD", key: event.key })
event.preventDefault()
}
},
}),
}
}
import {
Virtualizer,
elementScroll,
observeElementOffset,
observeElementRect,
type VirtualizerOptions,
} from '@tanstack/virtual-core'
import { createMachine, guards, ref } from '@zag-js/core'
import { trackDismissableElement } from '@zag-js/dismissable'
import { getByTypeahead, raf, scrollIntoView } from '@zag-js/dom-query'
import { trackFormControl } from '@zag-js/form-utils'
import { observeAttributes } from '@zag-js/mutation-observer'
import { getPlacement } from '@zag-js/popper'
import { proxyTabFocus } from '@zag-js/tabbable'
import { addOrRemove, compact, isEqual } from '@zag-js/utils'
import { collection } from './select.collection'
import { dom } from './select.dom'
import type {
CollectionItem,
MachineContext,
MachineState,
UserDefinedContext,
} from './select.types'
const { and, not, or } = guards
function getVirtualOptions(
ctx: MachineContext
): VirtualizerOptions<HTMLElement, HTMLElement> {
return {
count: ctx.collection.count(),
observeElementRect: observeElementRect,
observeElementOffset: observeElementOffset,
scrollToFn: elementScroll,
getScrollElement: () => dom.getContentEl(ctx),
estimateSize: () => 35,
overscan: 10,
}
}
export function machine<T extends CollectionItem>(
userContext: UserDefinedContext<T>
) {
const ctx = compact(userContext)
return createMachine<MachineContext, MachineState>(
{
id: 'select',
context: {
value: [],
highlightedValue: null,
selectOnBlur: false,
loop: false,
closeOnSelect: true,
disabled: false,
...ctx,
collection: ctx.collection ?? collection.empty(),
typeahead: getByTypeahead.defaultOptions,
fieldsetDisabled: false,
restoreFocus: true,
positioning: {
placement: 'bottom-start',
gutter: 8,
...ctx.positioning,
},
force: -1,
// @ts-expect-error
virtualizer: ref<any>(new Virtualizer(getVirtualOptions(ctx))),
},
computed: {
hasSelectedItems: (ctx) => ctx.value.length > 0,
isTypingAhead: (ctx) => ctx.typeahead.keysSoFar !== '',
isDisabled: (ctx) => !!ctx.disabled || ctx.fieldsetDisabled,
isInteractive: (ctx) => !(ctx.isDisabled || ctx.readOnly),
selectedItems: (ctx) => ctx.collection.items(ctx.value),
highlightedItem: (ctx) => ctx.collection.item(ctx.highlightedValue),
valueAsString: (ctx) => ctx.collection.itemsToString(ctx.selectedItems),
},
initial: ctx.open ? 'open' : 'idle',
watch: {
open: ['toggleVisibility'],
value: ['syncSelectElement'],
},
on: {
'HIGHLIGHTED_VALUE.SET': {
actions: ['setHighlightedItem'],
},
'ITEM.SELECT': {
actions: ['selectItem'],
},
'ITEM.CLEAR': {
actions: ['clearItem'],
},
'VALUE.SET': {
actions: ['setSelectedItems'],
},
'VALUE.CLEAR': {
actions: ['clearSelectedItems'],
},
'COLLECTION.SET': {
actions: ['setCollection'],
},
},
activities: ['trackFormControlState', 'trackVirtualizer'],
states: {
idle: {
tags: ['closed'],
on: {
'CONTROLLED.OPEN': [
{
guard: 'isTriggerClickEvent',
target: 'open',
actions: ['highlightFirstSelectedItem'],
},
{
target: 'open',
},
],
'TRIGGER.CLICK': [
{
guard: 'isOpenControlled',
actions: ['invokeOnOpen'],
},
{
target: 'open',
actions: ['invokeOnOpen', 'highlightFirstSelectedItem'],
},
],
'TRIGGER.FOCUS': {
target: 'focused',
},
OPEN: [
{
guard: 'isOpenControlled',
actions: ['invokeOnOpen'],
},
{
target: 'open',
actions: ['invokeOnOpen'],
},
],
},
},
focused: {
tags: ['closed'],
entry: ['focusTriggerEl'],
on: {
'CONTROLLED.OPEN': [
{
guard: 'isTriggerClickEvent',
target: 'open',
actions: ['highlightFirstSelectedItem'],
},
{
guard: 'isTriggerArrowUpEvent',
target: 'open',
actions: ['highlightComputedLastItem'],
},
{
guard: or('isTriggerArrowDownEvent', 'isTriggerEnterEvent'),
target: 'open',
actions: ['highlightComputedFirstItem'],
},
{
target: 'open',
},
],
OPEN: [
{
guard: 'isOpenControlled',
actions: ['invokeOnOpen'],
},
{
target: 'open',
actions: ['invokeOnOpen'],
},
],
'TRIGGER.BLUR': {
target: 'idle',
},
'TRIGGER.CLICK': [
{
guard: 'isOpenControlled',
actions: ['invokeOnOpen'],
},
{
target: 'open',
actions: ['invokeOnOpen', 'highlightFirstSelectedItem'],
},
],
'TRIGGER.ENTER': [
{
guard: 'isOpenControlled',
actions: ['invokeOnOpen'],
},
{
target: 'open',
actions: ['invokeOnOpen', 'highlightComputedFirstItem'],
},
],
'TRIGGER.ARROW_UP': [
{
guard: 'isOpenControlled',
actions: ['invokeOnOpen'],
},
{
target: 'open',
actions: ['invokeOnOpen', 'highlightComputedLastItem'],
},
],
'TRIGGER.ARROW_DOWN': [
{
guard: 'isOpenControlled',
actions: ['invokeOnOpen'],
},
{
target: 'open',
actions: ['invokeOnOpen', 'highlightComputedFirstItem'],
},
],
'TRIGGER.ARROW_LEFT': [
{
guard: and(not('multiple'), 'hasSelectedItems'),
actions: ['selectPreviousItem'],
},
{
guard: not('multiple'),
actions: ['selectLastItem'],
},
],
'TRIGGER.ARROW_RIGHT': [
{
guard: and(not('multiple'), 'hasSelectedItems'),
actions: ['selectNextItem'],
},
{
guard: not('multiple'),
actions: ['selectFirstItem'],
},
],
'TRIGGER.HOME': {
guard: not('multiple'),
actions: ['selectFirstItem'],
},
'TRIGGER.END': {
guard: not('multiple'),
actions: ['selectLastItem'],
},
'TRIGGER.TYPEAHEAD': {
guard: not('multiple'),
actions: ['selectMatchingItem'],
},
},
},
open: {
tags: ['open'],
entry: ['focusContentEl'],
exit: ['scrollContentToTop'],
activities: [
'trackDismissableElement',
'computePlacement',
'scrollToHighlightedItem',
'proxyTabFocus',
],
on: {
'CONTROLLED.CLOSE': [
{
guard: 'shouldRestoreFocus',
target: 'focused',
actions: ['clearHighlightedItem'],
},
{
target: 'idle',
actions: ['clearHighlightedItem'],
},
],
CLOSE: [
{
guard: 'isOpenControlled',
actions: ['invokeOnClose'],
},
{
target: 'focused',
actions: ['invokeOnClose', 'clearHighlightedItem'],
},
],
'TRIGGER.CLICK': [
{
guard: 'isOpenControlled',
actions: ['invokeOnClose'],
},
{
target: 'focused',
actions: ['invokeOnClose', 'clearHighlightedItem'],
},
],
'ITEM.CLICK': [
{
guard: and('closeOnSelect', 'isOpenControlled'),
actions: ['selectHighlightedItem', 'invokeOnClose'],
},
{
guard: 'closeOnSelect',
target: 'focused',
actions: [
'selectHighlightedItem',
'invokeOnClose',
'clearHighlightedItem',
],
},
{
actions: ['selectHighlightedItem'],
},
],
'CONTENT.INTERACT_OUTSIDE': [
// == group 1 ==
{
guard: and(
'selectOnBlur',
'hasHighlightedItem',
'isOpenControlled'
),
actions: ['selectHighlightedItem', 'invokeOnClose'],
},
{
guard: and('selectOnBlur', 'hasHighlightedItem'),
target: 'idle',
actions: [
'selectHighlightedItem',
'invokeOnClose',
'clearHighlightedItem',
],
},
// == group 2 ==
{
guard: and('shouldRestoreFocus', 'isOpenControlled'),
actions: ['invokeOnClose'],
},
{
guard: 'shouldRestoreFocus',
target: 'focused',
actions: ['invokeOnClose', 'clearHighlightedItem'],
},
// == group 3 ==
{
guard: 'isOpenControlled',
actions: ['invokeOnClose'],
},
{
target: 'idle',
actions: ['invokeOnClose', 'clearHighlightedItem'],
},
],
'CONTENT.HOME': {
actions: ['highlightFirstItem'],
},
'CONTENT.END': {
actions: ['highlightLastItem'],
},
'CONTENT.ARROW_DOWN': [
{
guard: and(
'hasHighlightedItem',
'loop',
'isLastItemHighlighted'
),
actions: ['highlightFirstItem'],
},
{
guard: 'hasHighlightedItem',
actions: ['highlightNextItem'],
},
{
actions: ['highlightFirstItem'],
},
],
'CONTENT.ARROW_UP': [
{
guard: and(
'hasHighlightedItem',
'loop',
'isFirstItemHighlighted'
),
actions: ['highlightLastItem'],
},
{
guard: 'hasHighlightedItem',
actions: ['highlightPreviousItem'],
},
{
actions: ['highlightLastItem'],
},
],
'CONTENT.TYPEAHEAD': {
actions: ['highlightMatchingItem'],
},
'ITEM.POINTER_MOVE': {
actions: ['highlightItem'],
},
'ITEM.POINTER_LEAVE': {
actions: ['clearHighlightedItem'],
},
'POSITIONING.SET': {
actions: ['reposition'],
},
},
},
},
},
{
guards: {
loop: (ctx) => !!ctx.loop,
multiple: (ctx) => !!ctx.multiple,
hasSelectedItems: (ctx) => !!ctx.hasSelectedItems,
hasHighlightedItem: (ctx) => ctx.highlightedValue != null,
isFirstItemHighlighted: (ctx) =>
ctx.highlightedValue === ctx.collection.first(),
isLastItemHighlighted: (ctx) =>
ctx.highlightedValue === ctx.collection.last(),
selectOnBlur: (ctx) => !!ctx.selectOnBlur,
closeOnSelect: (ctx, evt) => {
if (ctx.multiple) return false
return !!(evt.closeOnSelect ?? ctx.closeOnSelect)
},
shouldRestoreFocus: (ctx) => !!ctx.restoreFocus,
// guard assertions (for controlled mode)
isOpenControlled: (ctx) => !!ctx['open.controlled'],
isTriggerClickEvent: (_ctx, evt) =>
evt.previousEvent?.type === 'TRIGGER.CLICK',
isTriggerEnterEvent: (_ctx, evt) =>
evt.previousEvent?.type === 'TRIGGER.ENTER',
isTriggerArrowUpEvent: (_ctx, evt) =>
evt.previousEvent?.type === 'TRIGGER.ARROW_UP',
isTriggerArrowDownEvent: (_ctx, evt) =>
evt.previousEvent?.type === 'TRIGGER.ARROW_DOWN',
},
activities: {
trackVirtualizer(ctx) {
if (!ctx.virtualizer) return
ctx.virtualizer.setOptions({
...getVirtualOptions(ctx),
onChange: () => ctx.force++,
})
ctx.virtualizer._willUpdate()
return ctx.virtualizer._didMount()
},
proxyTabFocus(ctx) {
const contentEl = () => dom.getContentEl(ctx)
return proxyTabFocus(contentEl, {
defer: true,
triggerElement: dom.getTriggerEl(ctx),
onFocus(el) {
raf(() => el.focus({ preventScroll: true }))
},
})
},
trackFormControlState(ctx, _evt, { initialContext }) {
return trackFormControl(dom.getHiddenSelectEl(ctx), {
onFieldsetDisabledChange(disabled) {
ctx.fieldsetDisabled = disabled
},
onFormReset() {
set.selectedItems(ctx, initialContext.value)
},
})
},
trackDismissableElement(ctx, _evt, { send }) {
const contentEl = () => dom.getContentEl(ctx)
return trackDismissableElement(contentEl, {
defer: true,
exclude: [dom.getTriggerEl(ctx), dom.getClearTriggerEl(ctx)],
onFocusOutside: ctx.onFocusOutside,
onPointerDownOutside: ctx.onPointerDownOutside,
onInteractOutside(event) {
ctx.onInteractOutside?.(event)
ctx.restoreFocus = !event.detail.focusable
},
onDismiss() {
send({ type: 'CONTENT.INTERACT_OUTSIDE' })
},
})
},
computePlacement(ctx) {
ctx.currentPlacement = ctx.positioning.placement
const triggerEl = () => dom.getTriggerEl(ctx)
const positionerEl = () => dom.getPositionerEl(ctx)
return getPlacement(triggerEl, positionerEl, {
defer: true,
...ctx.positioning,
onComplete(data) {
ctx.currentPlacement = data.placement
},
})
},
scrollToHighlightedItem(ctx, _evt, { getState }) {
const exec = () => {
const state = getState()
// don't scroll into view if we're using the pointer
if (state.event.type.startsWith('ITEM.POINTER')) return
if (ctx.virtualizer) {
const highlightedIndex = ctx.collection.indexOf(
ctx.highlightedValue
)
ctx.virtualizer!.scrollToIndex(highlightedIndex, {
behavior: 'auto',
align: 'center',
})
return
}
scrollIntoView(dom.getHighlightedOptionEl(ctx), {
rootEl: dom.getContentEl(ctx),
block: 'nearest',
inline: 'center',
})
}
let called = 1
if (ctx.virtualizer) {
const id = setInterval(() => {
if (called === 2) {
clearInterval(id)
return
}
exec()
called++
}, 1000 / 60)
} else {
raf(() => exec())
}
return observeAttributes(
dom.getContentEl(ctx),
['aria-activedescendant'],
exec
)
},
},
actions: {
reposition(ctx, evt) {
const positionerEl = () => dom.getPositionerEl(ctx)
getPlacement(dom.getTriggerEl(ctx), positionerEl, {
...ctx.positioning,
...evt.options,
defer: true,
listeners: false,
onComplete(data) {
ctx.currentPlacement = data.placement
},
})
},
toggleVisibility(ctx, evt, { send }) {
send({
type: ctx.open ? 'CONTROLLED.OPEN' : 'CONTROLLED.CLOSE',
previousEvent: evt,
})
},
highlightPreviousItem(ctx) {
if (ctx.highlightedValue == null) return
const value = ctx.collection.prev(ctx.highlightedValue)
set.highlightedItem(ctx, value)
},
highlightNextItem(ctx) {
if (ctx.highlightedValue == null) return
const value = ctx.collection.next(ctx.highlightedValue)
set.highlightedItem(ctx, value)
},
highlightFirstItem(ctx) {
const value = ctx.collection.first()
set.highlightedItem(ctx, value)
},
highlightLastItem(ctx) {
const value = ctx.collection.last()
set.highlightedItem(ctx, value)
},
focusContentEl(ctx) {
raf(() => {
dom.getContentEl(ctx)?.focus({ preventScroll: true })
})
},
focusTriggerEl(ctx) {
raf(() => {
dom.getTriggerEl(ctx)?.focus({ preventScroll: true })
})
},
selectHighlightedItem(ctx, evt) {
const value = evt.value ?? ctx.highlightedValue
if (value == null) return
set.selectedItem(ctx, value)
},
highlightComputedFirstItem(ctx) {
const value = ctx.hasSelectedItems
? ctx.collection.sort(ctx.value)[0]
: ctx.collection.first()
set.highlightedItem(ctx, value)
},
highlightComputedLastItem(ctx) {
const value = ctx.hasSelectedItems
? ctx.collection.sort(ctx.value)[0]
: ctx.collection.last()
set.highlightedItem(ctx, value)
},
highlightFirstSelectedItem(ctx) {
if (!ctx.hasSelectedItems) return
const [value] = ctx.collection.sort(ctx.value)
set.highlightedItem(ctx, value)
},
highlightItem(ctx, evt) {
set.highlightedItem(ctx, evt.value)
},
highlightMatchingItem(ctx, evt) {
const value = ctx.collection.search(evt.key, {
state: ctx.typeahead,
currentValue: ctx.highlightedValue,
})
if (value == null) return
set.highlightedItem(ctx, value)
},
setHighlightedItem(ctx, evt) {
set.highlightedItem(ctx, evt.value)
},
clearHighlightedItem(ctx) {
set.highlightedItem(ctx, null, true)
},
selectItem(ctx, evt) {
set.selectedItem(ctx, evt.value)
},
clearItem(ctx, evt) {
const value = ctx.value.filter((v) => v !== evt.value)
set.selectedItems(ctx, value)
},
setSelectedItems(ctx, evt) {
set.selectedItems(ctx, evt.value)
},
clearSelectedItems(ctx) {
set.selectedItems(ctx, [])
},
selectPreviousItem(ctx) {
const value = ctx.collection.prev(ctx.value[0])
set.selectedItem(ctx, value)
},
selectNextItem(ctx) {
const value = ctx.collection.next(ctx.value[0])
set.selectedItem(ctx, value)
},
selectFirstItem(ctx) {
const value = ctx.collection.first()
set.selectedItem(ctx, value)
},
selectLastItem(ctx) {
const value = ctx.collection.last()
set.selectedItem(ctx, value)
},
selectMatchingItem(ctx, evt) {
const value = ctx.collection.search(evt.key, {
state: ctx.typeahead,
currentValue: ctx.value[0],
})
if (value == null) return
set.selectedItem(ctx, value)
},
scrollContentToTop(ctx) {
if (ctx.virtualizer) {
ctx.virtualizer.scrollToIndex(0)
} else {
dom.getContentEl(ctx)?.scrollTo(0, 0)
}
},
invokeOnOpen(ctx) {
ctx.onOpenChange?.({ open: true })
},
invokeOnClose(ctx) {
ctx.onOpenChange?.({ open: false })
},
syncSelectElement(ctx) {
const selectEl = dom.getHiddenSelectEl(ctx)
if (!selectEl) return
for (const option of selectEl.options) {
option.selected = ctx.value.includes(option.value)
}
},
setCollection(ctx, evt) {
ctx.collection = evt.value
},
},
}
)
}
function dispatchChangeEvent(ctx: MachineContext) {
raf(() => {
const node = dom.getHiddenSelectEl(ctx)
if (!node) return
const win = dom.getWin(ctx)
const changeEvent = new win.Event('change', {
bubbles: true,
composed: true,
})
node.dispatchEvent(changeEvent)
})
}
const invoke = {
change: (ctx: MachineContext) => {
ctx.onValueChange?.({
value: Array.from(ctx.value),
items: ctx.selectedItems,
})
dispatchChangeEvent(ctx)
},
highlightChange: (ctx: MachineContext) => {
ctx.onHighlightChange?.({
highlightedValue: ctx.highlightedValue,
highlightedItem: ctx.highlightedItem,
highlightedIndex: ctx.collection.indexOf(ctx.highlightedValue),
})
},
}
const set = {
selectedItem: (
ctx: MachineContext,
value: string | null | undefined,
force = false
) => {
if (isEqual(ctx.value, value)) return
if (value == null && !force) return
if (value == null && force) {
ctx.value = []
invoke.change(ctx)
return
}
const nextValue = ctx.multiple ? addOrRemove(ctx.value, value!) : [value!]
ctx.value = nextValue
invoke.change(ctx)
},
selectedItems: (ctx: MachineContext, value: string[]) => {
if (isEqual(ctx.value, value)) return
ctx.value = value
invoke.change(ctx)
},
highlightedItem: (
ctx: MachineContext,
value: string | null | undefined,
force = false
) => {
if (isEqual(ctx.highlightedValue, value)) return
if (value == null && !force) return
ctx.highlightedValue = value ?? null
invoke.highlightChange(ctx)
},
}
import { normalizeProps, Portal, useMachine } from '@zag-js/react'
import * as select from '@zag-js/select'
import { selectControls, selectData } from '@zag-js/shared'
import serialize from 'form-serialize'
import { useId } from 'react'
import { StateVisualizer } from '../components/state-visualizer'
import { Toolbar } from '../components/toolbar'
import { useControls } from '../hooks/use-controls'
export default function Page() {
const controls = useControls(selectControls)
const [state, send] = useMachine(
select.machine({
collection: select.collection({ items: selectData }),
id: useId(),
name: 'country',
// onHighlightChange(details) {
// console.log("onHighlightChange", details)
// },
// onValueChange(details) {
// console.log("onChange", details)
// },
// onOpenChange(details) {
// console.log("onOpenChange", details)
// },
}),
{
context: controls.context,
}
)
const api = select.connect(state, send, normalizeProps)
return (
<>
<main className="select">
<div {...api.rootProps}>
<label {...api.labelProps}>Label</label>
{/* control */}
<div {...api.controlProps}>
<button {...api.triggerProps}>
<span>{api.valueAsString || 'Select option'}</span>
<span {...api.indicatorProps}>▼</span>
</button>
<button {...api.clearTriggerProps}>X</button>
</div>
<form
onChange={(e) => {
const formData = serialize(e.currentTarget, { hash: true })
console.log(formData)
}}
>
{/* Hidden select */}
<select {...api.hiddenSelectProps}>
{api.isValueEmpty && <option value="" />}
{selectData.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</form>
{/* UI select */}
<Portal>
<div {...api.positionerProps}>
<div {...api.contentProps}>
<div {...api.virtualizerProps}>
{api.virtualItems.map((virtualItem) => {
const item = selectData[virtualItem.index]
return (
<div
key={virtualItem.key}
{...api.getItemProps({ item, virtual: virtualItem })}
>
<span {...api.getItemTextProps({ item })}>
{item.label}
</span>
<span {...api.getItemIndicatorProps({ item })}>✓</span>
</div>
)
})}
</div>
</div>
</div>
</Portal>
</div>
</main>
<Toolbar controls={controls.ui} viz>
{/* <StateVisualizer state={state} omit={["collection", "virtualizer"]} /> */}
</Toolbar>
</>
)
}
import type { Collection, CollectionItem, CollectionOptions } from "@zag-js/collection"
import type { StateMachine as S } from "@zag-js/core"
import type { InteractOutsideHandlers } from "@zag-js/dismissable"
import type { TypeaheadState } from "@zag-js/dom-query"
import type { Placement, PositioningOptions } from "@zag-js/popper"
import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types"
import type { VirtualItem, Virtualizer, VirtualizerOptions } from "@tanstack/virtual-core"
/* -----------------------------------------------------------------------------
* Callback details
* -----------------------------------------------------------------------------*/
export interface ValueChangeDetails<T extends CollectionItem = CollectionItem> {
value: string[]
items: T[]
}
export interface HighlightChangeDetails<T extends CollectionItem = CollectionItem> {
highlightedValue: string | null
highlightedItem: T | null
highlightedIndex: number
}
export interface OpenChangeDetails {
open: boolean
}
/* -----------------------------------------------------------------------------
* Machine context
* -----------------------------------------------------------------------------*/
export type ElementIds = Partial<{
root: string
content: string
control: string
trigger: string
clearTrigger: string
label: string
hiddenSelect: string
positioner: string
virtualizer: string
item(id: string | number): string
itemGroup(id: string | number): string
itemGroupLabel(id: string | number): string
}>
interface PublicContext<T extends CollectionItem = CollectionItem>
extends DirectionProperty,
CommonProperties,
InteractOutsideHandlers {
/**
* The item collection
*/
collection: Collection<CollectionItem>
/**
* The ids of the elements in the select. Useful for composition.
*/
ids?: ElementIds
/**
* The `name` attribute of the underlying select.
*/
name?: string
/**
* The associate form of the underlying select.
*/
form?: string
/**
* Whether the select is disabled
*/
disabled?: boolean
/**
* Whether the select is invalid
*/
invalid?: boolean
/**
* Whether the select is read-only
*/
readOnly?: boolean
/**
* Whether the select should close after an item is selected
*/
closeOnSelect?: boolean
/**
* Whether to select the highlighted item when the user presses Tab,
* and the menu is open.
*/
selectOnBlur?: boolean
/**
* The callback fired when the highlighted item changes.
*/
onHighlightChange?: (details: HighlightChangeDetails<T>) => void
/**
* The callback fired when the selected item changes.
*/
onValueChange?: (details: ValueChangeDetails<T>) => void
/**
* Function called when the popup is opened
*/
onOpenChange?: (details: OpenChangeDetails) => void
/**
* The positioning options of the menu.
*/
positioning: PositioningOptions
/**
* The virtualization options of the menu.
*/
virtualization?: VirtualizerOptions<HTMLElement, HTMLElement>
force?: any
/**
* The keys of the selected items
*/
value: string[]
/**
* The key of the highlighted item
*/
highlightedValue: string | null
/**
* Whether to loop the keyboard navigation through the options
*/
loop?: boolean
/**
* Whether to allow multiple selection
*/
multiple?: boolean
/**
* Whether the select menu is open
*/
open?: boolean
/**
* Whether the select's open state is controlled by the user
*/
"open.controlled"?: boolean
}
interface PrivateContext {
/**
* @internal
* Internal state of the typeahead
*/
typeahead: TypeaheadState
/**
* @internal
* The current placement of the menu
*/
currentPlacement?: Placement
/**
* @internal
* Whether the fieldset is disabled
*/
fieldsetDisabled: boolean
/**
* @internal
* Whether to restore focus to the trigger after the menu closes
*/
restoreFocus?: boolean
/**
* @internal
* The virtualizer instance
*/
virtualizer?: Virtualizer<HTMLElement, HTMLElement>
}
type ComputedContext<T extends CollectionItem = CollectionItem> = Readonly<{
/**
* @computed
* Whether there's a selected option
*/
hasSelectedItems: boolean
/**
* @computed
* Whether a typeahead is currently active
*/
isTypingAhead: boolean
/**
* @computed
* Whether the select is interactive
*/
isInteractive: boolean
/**
* @computed
* Whether the select is disabled
*/
isDisabled: boolean
/**
* The highlighted item
*/
highlightedItem: T | null
/**
* @computed
* The selected items
*/
selectedItems: T[]
/**
* @computed
* The display value of the select (based on the selected items)
*/
valueAsString: string
}>
export type UserDefinedContext<T extends CollectionItem = CollectionItem> = RequiredBy<
PublicContext<T>,
"id" | "collection"
>
export interface MachineContext extends PublicContext, PrivateContext, ComputedContext {}
export interface MachineState {
value: "idle" | "focused" | "open"
}
export type State = S.State<MachineContext, MachineState>
export type Send = S.Send<S.AnyEventObject>
/* -----------------------------------------------------------------------------
* Component API
* -----------------------------------------------------------------------------*/
export interface ItemProps<T extends CollectionItem = CollectionItem> {
item: T
virtual?: VirtualItem
}
export interface ItemState {
value: string
isDisabled: boolean
isSelected: boolean
isHighlighted: boolean
}
export interface ItemGroupProps {
id: string
}
export interface ItemGroupLabelProps {
htmlFor: string
}
export interface MachineApi<T extends PropTypes = PropTypes, V extends CollectionItem = CollectionItem> {
/**
* The virtual items
*/
virtualItems: VirtualItem[]
/**
* Whether the select is focused
*/
isFocused: boolean
/**
* Whether the select is open
*/
isOpen: boolean
/**
* Whether the select value is empty
*/
isValueEmpty: boolean
/**
* The value of the highlighted item
*/
highlightedValue: string | null
/**
* The highlighted item
*/
highlightedItem: V | null
/**
* The value of the select input
*/
highlightValue(value: string): void
/**
* The selected items
*/
selectedItems: V[]
/**
* Whether there's a selected option
*/
hasSelectedItems: boolean
/**
* The selected item keys
*/
value: string[]
/**
* The string representation of the selected items
*/
valueAsString: string
/**
* Function to select a value
*/
selectValue(value: string): void
/**
* Function to set the value of the select
*/
setValue(value: string[]): void
/**
* Function to clear the value of the select
*/
clearValue(value?: string): void
/**
* Function to focus on the select input
*/
focus(): void
/**
* Returns the state of a select item
*/
getItemState(props: ItemProps): ItemState
/**
* Function to open the select
*/
open(): void
/**
* Function to close the select
*/
close(): void
/**
* Function to toggle the select
*/
collection: Collection<V>
/**
* Function to set the collection of items
*/
setCollection(collection: Collection<V>): void
/**
* Function to set the positioning options of the select
*/
reposition(options: Partial<PositioningOptions>): void
rootProps: T["element"]
labelProps: T["label"]
controlProps: T["element"]
triggerProps: T["button"]
indicatorProps: T["element"]
clearTriggerProps: T["button"]
positionerProps: T["element"]
virtualizerProps: T["element"]
contentProps: T["element"]
getItemProps(props: ItemProps): T["element"]
getItemTextProps(props: ItemProps): T["element"]
getItemIndicatorProps(props: ItemProps): T["element"]
getItemGroupProps(props: ItemGroupProps): T["element"]
getItemGroupLabelProps(props: ItemGroupLabelProps): T["element"]
hiddenSelectProps: T["select"]
}
/* -----------------------------------------------------------------------------
* Re-exported types
* -----------------------------------------------------------------------------*/
export type { CollectionOptions, CollectionItem }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment