bits-ui
Version:
The headless components for Svelte.
1,178 lines (1,177 loc) • 44.3 kB
JavaScript
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")]: "",
}));
}