adwaveui
Version:
Interactive Web Components inspired by the Gtk Adwaita theme.
802 lines (800 loc) • 23.6 kB
JavaScript
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
};