UNPKG

adwaveui

Version:

Interactive Web Components inspired by the Gtk Adwaita theme.

802 lines (800 loc) 23.6 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); // src/components/selector/selector.tsx import "../../base-elements.mjs"; import { sig } from "@ncpa0cpl/vanilla-jsx/signals"; import { Selector } from "adwavecss"; import { customElement } from "wc_toolkit"; import { arrEq } from "../../utils/cmp-arrray.mjs"; import { debounced } from "../../utils/debounced.mjs"; import { Enum } from "../../utils/enum-attribute.mjs"; import { CustomKeyboardEvent, CustomMouseEvent } from "../../utils/events.mjs"; import { getUid } from "../../utils/get-uid.mjs"; import { stopEvent } from "../../utils/prevent-default.mjs"; import { AdwSelectorChangeEvent, OptionAttributeChangeEvent, OptionContentChangeEvent } from "./events.mjs"; import { AdwSelectorOption } from "./option.mjs"; import { jsx, jsxs } from "@ncpa0cpl/vanilla-jsx/jsx-runtime"; var IS_MOBILE = typeof navigator !== "undefined" && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ); var SEARCHABLE_CHARS = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+=-[]\\{}|;':",./<>?`.split( "" ); var FOCUS_CHANGE_EVENT_THROTTLE = 60; var { CustomElement } = customElement("adw-selector").attributes({ placeholder: "string", disabled: "boolean", name: "string", form: "string", value: "string", defaultValue: "string", allowUnselect: "boolean", unselectLabel: "string", /** * In which direction the dropdown should open. * - `up` - The dropdown will open above the selector. * - `down` - The dropdown will open below the selector. * - `detect` - The dropdown will try to detect if it has * enough space to open `down`, if not it will open `up`. * * Default: `down` */ orientation: Enum(["up", "down", "detect"]), /** * When enabled, the options will be displayed in reversed order. */ reverseOrder: "boolean", /** * When enabled, the selected option will be scrolled into view when the dropdown opens. */ scrollIntoViewOnOpen: "boolean" }, { scrollIntoViewOnOpen: { htmlName: "scrollintoview" } }).events({ "change": AdwSelectorChangeEvent, "click": CustomMouseEvent, "keydown": CustomKeyboardEvent, "open": Event, "close": Event }).context(({ value }) => { const options = sig([]); const optionsDep = sig(0); const optionPreview = sig.derive( options, value.signal, (options2, value2) => { return options2.find( (option) => !option.inert && option.isEqualTo(value2) )?.getLabel(); } ); return { open: sig(false), options, optionsDep, optionPreview, uid: getUid(), searchInputMemory: "", clearSearchInputMemoryTimeout: void 0, optionsList: void 0, innerDialog: void 0, lastFocusChange: 0 }; }).methods((wc) => { const { attribute: { value, orientation, disabled, allowUnselect }, context } = wc; return { isOpen() { return context.open.get(); }, /** * Open or closes the dropdown, depending on it's current state. */ toggle() { context.open.dispatch((open) => !open); }, getSelectedOption() { const currentValue = value.get(); return context.options.get().find((opt) => opt.isEqualTo(currentValue)); }, scrollToOption(value2, behavior = "instant") { if (!context.optionsList || value2 == null) { return; } const allOptElems = Array.from( context.optionsList.querySelectorAll("button.option") ); const activeOptionElem = allOptElems.find( (btn) => btn.dataset.option === value2 ); if (activeOptionElem) { context.optionsList.scrollTo({ top: activeOptionElem.offsetTop - context.optionsList.clientHeight / 2, behavior }); activeOptionElem.focus(); } }, focus() { wc.thisElement.querySelector(`.${Selector.selector}`)?.focus(); }, select(optionValue) { const options = context.options.get(); if (optionValue == null) { if (allowUnselect.get()) { const prevValue = value.get(); for (let i = 0; i < options.length; i++) { const option = options[i]; option.setSelected(false); } value.set(null); return prevValue != null; } else { return false; } } let success = false; for (let i = 0; i < options.length; i++) { const option = options[i]; const isSelected = option.isEqualTo(optionValue); if (isSelected) { value.set(option.value); option.setSelected(true); success = true; } else { option.setSelected(false); } } return success; }, /** * Selects the option that is offset from the currently focused * option. (e.g. focusOption(1) should select the option following * the currently focused one whereas focusOption(-2) should select * the second option that's behind the focused option) */ focusOption(offset) { if (!context.optionsList) { return; } let currentOption = context.optionsList.querySelector( `.${Selector.option}:focus` ); if (!currentOption) { currentOption = context.optionsList.querySelector( `.${Selector.option}.selected` ); } if (!currentOption) { const reverse = orientation.get() === "up"; const firstOption = context.optionsList.querySelector( reverse ? `.${Selector.option}:nth-last-child(1 of :not(.inert))` : `.${Selector.option}:nth-child(1 of :not(.inert))` ); firstOption?.focus(); return; } let target = currentOption; const direction = offset > 0 ? "nextElementSibling" : "previousElementSibling"; mainloop: for (let i = 0; i < Math.abs(offset); i++) { let next = target[direction]; if (!next) { break; } while (next.classList.contains("inert")) { next = next[direction]; if (!next) { break mainloop; } } target = next; } if (target) { target.focus(); } }, _forceOptionsRerender: debounced(() => { context.optionsDep.dispatch((v) => v + 1 % 128); }), _setSelectedByValue(options, value2) { let selected; for (let i = 0; i < options.length; i++) { const opt = options[i]; if (opt.isEqualTo(value2)) { selected = opt; } else { opt.selected = false; } } if (selected) { selected.selected = true; } return selected; }, _updateSelectableOptions(children, forceDispatch = false) { const currentValue = value.get(); const options = children.filter( (child) => child instanceof AdwSelectorOption ); const selected = options.filter((opt) => opt.selected && !opt.inert); if (!(selected.length === 0 && currentValue == null)) { if (selected.length > 0 && currentValue != null) { if (!(selected.length === 1 && selected[0].isEqualTo(currentValue))) { const selectedOpt = this._setSelectedByValue( options, currentValue ); if (!selectedOpt) { value.unset(); } } } else if (selected.length === 0 && currentValue != null) { const selectedOpt = this._setSelectedByValue( options, currentValue ); if (!selectedOpt) { value.unset(); } } else if (selected.length > 0 && currentValue == null) { const selectedOpt = selected.pop(); for (let i = 0; i < selected.length; i++) { selected[i].selected = false; } value.set(selectedOpt.value); } } if (forceDispatch || !arrEq(context.options.get(), options)) { context.options.dispatch(options); } }, _tryInputSearch() { if (context.searchInputMemory.length === 0) return; const searchTerm = context.searchInputMemory.toLowerCase(); const options = context.options.get(); let foundOpt = options.find((opt) => { const label = opt.getLabel().toLowerCase(); return label.startsWith(searchTerm); }); if (!foundOpt) { foundOpt = options.find((opt) => { const label = opt.getLabel().toLowerCase(); return label.includes(searchTerm); }); } if (foundOpt) { this.scrollToOption(foundOpt.getValue(), "smooth"); } }, _handleClick(e) { e.preventDefault(); e.stopPropagation(); if (disabled.get()) { return; } wc.emitEvent("click", { type: "selector" }, e).onCommit(() => { this.toggle(); }); }, _handleDialogClick(e) { e.preventDefault(); e.stopPropagation(); wc.emitEvent("click", { type: "dialog" }, e).onCommit(() => { if (context.open.get() && !context.optionsList?.contains(e.target)) { context.open.dispatch(false); } }); }, _handleOptionClick(e) { e.preventDefault(); e.stopPropagation(); const btn = e.currentTarget; const { option: optValue } = btn?.dataset ?? {}; wc.emitEvent( "click", { type: "option", option: optValue }, e ).onCommit(() => { if (disabled.get() || optValue == null) { return; } const success = this.select(optValue); if (success) { wc.emitEvent("change", value.get()); context.open.dispatch(false); this.focus(); } }); }, _handleUnselect(e) { e.preventDefault(); e.stopPropagation(); wc.emitEvent( "click", { type: "option", option: void 0 }, e ).onCommit(() => { if (disabled.get()) { return; } const success = this.select(void 0); if (success) { wc.emitEvent("change", value.get()); context.open.dispatch(false); this.focus(); } }); }, _handleModalCancel(e) { context.open.dispatch(false); }, _handleKeyDown(ev) { if (disabled.get()) { return; } switch (ev.key) { case " ": case "Enter": { ev.stopPropagation(); ev.preventDefault(); wc.emitEvent("keydown", {}, ev).onCommit(() => { if (!context.open.get()) { context.open.dispatch(true); } else { const target = ev.target; if (target.tagName === "BUTTON") { if (ev.key === "Enter") target.click(); } else { context.open.dispatch(false); } } }); break; } case "ArrowUp": { ev.stopPropagation(); ev.preventDefault(); this._withFocusChangeEvent(() => { wc.emitEvent("keydown", {}, ev).onCommit(() => { this.focusOption(-1); }); }); break; } case "ArrowDown": { ev.stopPropagation(); ev.preventDefault(); this._withFocusChangeEvent(() => { wc.emitEvent("keydown", {}, ev).onCommit(() => { this.focusOption(1); }); }); break; } case "PageUp": { ev.stopPropagation(); ev.preventDefault(); this._withFocusChangeEvent(() => { wc.emitEvent("keydown", {}, ev).onCommit(() => { this.focusOption(-10); }); }); break; } case "PageDown": { ev.stopPropagation(); ev.preventDefault(); this._withFocusChangeEvent(() => { wc.emitEvent("keydown", {}, ev).onCommit(() => { this.focusOption(10); }); }); break; } case "Home": { ev.stopPropagation(); ev.preventDefault(); this._withFocusChangeEvent(() => { wc.emitEvent("keydown", {}, ev).onCommit(() => { this.focusOption(-context.options.get().length); }); }); break; } case "End": { ev.stopPropagation(); ev.preventDefault(); this._withFocusChangeEvent(() => { wc.emitEvent("keydown", {}, ev).onCommit(() => { this.focusOption(+context.options.get().length); }); }); break; } case "Escape": { ev.stopPropagation(); ev.preventDefault(); wc.emitEvent("keydown", {}, ev).onCommit(() => { if (context.open.get()) { context.open.dispatch(false); this.focus(); } }); break; } default: if (SEARCHABLE_CHARS.includes(ev.key)) { context.searchInputMemory += ev.key; window.clearTimeout(context.clearSearchInputMemoryTimeout); context.clearSearchInputMemoryTimeout = window.setTimeout(() => { context.searchInputMemory = ""; }, 1e3); this._tryInputSearch(); } break; } }, _withFocusChangeEvent(handler) { const now = Date.now(); if (now - context.lastFocusChange > FOCUS_CHANGE_EVENT_THROTTLE) { context.lastFocusChange = now; handler(); } } }; }).connected((wc) => { const { context, method, attribute: { value, disabled, form, name, orientation, placeholder, reverseOrder, scrollIntoViewOnOpen, defaultValue, allowUnselect, unselectLabel } } = wc; const forcedPosition = sig(); wc.listen( OptionAttributeChangeEvent.EVNAME, (event) => { switch (event.attributeName) { case "selected": { const opt = event.target; if (opt.selected && opt.value != null) { value.set(opt.value); } break; } case "value": { const opt = event.target; const selectedOpt = method.getSelectedOption(); if (opt.selected && opt === selectedOpt) { value.set(opt.value); } method._updateSelectableOptions(wc.getChildren(), true); break; } case "inert": { const opt = event.target; const selectedOpt = method.getSelectedOption(); if (opt.selected && opt === selectedOpt) { value.unset(); opt.selected = false; } method._forceOptionsRerender(); break; } } } ); wc.listen( OptionContentChangeEvent.EVNAME, (event) => { method._forceOptionsRerender(); } ); wc.onChildrenChange((children) => { method._updateSelectableOptions(children); }); wc.onReady(() => { if (value.get() == null && defaultValue.get() != null) { method.select(defaultValue.get()); } }); const globalClickListener = wc.listenDocument( "click", (event) => { if (!wc.thisElement.contains(event.target)) { context.open.dispatch(false); globalClickListener.disable(); } }, { initEnabled: false } ); wc.onChange([value], () => { method._updateSelectableOptions(wc.getChildren()); }); wc.onChange([context.open], () => { globalClickListener.disable(); if (context.open.get()) { if (!IS_MOBILE) { if (orientation.get() === "detect") { const rect = rootElem.getBoundingClientRect(); const distanceToBottom = window.innerHeight - rect.bottom; const fontSize = getComputedStyle(rootElem).fontSize; const emSize = Number(fontSize.replace("px", "")); const maxTargetHeight = Math.min( // 20em 20 * emSize, // 80vh 0.8 * window.innerHeight ); const targetHeight = Math.min( maxTargetHeight, context.options.get().length * (1.9 * emSize) ); if (distanceToBottom < targetHeight) { forcedPosition.dispatch("up"); } else { forcedPosition.dispatch("down"); } } } if (context.optionsList) { const reverse = (forcedPosition.get() ?? orientation.get()) === "up"; if (value.get() != null) { method.scrollToOption(value.get()); } else { context.optionsList.scrollTo({ top: reverse ? context.optionsList.scrollHeight : 0, behavior: "instant" }); } } if (!IS_MOBILE) { globalClickListener.enable(); if (scrollIntoViewOnOpen.get() && context.optionsList) { setTimeout(() => { const selectedButton = context.optionsList?.querySelector( ".option.selected" ); if (context.open.get() && selectedButton) { selectedButton.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }, 201); } } } if (context.open.get()) { wc.emitEvent("open"); } else { wc.emitEvent("close"); } }); const Option = /* @__PURE__ */ __name((props) => { const isSelected = value.signal.derive((v) => props.option.isEqualTo(v)); const isInert = props.option.inert; if (isInert) { return /* @__PURE__ */ jsxs( "button", { class: [Selector.option, "inert"], role: "presentation", onclick: stopEvent, children: [ /* @__PURE__ */ jsx("span", {}), /* @__PURE__ */ jsx("span", { class: "opt-label", children: props.option.getLabel() }), /* @__PURE__ */ jsx("span", {}) ] } ); } const elem = /* @__PURE__ */ jsx( "button", { class: { [Selector.option]: true, selected: isSelected }, onclick: method._handleOptionClick, "data-option": props.option.getValue(), role: "option", "aria-selected": isSelected, children: props.option.getLabel() } ); return elem; }, "Option"); const UnselectOption = /* @__PURE__ */ __name(() => { const isSelected = value.signal.derive((v) => v == null); return /* @__PURE__ */ jsx( "button", { class: { [Selector.option]: true, selected: isSelected, unselect: true }, onclick: method._handleUnselect, role: "option", "aria-selected": isSelected, children: sig.nuc( unselectLabel.signal, placeholder.signal, "Select option" ) } ); }, "UnselectOption"); const OptionsListMobile = /* @__PURE__ */ __name(() => { context.optionsList = /* @__PURE__ */ jsxs( "div", { id: context.uid, class: [Selector.optionsList, Selector.noPosition], role: "listbox", children: [ allowUnselect.signal.derive((allowUnselect2) => { if (!allowUnselect2) return null; return /* @__PURE__ */ jsx(UnselectOption, {}); }), sig.derive( context.options, reverseOrder.signal, context.optionsDep, (options, reverse) => { if (reverse) { options = options.slice().reverse(); } return options.map((option) => /* @__PURE__ */ jsx(Option, { option })); } ) ] } ); context.innerDialog = /* @__PURE__ */ jsx( "dialog", { onclick: method._handleDialogClick, oncancel: method._handleModalCancel, children: context.optionsList } ); context.innerDialog._openEffect = context.open.add((open) => { if (open) { context.innerDialog.showModal(); } else { context.innerDialog.close(); } }); return context.innerDialog; }, "OptionsListMobile"); const OptionsListDesktop = /* @__PURE__ */ __name(() => { const isTop = sig.derive( orientation.signal, forcedPosition, (o, fo) => { if (o === "detect") { o = fo; } return o === "up"; } ); context.optionsList = /* @__PURE__ */ jsxs( "div", { id: context.uid, class: { [Selector.optionsList]: true, [Selector.top]: isTop }, role: "listbox", children: [ allowUnselect.signal.derive((allowUnselect2) => { if (!allowUnselect2) return null; return /* @__PURE__ */ jsx(UnselectOption, {}); }), sig.derive( context.options, reverseOrder.signal, context.optionsDep, (options, reverse) => { if (reverse) { options = options.slice().reverse(); } return options.map((option) => /* @__PURE__ */ jsx(Option, { option })); } ) ] } ); return context.optionsList; }, "OptionsListDesktop"); const HiddenSelect = /* @__PURE__ */ __name(() => { return /* @__PURE__ */ jsx( "select", { class: "_adw_hidden", name: name.signal, "attribute:form": form.signal, disabled: disabled.signal, "aria-hidden": true, onchange: stopEvent, children: context.options.derive( (options) => options.map((option, index) => { return /* @__PURE__ */ jsx( "option", { value: option.getValue(), selected: value.signal.derive((v) => option.isEqualTo(v)) } ); }) ) } ); }, "HiddenSelect"); const rootElem = /* @__PURE__ */ jsxs( "div", { class: { [Selector.noPosition]: IS_MOBILE, [Selector.selector]: true, [Selector.disabled]: disabled.signal, [Selector.opened]: context.open, closed: sig.not(context.open) }, onclick: method._handleClick, onkeydown: method._handleKeyDown, tabIndex: 0, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": context.open, "aria-controls": context.uid, "aria-placeholder": placeholder.signal, children: [ /* @__PURE__ */ jsx( "span", { class: { [Selector.selectedOption]: true, "with-placeholder": sig.not(context.optionPreview) }, children: sig.nuc(context.optionPreview, placeholder.signal) } ), /* @__PURE__ */ jsx("span", { class: Selector.downButton }), IS_MOBILE ? /* @__PURE__ */ jsx(OptionsListMobile, {}) : /* @__PURE__ */ jsx(OptionsListDesktop, {}), /* @__PURE__ */ jsx(HiddenSelect, {}) ] } ); wc.attach(rootElem); }).register(); var AdwSelector = CustomElement; export { AdwSelector };