UNPKG

melt

Version:

The next generation of Melt UI. Built for Svelte 5.

186 lines (185 loc) 6.61 kB
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); } }, }; } }