@melt-ui/svelte
Version:

202 lines (201 loc) • 7.84 kB
JavaScript
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,
};
};