UNPKG

@melt-ui/svelte

Version:
202 lines (201 loc) 7.84 kB
import { addMeltEventListener, makeElement, createElHelpers, disabledAttr, executeCallbacks, generateId, generateIds, getElementByMeltId, isHTMLElement, kbd, omit, overridable, styleToString, toWritableStores, } from '../../internal/helpers/index.js'; import { tick } from 'svelte'; import { derived, writable } from 'svelte/store'; const { name, selector } = createElHelpers('accordion'); const defaults = { multiple: false, disabled: false, forceVisible: false, }; export const createAccordion = (props) => { const withDefaults = { ...defaults, ...props }; const options = toWritableStores(omit(withDefaults, 'value', 'onValueChange', 'defaultValue')); const meltIds = generateIds(['root']); const { disabled, forceVisible } = options; const valueWritable = withDefaults.value ?? writable(withDefaults.defaultValue); const value = overridable(valueWritable, withDefaults?.onValueChange); const isSelected = (key, v) => { if (v === undefined) return false; if (typeof v === 'string') return v === key; return v.includes(key); }; const isSelectedStore = derived(value, ($value) => { return (key) => isSelected(key, $value); }); const root = makeElement(name(), { returned: () => ({ 'data-melt-id': meltIds.root, }), }); const parseItemProps = (props) => { if (typeof props === 'string') { return { value: props }; } else { return props; } }; const parseHeadingProps = (props) => { if (typeof props === 'number') { return { level: props }; } else { return props; } }; const item = makeElement(name('item'), { stores: value, returned: ($value) => { return (props) => { const { value: itemValue, disabled } = parseItemProps(props); return { 'data-state': isSelected(itemValue, $value) ? 'open' : 'closed', 'data-disabled': disabledAttr(disabled), }; }; }, }); const trigger = makeElement(name('trigger'), { stores: [value, disabled], returned: ([$value, $disabled]) => { return (props) => { const { value: itemValue, disabled } = parseItemProps(props); // generate the content ID here so that we can grab it in the content // builder action to ensure the values match. return { disabled: disabledAttr($disabled || disabled), 'aria-expanded': isSelected(itemValue, $value) ? true : false, 'aria-disabled': disabled ? true : false, 'data-disabled': disabledAttr(disabled), 'data-value': itemValue, 'data-state': isSelected(itemValue, $value) ? 'open' : 'closed', }; }; }, action: (node) => { const unsub = executeCallbacks(addMeltEventListener(node, 'click', () => { const disabled = node.dataset.disabled === 'true'; const itemValue = node.dataset.value; if (disabled || !itemValue) return; handleValueUpdate(itemValue); }), addMeltEventListener(node, 'keydown', (e) => { if (![kbd.ARROW_DOWN, kbd.ARROW_UP, kbd.HOME, kbd.END].includes(e.key)) { return; } e.preventDefault(); if (e.key === kbd.SPACE || e.key === kbd.ENTER) { const disabled = node.dataset.disabled === 'true'; const itemValue = node.dataset.value; if (disabled || !itemValue) return; handleValueUpdate(itemValue); return; } const el = e.target; const rootEl = getElementByMeltId(meltIds.root); if (!rootEl || !isHTMLElement(el)) return; const items = Array.from(rootEl.querySelectorAll(selector('trigger'))); const candidateItems = items.filter((item) => { if (!isHTMLElement(item)) return false; return item.dataset.disabled !== 'true'; }); if (!candidateItems.length) return; const elIdx = candidateItems.indexOf(el); if (e.key === kbd.ARROW_DOWN) { candidateItems[(elIdx + 1) % candidateItems.length].focus(); } if (e.key === kbd.ARROW_UP) { candidateItems[(elIdx - 1 + candidateItems.length) % candidateItems.length].focus(); } if (e.key === kbd.HOME) { candidateItems[0].focus(); } if (e.key === kbd.END) { candidateItems[candidateItems.length - 1].focus(); } })); return { destroy: unsub, }; }, }); const content = makeElement(name('content'), { stores: [value, disabled, forceVisible], returned: ([$value, $disabled, $forceVisible]) => { return (props) => { const { value: itemValue } = parseItemProps(props); const selected = isSelected(itemValue, $value); const isVisible = selected || $forceVisible; return { 'data-state': selected ? 'open' : 'closed', 'data-disabled': disabledAttr($disabled), 'data-value': itemValue, hidden: isVisible ? undefined : true, style: isVisible ? undefined : styleToString({ display: 'none' }), }; }; }, action: (node) => { tick().then(() => { const contentId = generateId(); const triggerId = generateId(); const parentTrigger = document.querySelector(`${selector('trigger')}, [data-value="${node.dataset.value}"]`); if (!isHTMLElement(parentTrigger)) return; node.id = contentId; parentTrigger.setAttribute('aria-controls', contentId); parentTrigger.id = triggerId; }); }, }); const heading = makeElement(name('heading'), { returned: () => { return (props) => { const { level } = parseHeadingProps(props); return { role: 'heading', 'aria-level': level, 'data-heading-level': level, }; }; }, }); function handleValueUpdate(itemValue) { value.update(($value) => { if ($value === undefined) { return withDefaults.multiple ? [itemValue] : itemValue; } if (Array.isArray($value)) { if ($value.includes(itemValue)) { return $value.filter((v) => v !== itemValue); } $value.push(itemValue); return $value; } return $value === itemValue ? undefined : itemValue; }); } return { ids: meltIds, elements: { root, item, trigger, content, heading, }, states: { value: value, }, helpers: { isSelected: isSelectedStore, }, options, }; };