melt
Version:
The next generation of Melt UI. Built for Svelte 5.
186 lines (185 loc) • 6.61 kB
JavaScript
import { Synced } from "../Synced.svelte";
import { dataAttr, disabledAttr } from "../utils/attribute";
import { extract } from "../utils/extract";
import { createBuilderMetadata } from "../utils/identifiers";
import { isHtmlElement } from "../utils/is";
import { getDirectionalKeys, kbd } from "../utils/keyboard";
const metadata = createBuilderMetadata("radio-group", ["root", "item", "label", "hidden-input"]);
export class RadioGroup {
ids = $state(metadata.createIds());
/* Props */
#props;
disabled = $derived(extract(this.#props.disabled, false));
required = $derived(extract(this.#props.required, false));
loop = $derived(extract(this.#props.loop, true));
selectWhenFocused = $derived(extract(this.#props.selectWhenFocused, true));
orientation = $derived(extract(this.#props.orientation, "vertical"));
/* State */
#value;
constructor(props) {
this.#props = props;
this.#value = new Synced({
value: props.value,
onChange: props.onValueChange,
defaultValue: "",
});
}
get value() {
return this.#value.current;
}
set value(value) {
this.#value.current = value;
}
get #sharedAttrs() {
return {
"data-orientation": dataAttr(this.orientation),
"data-disabled": disabledAttr(this.disabled),
"data-value": this.value,
};
}
get root() {
// TODO: add attachment and check if aria-label is present. Otherwise, use aria-labelledby
return {
...this.#sharedAttrs,
[metadata.dataAttrs["root"]]: "",
id: this.ids.root,
role: "radiogroup",
"aria-required": this.required,
"aria-labelledby": this.ids.label,
};
}
get label() {
return {
...this.#sharedAttrs,
[metadata.dataAttrs.label]: "",
id: this.ids.label,
for: this.ids.root,
onclick: (e) => {
if (this.disabled)
return;
// focus the selected item
const el = e.currentTarget;
if (!isHtmlElement(el))
return;
const root = el.closest(metadata.dataSelectors.root);
if (!isHtmlElement(root))
return;
const item = root.querySelector(metadata.dataSelectors.item + `[data-value="${dataAttr(this.value)}"]`);
if (isHtmlElement(item))
item.focus();
},
};
}
getItem(item) {
return new RadioItem({ group: this, item, getSharedAttrs: () => this.#sharedAttrs });
}
get hiddenInput() {
return {
[metadata.dataAttrs["hidden-input"]]: "",
disabled: this.disabled,
required: this.required,
hidden: true,
"aria-hidden": true,
tabindex: -1,
value: this.value,
name: extract(this.#props.name),
};
}
select(item) {
if (this.disabled)
return;
this.value = item;
}
}
class RadioItem {
#props;
#group = $derived(this.#props.group);
value = $derived(this.#props.item);
checked = $derived(this.#group.value === this.value);
constructor(props) {
this.#props = props;
}
#select(e) {
if (this.#group.disabled)
return;
this.#group.select(this.value);
const el = e.currentTarget;
if (!isHtmlElement(el))
return;
el.focus();
}
get attrs() {
return {
...this.#props.getSharedAttrs(),
[metadata.dataAttrs["item"]]: "",
"data-value": dataAttr(this.value),
"data-state": dataAttr(this.checked ? "checked" : "unchecked"),
"aria-checked": this.checked,
role: "radio",
tabindex: 0,
onclick: (e) => {
this.#select(e);
},
onkeydown: (e) => {
if (e.key === kbd.SPACE) {
e.preventDefault();
this.#select(e);
return;
}
const el = e.currentTarget;
const root = el.closest(metadata.dataSelectors.root);
if (!isHtmlElement(root))
return;
const items = Array.from(root.querySelectorAll(metadata.dataSelectors.item)).filter((el) => isHtmlElement(el) && !el.hasAttribute("data-disabled"));
const currentIdx = items.indexOf(el);
const loop = this.#group.loop;
const style = window.getComputedStyle(el);
const dir = style.getPropertyValue("direction");
const { nextKey, prevKey } = getDirectionalKeys(dir, this.#group.orientation);
let itemToFocus;
switch (e.key) {
case nextKey: {
e.preventDefault();
const nextIdx = currentIdx + 1;
if (nextIdx >= items.length && loop) {
itemToFocus = items[0];
}
else {
itemToFocus = items[nextIdx];
}
break;
}
case prevKey: {
e.preventDefault();
const prevIdx = currentIdx - 1;
if (prevIdx < 0 && loop) {
itemToFocus = items[items.length - 1];
}
else {
itemToFocus = items[prevIdx];
}
break;
}
case kbd.HOME: {
e.preventDefault();
itemToFocus = items[0];
break;
}
case kbd.END: {
e.preventDefault();
itemToFocus = items[items.length - 1];
break;
}
default: {
return;
}
}
if (itemToFocus) {
itemToFocus.focus();
if (this.#group.selectWhenFocused)
this.#group.select(itemToFocus.dataset.value);
}
},
};
}
}