UNPKG

bits-ui

Version:

The headless components for Svelte.

1,178 lines (1,177 loc) 44.3 kB
import { Context, Previous, watch } from "runed"; import { afterSleep, afterTick, onDestroyEffect, onMountEffect, attachRef, DOMContext, box, } from "svelte-toolbelt"; import { on } from "svelte/events"; import { backward, forward, next, prev } from "../../internal/arrays.js"; import { getAriaExpanded, getAriaHidden, getDataDisabled, getDataOpenClosed, getDisabled, getRequired, } from "../../internal/attrs.js"; import { kbd } from "../../internal/kbd.js"; import { noop } from "../../internal/noop.js"; import { isIOS } from "../../internal/is.js"; import { createBitsAttrs } from "../../internal/attrs.js"; import { getFloatingContentCSSVars } from "../../internal/floating-svelte/floating-utils.svelte.js"; import { DataTypeahead } from "../../internal/data-typeahead.svelte.js"; import { DOMTypeahead } from "../../internal/dom-typeahead.svelte.js"; import { OpenChangeComplete } from "../../internal/open-change-complete.js"; import { debounce } from "../../internal/debounce.js"; // prettier-ignore export const INTERACTION_KEYS = [kbd.ARROW_LEFT, kbd.ESCAPE, kbd.ARROW_RIGHT, kbd.SHIFT, kbd.CAPS_LOCK, kbd.CONTROL, kbd.ALT, kbd.META, kbd.ENTER, kbd.F1, kbd.F2, kbd.F3, kbd.F4, kbd.F5, kbd.F6, kbd.F7, kbd.F8, kbd.F9, kbd.F10, kbd.F11, kbd.F12]; export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME]; export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END]; export const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]; export const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE]; export const CONTENT_MARGIN = 10; const selectAttrs = createBitsAttrs({ component: "select", parts: [ "trigger", "content", "item", "viewport", "scroll-up-button", "scroll-down-button", "group", "group-label", "separator", "arrow", "input", "content-wrapper", "item-text", "value", ], }); const SelectRootContext = new Context("Select.Root | Combobox.Root"); const SelectGroupContext = new Context("Select.Group | Combobox.Group"); const SelectContentContext = new Context("Select.Content | Combobox.Content"); class SelectBaseRootState { opts; touchedInput = $state(false); inputNode = $state(null); contentNode = $state(null); triggerNode = $state(null); valueId = $state(""); highlightedNode = $state(null); highlightedValue = $derived.by(() => { if (!this.highlightedNode) return null; return this.highlightedNode.getAttribute("data-value"); }); highlightedId = $derived.by(() => { if (!this.highlightedNode) return undefined; return this.highlightedNode.id; }); highlightedLabel = $derived.by(() => { if (!this.highlightedNode) return null; return this.highlightedNode.getAttribute("data-label"); }); isUsingKeyboard = false; isCombobox = false; domContext = new DOMContext(() => null); constructor(opts) { this.opts = opts; this.isCombobox = opts.isCombobox; new OpenChangeComplete({ ref: box.with(() => this.contentNode), open: this.opts.open, onComplete: () => { this.opts.onOpenChangeComplete.current(this.opts.open.current); }, }); $effect.pre(() => { if (!this.opts.open.current) { this.setHighlightedNode(null); } }); } #debouncedSetHighlightedToFirstCandidate = debounce(this.setHighlightedToFirstCandidate.bind(this), 20); setHighlightedNode(node, initial = false) { this.highlightedNode = node; if (node && (this.isUsingKeyboard || initial)) { node.scrollIntoView({ block: this.opts.scrollAlignment.current }); } } getCandidateNodes() { const node = this.contentNode; if (!node) return []; return Array.from(node.querySelectorAll(`[${this.getBitsAttr("item")}]:not([data-disabled])`)); } setHighlightedToFirstCandidate(options = { debounced: false }) { if (options.debounced) { this.#debouncedSetHighlightedToFirstCandidate(); return; } this.setHighlightedNode(null); const candidateNodes = this.getCandidateNodes(); if (!candidateNodes.length) return; this.setHighlightedNode(candidateNodes[0]); } getNodeByValue(value) { const candidateNodes = this.getCandidateNodes(); return candidateNodes.find((node) => node.dataset.value === value) ?? null; } setOpen(open) { this.opts.open.current = open; } toggleOpen() { this.opts.open.current = !this.opts.open.current; } handleOpen() { this.setOpen(true); } handleClose() { this.setHighlightedNode(null); this.setOpen(false); } toggleMenu() { this.toggleOpen(); } getBitsAttr = (part) => { return selectAttrs.getAttr(part, this.isCombobox ? "combobox" : undefined); }; } export class SelectSingleRootState extends SelectBaseRootState { opts; isMulti = false; hasValue = $derived.by(() => this.opts.value.current !== ""); currentLabel = $derived.by(() => { if (!this.opts.items.current.length) return ""; const match = this.opts.items.current.find((item) => item.value === this.opts.value.current)?.label; return match ?? ""; }); candidateLabels = $derived.by(() => { if (!this.opts.items.current.length) return []; const filteredItems = this.opts.items.current.filter((item) => !item.disabled); return filteredItems.map((item) => item.label); }); dataTypeaheadEnabled = $derived.by(() => { if (this.isMulti) return false; if (this.opts.items.current.length === 0) return false; return true; }); constructor(opts) { super(opts); this.opts = opts; $effect(() => { if (!this.opts.open.current && this.highlightedNode) { this.setHighlightedNode(null); } }); watch(() => this.opts.open.current, () => { if (!this.opts.open.current) return; this.setInitialHighlightedNode(); }); } includesItem(itemValue) { return this.opts.value.current === itemValue; } toggleItem(itemValue, itemLabel = itemValue) { this.opts.value.current = this.includesItem(itemValue) ? "" : itemValue; this.opts.inputValue.current = itemLabel; } setInitialHighlightedNode() { afterTick(() => { if (this.highlightedNode && this.domContext.getDocument().contains(this.highlightedNode)) return; if (this.opts.value.current !== "") { const node = this.getNodeByValue(this.opts.value.current); if (node) { this.setHighlightedNode(node, true); return; } } // if no value is set, we want to highlight the first item const firstCandidate = this.getCandidateNodes()[0]; if (!firstCandidate) return; this.setHighlightedNode(firstCandidate, true); }); } } class SelectMultipleRootState extends SelectBaseRootState { opts; isMulti = true; hasValue = $derived.by(() => this.opts.value.current.length > 0); constructor(opts) { super(opts); this.opts = opts; $effect(() => { if (!this.opts.open.current && this.highlightedNode) { this.setHighlightedNode(null); } }); watch(() => this.opts.open.current, () => { if (!this.opts.open.current) return; this.setInitialHighlightedNode(); }); } includesItem(itemValue) { return this.opts.value.current.includes(itemValue); } toggleItem(itemValue, itemLabel = itemValue) { if (this.includesItem(itemValue)) { this.opts.value.current = this.opts.value.current.filter((v) => v !== itemValue); } else { this.opts.value.current = [...this.opts.value.current, itemValue]; } this.opts.inputValue.current = itemLabel; } setInitialHighlightedNode() { afterTick(() => { if (!this.domContext) return; if (this.highlightedNode && this.domContext.getDocument().contains(this.highlightedNode)) return; if (this.opts.value.current.length && this.opts.value.current[0] !== "") { const node = this.getNodeByValue(this.opts.value.current[0]); if (node) { this.setHighlightedNode(node, true); return; } } // if no value is set, we want to highlight the first item const firstCandidate = this.getCandidateNodes()[0]; if (!firstCandidate) return; this.setHighlightedNode(firstCandidate, true); }); } } export class SelectRootState { static create(props) { const { type, ...rest } = props; const rootState = type === "single" ? new SelectSingleRootState(rest) : new SelectMultipleRootState(rest); return SelectRootContext.set(rootState); } } export class SelectInputState { static create(opts) { return new SelectInputState(opts, SelectRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref, (v) => (this.root.inputNode = v)); this.root.domContext = new DOMContext(opts.ref); this.onkeydown = this.onkeydown.bind(this); this.oninput = this.oninput.bind(this); watch([() => this.root.opts.value.current, () => this.opts.clearOnDeselect.current], ([value, clearOnDeselect], [prevValue]) => { if (!clearOnDeselect) return; if (Array.isArray(value) && Array.isArray(prevValue)) { if (value.length === 0 && prevValue.length !== 0) { this.root.opts.inputValue.current = ""; } } else if (value === "" && prevValue !== "") { this.root.opts.inputValue.current = ""; } }); } onkeydown(e) { this.root.isUsingKeyboard = true; if (e.key === kbd.ESCAPE) return; // prevent arrow up/down from moving the position of the cursor in the input if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN) e.preventDefault(); if (!this.root.opts.open.current) { if (INTERACTION_KEYS.includes(e.key)) return; if (e.key === kbd.TAB) return; if (e.key === kbd.BACKSPACE && this.root.opts.inputValue.current === "") return; this.root.handleOpen(); // we need to wait for a tick after the menu opens to ensure the highlighted nodes are // set correctly. if (this.root.hasValue) return; const candidateNodes = this.root.getCandidateNodes(); if (!candidateNodes.length) return; if (e.key === kbd.ARROW_DOWN) { const firstCandidate = candidateNodes[0]; this.root.setHighlightedNode(firstCandidate); } else if (e.key === kbd.ARROW_UP) { const lastCandidate = candidateNodes[candidateNodes.length - 1]; this.root.setHighlightedNode(lastCandidate); } return; } if (e.key === kbd.TAB) { this.root.handleClose(); return; } if (e.key === kbd.ENTER && !e.isComposing) { e.preventDefault(); const isCurrentSelectedValue = this.root.highlightedValue === this.root.opts.value.current; if (!this.root.opts.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) { this.root.handleClose(); return; } if (this.root.highlightedValue) { this.root.toggleItem(this.root.highlightedValue, this.root.highlightedLabel ?? undefined); } if (!this.root.isMulti && !isCurrentSelectedValue) { this.root.handleClose(); } } if (e.key === kbd.ARROW_UP && e.altKey) { this.root.handleClose(); } if (FIRST_LAST_KEYS.includes(e.key)) { e.preventDefault(); const candidateNodes = this.root.getCandidateNodes(); const currHighlightedNode = this.root.highlightedNode; const currIndex = currHighlightedNode ? candidateNodes.indexOf(currHighlightedNode) : -1; const loop = this.root.opts.loop.current; let nextItem; if (e.key === kbd.ARROW_DOWN) { nextItem = next(candidateNodes, currIndex, loop); } else if (e.key === kbd.ARROW_UP) { nextItem = prev(candidateNodes, currIndex, loop); } else if (e.key === kbd.PAGE_DOWN) { nextItem = forward(candidateNodes, currIndex, 10, loop); } else if (e.key === kbd.PAGE_UP) { nextItem = backward(candidateNodes, currIndex, 10, loop); } else if (e.key === kbd.HOME) { nextItem = candidateNodes[0]; } else if (e.key === kbd.END) { nextItem = candidateNodes[candidateNodes.length - 1]; } if (!nextItem) return; this.root.setHighlightedNode(nextItem); return; } if (INTERACTION_KEYS.includes(e.key)) return; if (!this.root.highlightedNode) { this.root.setHighlightedToFirstCandidate(); } } oninput(e) { this.root.opts.inputValue.current = e.currentTarget.value; } props = $derived.by(() => ({ id: this.opts.id.current, role: "combobox", disabled: this.root.opts.disabled.current ? true : undefined, "aria-activedescendant": this.root.highlightedId, "aria-autocomplete": "list", "aria-expanded": getAriaExpanded(this.root.opts.open.current), "data-state": getDataOpenClosed(this.root.opts.open.current), "data-disabled": getDataDisabled(this.root.opts.disabled.current), onkeydown: this.onkeydown, oninput: this.oninput, [this.root.getBitsAttr("input")]: "", ...this.attachment, })); } export class SelectComboTriggerState { static create(opts) { return new SelectComboTriggerState(opts, SelectRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); this.onkeydown = this.onkeydown.bind(this); this.onpointerdown = this.onpointerdown.bind(this); } onkeydown(e) { if (!this.root.domContext) return; if (e.key === kbd.ENTER || e.key === kbd.SPACE) { e.preventDefault(); if (this.root.domContext.getActiveElement() !== this.root.inputNode) { this.root.inputNode?.focus(); } this.root.toggleMenu(); } } /** * `pointerdown` fires before the `focus` event, so we can prevent the default * behavior of focusing the button and keep focus on the input. */ onpointerdown(e) { if (this.root.opts.disabled.current || !this.root.domContext) return; e.preventDefault(); if (this.root.domContext.getActiveElement() !== this.root.inputNode) { this.root.inputNode?.focus(); } this.root.toggleMenu(); } props = $derived.by(() => ({ id: this.opts.id.current, disabled: this.root.opts.disabled.current ? true : undefined, "aria-haspopup": "listbox", "data-state": getDataOpenClosed(this.root.opts.open.current), "data-disabled": getDataDisabled(this.root.opts.disabled.current), [this.root.getBitsAttr("trigger")]: "", onpointerdown: this.onpointerdown, onkeydown: this.onkeydown, ...this.attachment, })); } export class SelectTriggerState { static create(opts) { return new SelectTriggerState(opts, SelectRootContext.get()); } opts; root; attachment; #domTypeahead; #dataTypeahead; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref, (v) => (this.root.triggerNode = v)); this.root.domContext = new DOMContext(opts.ref); this.#domTypeahead = new DOMTypeahead({ getCurrentItem: () => this.root.highlightedNode, onMatch: (node) => { this.root.setHighlightedNode(node); }, getActiveElement: () => this.root.domContext.getActiveElement(), getWindow: () => this.root.domContext.getWindow(), }); this.#dataTypeahead = new DataTypeahead({ getCurrentItem: () => { if (this.root.isMulti) return ""; return this.root.currentLabel; }, onMatch: (label) => { if (this.root.isMulti) return; if (!this.root.opts.items.current) return; const matchedItem = this.root.opts.items.current.find((item) => item.label === label); if (!matchedItem) return; this.root.opts.value.current = matchedItem.value; }, enabled: () => !this.root.isMulti && this.root.dataTypeaheadEnabled, candidateValues: () => (this.root.isMulti ? [] : this.root.candidateLabels), getWindow: () => this.root.domContext.getWindow(), }); this.onkeydown = this.onkeydown.bind(this); this.onpointerdown = this.onpointerdown.bind(this); this.onpointerup = this.onpointerup.bind(this); this.onclick = this.onclick.bind(this); } #handleOpen() { this.root.opts.open.current = true; this.#dataTypeahead.resetTypeahead(); this.#domTypeahead.resetTypeahead(); } #handlePointerOpen(_) { this.#handleOpen(); } /** * Logic used to handle keyboard selection/deselection. * * If it returns true, it means the item was selected and whatever is calling * this function should return early * */ #handleKeyboardSelection() { const isCurrentSelectedValue = this.root.highlightedValue === this.root.opts.value.current; if (!this.root.opts.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) { this.root.handleClose(); return true; } // "" is a valid value for a select item so we need to check for that if (this.root.highlightedValue !== null) { this.root.toggleItem(this.root.highlightedValue, this.root.highlightedLabel ?? undefined); } if (!this.root.isMulti && !isCurrentSelectedValue) { this.root.handleClose(); return true; } return false; } onkeydown(e) { this.root.isUsingKeyboard = true; if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN) e.preventDefault(); if (!this.root.opts.open.current) { if (e.key === kbd.ENTER || e.key === kbd.SPACE || e.key === kbd.ARROW_DOWN || e.key === kbd.ARROW_UP) { e.preventDefault(); this.root.handleOpen(); } else if (!this.root.isMulti && this.root.dataTypeaheadEnabled) { this.#dataTypeahead.handleTypeaheadSearch(e.key); return; } // we need to wait for a tick after the menu opens to ensure // the highlighted nodes are set correctly if (this.root.hasValue) return; const candidateNodes = this.root.getCandidateNodes(); if (!candidateNodes.length) return; if (e.key === kbd.ARROW_DOWN) { const firstCandidate = candidateNodes[0]; this.root.setHighlightedNode(firstCandidate); } else if (e.key === kbd.ARROW_UP) { const lastCandidate = candidateNodes[candidateNodes.length - 1]; this.root.setHighlightedNode(lastCandidate); } return; } if (e.key === kbd.TAB) { this.root.handleClose(); return; } if ((e.key === kbd.ENTER || // if we're currently "typing ahead", we don't want to select the item // just yet as the item the user is trying to get to may have a space in it, // so we defer handling the close for this case until further down (e.key === kbd.SPACE && this.#domTypeahead.search === "")) && !e.isComposing) { e.preventDefault(); const shouldReturn = this.#handleKeyboardSelection(); if (shouldReturn) return; } if (e.key === kbd.ARROW_UP && e.altKey) { this.root.handleClose(); } if (FIRST_LAST_KEYS.includes(e.key)) { e.preventDefault(); const candidateNodes = this.root.getCandidateNodes(); const currHighlightedNode = this.root.highlightedNode; const currIndex = currHighlightedNode ? candidateNodes.indexOf(currHighlightedNode) : -1; const loop = this.root.opts.loop.current; let nextItem; if (e.key === kbd.ARROW_DOWN) { nextItem = next(candidateNodes, currIndex, loop); } else if (e.key === kbd.ARROW_UP) { nextItem = prev(candidateNodes, currIndex, loop); } else if (e.key === kbd.PAGE_DOWN) { nextItem = forward(candidateNodes, currIndex, 10, loop); } else if (e.key === kbd.PAGE_UP) { nextItem = backward(candidateNodes, currIndex, 10, loop); } else if (e.key === kbd.HOME) { nextItem = candidateNodes[0]; } else if (e.key === kbd.END) { nextItem = candidateNodes[candidateNodes.length - 1]; } if (!nextItem) return; this.root.setHighlightedNode(nextItem); return; } const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; const isCharacterKey = e.key.length === 1; const isSpaceKey = e.key === kbd.SPACE; const candidateNodes = this.root.getCandidateNodes(); if (e.key === kbd.TAB) return; if (!isModifierKey && (isCharacterKey || isSpaceKey)) { const matchedNode = this.#domTypeahead.handleTypeaheadSearch(e.key, candidateNodes); if (!matchedNode && isSpaceKey) { e.preventDefault(); this.#handleKeyboardSelection(); } return; } if (!this.root.highlightedNode) { this.root.setHighlightedToFirstCandidate(); } } onclick(e) { // While browsers generally have no issue focusing the trigger when clicking // on a label, Safari seems to struggle with the fact that there's no `onClick`. // We force `focus` in this case. Note: this doesn't create any other side-effect // because we are preventing default in `onpointerdown` so effectively // this only runs for a label 'click' const currTarget = e.currentTarget; currTarget.focus(); } onpointerdown(e) { if (this.root.opts.disabled.current) return; // prevent opening on touch down which can be triggered when scrolling on touch devices if (e.pointerType === "touch") return e.preventDefault(); // prevent implicit pointer capture const target = e.target; if (target?.hasPointerCapture(e.pointerId)) { target?.releasePointerCapture(e.pointerId); } // only call the handle if it's a left click, since pointerdown is triggered // by right clicks as well, but not when ctrl is pressed if (e.button === 0 && e.ctrlKey === false) { if (this.root.opts.open.current === false) { this.#handlePointerOpen(e); } else { this.root.handleClose(); } } } onpointerup(e) { if (this.root.opts.disabled.current) return; e.preventDefault(); if (e.pointerType === "touch") { if (this.root.opts.open.current === false) { this.#handlePointerOpen(e); } else { this.root.handleClose(); } } } props = $derived.by(() => ({ id: this.opts.id.current, disabled: this.root.opts.disabled.current ? true : undefined, "aria-haspopup": "listbox", "aria-expanded": getAriaExpanded(this.root.opts.open.current), "aria-activedescendant": this.root.highlightedId, "data-state": getDataOpenClosed(this.root.opts.open.current), "data-disabled": getDataDisabled(this.root.opts.disabled.current), "data-placeholder": this.root.hasValue ? undefined : "", [this.root.getBitsAttr("trigger")]: "", onpointerdown: this.onpointerdown, onkeydown: this.onkeydown, onclick: this.onclick, onpointerup: this.onpointerup, ...this.attachment, })); } export class SelectContentState { static create(opts) { return SelectContentContext.set(new SelectContentState(opts, SelectRootContext.get())); } opts; root; attachment; viewportNode = $state(null); isPositioned = $state(false); domContext; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref, (v) => (this.root.contentNode = v)); this.domContext = new DOMContext(this.opts.ref); if (this.root.domContext === null) { this.root.domContext = this.domContext; } onDestroyEffect(() => { this.root.contentNode = null; this.isPositioned = false; }); watch(() => this.root.opts.open.current, () => { if (this.root.opts.open.current) return; this.isPositioned = false; }); this.onpointermove = this.onpointermove.bind(this); } onpointermove(_) { this.root.isUsingKeyboard = false; } #styles = $derived.by(() => { return getFloatingContentCSSVars(this.root.isCombobox ? "combobox" : "select"); }); onInteractOutside = (e) => { if (e.target === this.root.triggerNode || e.target === this.root.inputNode) { e.preventDefault(); return; } this.opts.onInteractOutside.current(e); if (e.defaultPrevented) return; this.root.handleClose(); }; onEscapeKeydown = (e) => { this.opts.onEscapeKeydown.current(e); if (e.defaultPrevented) return; this.root.handleClose(); }; onOpenAutoFocus = (e) => { e.preventDefault(); }; onCloseAutoFocus = (e) => { e.preventDefault(); }; snippetProps = $derived.by(() => ({ open: this.root.opts.open.current })); props = $derived.by(() => ({ id: this.opts.id.current, role: "listbox", "aria-multiselectable": this.root.isMulti ? "true" : undefined, "data-state": getDataOpenClosed(this.root.opts.open.current), [this.root.getBitsAttr("content")]: "", style: { display: "flex", flexDirection: "column", outline: "none", boxSizing: "border-box", pointerEvents: "auto", ...this.#styles, }, onpointermove: this.onpointermove, ...this.attachment, })); popperProps = { onInteractOutside: this.onInteractOutside, onEscapeKeydown: this.onEscapeKeydown, onOpenAutoFocus: this.onOpenAutoFocus, onCloseAutoFocus: this.onCloseAutoFocus, trapFocus: false, loop: false, onPlaced: () => { // onPlaced is also called when the menu is closed, so we need to check if the menu // is actually open to avoid setting positioning to true when the menu is closed if (this.root.opts.open.current) { this.isPositioned = true; } }, }; } export class SelectItemState { static create(opts) { return new SelectItemState(opts, SelectRootContext.get()); } opts; root; attachment; isSelected = $derived.by(() => this.root.includesItem(this.opts.value.current)); isHighlighted = $derived.by(() => this.root.highlightedValue === this.opts.value.current); prevHighlighted = new Previous(() => this.isHighlighted); mounted = $state(false); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); onMountEffect(() => { this.root.setHighlightedToFirstCandidate({ debounced: true }); }); onDestroyEffect(() => { this.root.setHighlightedToFirstCandidate({ debounced: true }); }); watch([() => this.isHighlighted, () => this.prevHighlighted.current], () => { if (this.isHighlighted) { this.opts.onHighlight.current(); } else if (this.prevHighlighted.current) { this.opts.onUnhighlight.current(); } }); watch(() => this.mounted, () => { if (!this.mounted) return; this.root.setInitialHighlightedNode(); }); this.onpointerdown = this.onpointerdown.bind(this); this.onpointerup = this.onpointerup.bind(this); this.onpointermove = this.onpointermove.bind(this); } handleSelect() { if (this.opts.disabled.current) return; const isCurrentSelectedValue = this.opts.value.current === this.root.opts.value.current; // if allowDeselect is false and the item is already selected and we're not in a // multi select, do nothing and close the menu if (!this.root.opts.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) { this.root.handleClose(); return; } // otherwise, toggle the item and if we're not in a multi select, close the menu this.root.toggleItem(this.opts.value.current, this.opts.label.current); if (!this.root.isMulti && !isCurrentSelectedValue) { this.root.handleClose(); } } snippetProps = $derived.by(() => ({ selected: this.isSelected, highlighted: this.isHighlighted, })); onpointerdown(e) { // prevent focus from leaving the input/select trigger e.preventDefault(); } /** * Using `pointerup` instead of `click` allows power users to pointerdown * the trigger, then release pointerup on an item to select it vs having to do * multiple clicks. */ onpointerup(e) { if (e.defaultPrevented || !this.opts.ref.current) return; // prevent any default behavior /** * For one reason or another, when it's a touch pointer and _not_ on IOS, * we need to listen for the immediate click event to handle the selection, * otherwise a click event will fire on the element _behind_ the item. */ if (e.pointerType === "touch" && !isIOS) { on(this.opts.ref.current, "click", () => { this.handleSelect(); // set highlighted node since we don't do it on `pointermove` events // for touch devices this.root.setHighlightedNode(this.opts.ref.current); }, { once: true }); return; } e.preventDefault(); this.handleSelect(); if (e.pointerType === "touch") { // set highlighted node since we don't do it on `pointermove` events // for touch devices this.root.setHighlightedNode(this.opts.ref.current); } } onpointermove(e) { /** * We don't want to highlight items on touch devices when scrolling, * as this is confusing behavior, so we return here and instead handle * the highlighting on the `pointerup` (or following `click`) event for * touch devices only. */ if (e.pointerType === "touch") return; if (this.root.highlightedNode !== this.opts.ref.current) { this.root.setHighlightedNode(this.opts.ref.current); } } props = $derived.by(() => ({ id: this.opts.id.current, role: "option", "aria-selected": this.root.includesItem(this.opts.value.current) ? "true" : undefined, "data-value": this.opts.value.current, "data-disabled": getDataDisabled(this.opts.disabled.current), "data-highlighted": this.root.highlightedValue === this.opts.value.current && !this.opts.disabled.current ? "" : undefined, "data-selected": this.root.includesItem(this.opts.value.current) ? "" : undefined, "data-label": this.opts.label.current, [this.root.getBitsAttr("item")]: "", onpointermove: this.onpointermove, onpointerdown: this.onpointerdown, onpointerup: this.onpointerup, ...this.attachment, })); } export class SelectGroupState { static create(opts) { return SelectGroupContext.set(new SelectGroupState(opts, SelectRootContext.get())); } opts; root; labelNode = $state(null); attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, role: "group", [this.root.getBitsAttr("group")]: "", "aria-labelledby": this.labelNode?.id ?? undefined, ...this.attachment, })); } export class SelectGroupHeadingState { static create(opts) { return new SelectGroupHeadingState(opts, SelectGroupContext.get()); } opts; group; attachment; constructor(opts, group) { this.opts = opts; this.group = group; this.attachment = attachRef(opts.ref, (v) => (this.group.labelNode = v)); } props = $derived.by(() => ({ id: this.opts.id.current, [this.group.root.getBitsAttr("group-label")]: "", ...this.attachment, })); } export class SelectHiddenInputState { static create(opts) { return new SelectHiddenInputState(opts, SelectRootContext.get()); } opts; root; shouldRender = $derived.by(() => this.root.opts.name.current !== ""); constructor(opts, root) { this.opts = opts; this.root = root; this.onfocus = this.onfocus.bind(this); } onfocus(e) { e.preventDefault(); if (!this.root.isCombobox) { this.root.triggerNode?.focus(); } else { this.root.inputNode?.focus(); } } props = $derived.by(() => ({ disabled: getDisabled(this.root.opts.disabled.current), required: getRequired(this.root.opts.required.current), name: this.root.opts.name.current, value: this.opts.value.current, onfocus: this.onfocus, })); } export class SelectViewportState { static create(opts) { return new SelectViewportState(opts, SelectContentContext.get()); } opts; content; root; attachment; prevScrollTop = $state(0); constructor(opts, content) { this.opts = opts; this.content = content; this.root = content.root; this.attachment = attachRef(opts.ref, (v) => (this.content.viewportNode = v)); } props = $derived.by(() => ({ id: this.opts.id.current, role: "presentation", [this.root.getBitsAttr("viewport")]: "", style: { // we use position: 'relative' here on the `viewport` so that when we call // `selectedItem.offsetTop` in calculations, the offset is relative to the viewport // (independent of the scrollUpButton). position: "relative", flex: 1, overflow: "auto", }, ...this.attachment, })); } export class SelectScrollButtonImplState { opts; content; root; attachment; autoScrollTimer = null; userScrollTimer = -1; isUserScrolling = false; onAutoScroll = noop; mounted = $state(false); constructor(opts, content) { this.opts = opts; this.content = content; this.root = content.root; this.attachment = attachRef(opts.ref); watch([() => this.mounted], () => { if (!this.mounted) { this.isUserScrolling = false; return; } if (this.isUserScrolling) return; }); $effect(() => { if (this.mounted) return; this.clearAutoScrollInterval(); }); this.onpointerdown = this.onpointerdown.bind(this); this.onpointermove = this.onpointermove.bind(this); this.onpointerleave = this.onpointerleave.bind(this); } handleUserScroll() { this.content.domContext.clearTimeout(this.userScrollTimer); this.isUserScrolling = true; this.userScrollTimer = this.content.domContext.setTimeout(() => { this.isUserScrolling = false; }, 200); } clearAutoScrollInterval() { if (this.autoScrollTimer === null) return; this.content.domContext.clearTimeout(this.autoScrollTimer); this.autoScrollTimer = null; } onpointerdown(_) { if (this.autoScrollTimer !== null) return; const autoScroll = (tick) => { this.onAutoScroll(); this.autoScrollTimer = this.content.domContext.setTimeout(() => autoScroll(tick + 1), this.opts.delay.current(tick)); }; this.autoScrollTimer = this.content.domContext.setTimeout(() => autoScroll(1), this.opts.delay.current(0)); } onpointermove(e) { this.onpointerdown(e); } onpointerleave(_) { this.clearAutoScrollInterval(); } props = $derived.by(() => ({ id: this.opts.id.current, "aria-hidden": getAriaHidden(true), style: { flexShrink: 0, }, onpointerdown: this.onpointerdown, onpointermove: this.onpointermove, onpointerleave: this.onpointerleave, ...this.attachment, })); } export class SelectScrollDownButtonState { static create(opts) { return new SelectScrollDownButtonState(new SelectScrollButtonImplState(opts, SelectContentContext.get())); } scrollButtonState; content; root; canScrollDown = $state(false); scrollIntoViewTimer = null; constructor(scrollButtonState) { this.scrollButtonState = scrollButtonState; this.content = scrollButtonState.content; this.root = scrollButtonState.root; this.scrollButtonState.onAutoScroll = this.handleAutoScroll; watch([() => this.content.viewportNode, () => this.content.isPositioned], () => { if (!this.content.viewportNode || !this.content.isPositioned) return; this.handleScroll(true); return on(this.content.viewportNode, "scroll", () => this.handleScroll()); }); watch(() => this.scrollButtonState.mounted, () => { if (!this.scrollButtonState.mounted) return; if (this.scrollIntoViewTimer) { clearTimeout(this.scrollIntoViewTimer); } this.scrollIntoViewTimer = afterSleep(5, () => { const activeItem = this.root.highlightedNode; activeItem?.scrollIntoView({ block: this.root.opts.scrollAlignment.current }); }); }); } /** * @param manual - if true, it means the function was invoked manually outside of an event * listener, so we don't call `handleUserScroll` to prevent the auto scroll from kicking in. */ handleScroll = (manual = false) => { if (!manual) { this.scrollButtonState.handleUserScroll(); } if (!this.content.viewportNode) return; const maxScroll = this.content.viewportNode.scrollHeight - this.content.viewportNode.clientHeight; const paddingTop = Number.parseInt(getComputedStyle(this.content.viewportNode).paddingTop, 10); this.canScrollDown = Math.ceil(this.content.viewportNode.scrollTop) < maxScroll - paddingTop; }; handleAutoScroll = () => { const viewport = this.content.viewportNode; const selectedItem = this.root.highlightedNode; if (!viewport || !selectedItem) return; viewport.scrollTop = viewport.scrollTop + selectedItem.offsetHeight; }; props = $derived.by(() => ({ ...this.scrollButtonState.props, [this.root.getBitsAttr("scroll-down-button")]: "", })); } export class SelectScrollUpButtonState { static create(opts) { return new SelectScrollUpButtonState(new SelectScrollButtonImplState(opts, SelectContentContext.get())); } scrollButtonState; content; root; canScrollUp = $state(false); constructor(scrollButtonState) { this.scrollButtonState = scrollButtonState; this.content = scrollButtonState.content; this.root = scrollButtonState.root; this.scrollButtonState.onAutoScroll = this.handleAutoScroll; watch([() => this.content.viewportNode, () => this.content.isPositioned], () => { if (!this.content.viewportNode || !this.content.isPositioned) return; this.handleScroll(true); return on(this.content.viewportNode, "scroll", () => this.handleScroll()); }); } /** * @param manual - if true, it means the function was invoked manually outside of an event * listener, so we don't call `handleUserScroll` to prevent the auto scroll from kicking in. */ handleScroll = (manual = false) => { if (!manual) { this.scrollButtonState.handleUserScroll(); } if (!this.content.viewportNode) return; const paddingTop = Number.parseInt(getComputedStyle(this.content.viewportNode).paddingTop, 10); this.canScrollUp = this.content.viewportNode.scrollTop - paddingTop > 0.1; }; handleAutoScroll = () => { if (!this.content.viewportNode || !this.root.highlightedNode) return; this.content.viewportNode.scrollTop = this.content.viewportNode.scrollTop - this.root.highlightedNode.offsetHeight; }; props = $derived.by(() => ({ ...this.scrollButtonState.props, [this.root.getBitsAttr("scroll-up-button")]: "", })); }