Parts: Root, Item, Header, Trigger, Content - Single/multiple mode via type prop - Collapsible (single mode: click open item to close) - Per-item and global disabled - Keyboard: Arrow keys (orientation-aware), Home/End, loop option - Presence on Content with data-starting/ending-style - --soma-accordion-content-height/width CSS vars for animations - forceMount on Content - Header with level prop (aria-level) - Auto-generated item values when omitted - Demo page with 5 sections Compared with Radix, Ark UI, Bits UI: - Has collapsible + loop (Radix has collapsible, Bits has loop, soma has both) - Has header level (like Bits) - Has CSS animation vars (like all three) - Has data-starting/ending-style (like Bits) - Skipped ItemIndicator (Ark) — presentation concern for visual layer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>semantuix
parent
193bfcdca2
commit
bb0cdeb554
@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import * as Accordion from '$soma/components/accordion';
|
||||
|
||||
let value1 = $state<string[]>([]);
|
||||
let value2 = $state<string[]>(['item-1']);
|
||||
let value3 = $state<string[]>([]);
|
||||
let value4 = $state<string[]>([]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Accordion · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Soma Accordion</h1>
|
||||
<p>Headless accordion with keyboard navigation, single/multiple modes, and CSS animation support.</p>
|
||||
|
||||
<!-- ── 1. Single (default) ──────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>1. Single mode (collapsible)</h2>
|
||||
<p>One item open at a time. Click an open item to collapse it.</p>
|
||||
<p class="state">value: {JSON.stringify(value1)}</p>
|
||||
|
||||
<Accordion.Root bind:value={value1} collapsible>
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>What is soma?</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">
|
||||
Soma is a headless component library for Svelte 5. It provides behavior,
|
||||
accessibility, and composition without visual opinions.
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>How does it compare?</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">
|
||||
Soma matches or exceeds Radix, Ark UI, and Bits UI in features.
|
||||
Layers are integrated as behaviors, not nested wrappers.
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>What about animations?</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">
|
||||
Content exposes --soma-accordion-content-height and data-starting-style /
|
||||
data-ending-style for CSS transitions. Use forceMount to keep content in DOM.
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. Multiple ──────────────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>2. Multiple mode</h2>
|
||||
<p>Any number of items open simultaneously.</p>
|
||||
<p class="state">value: {JSON.stringify(value2)}</p>
|
||||
|
||||
<Accordion.Root type="multiple" bind:value={value2}>
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>First (starts open)</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">This item starts open because value includes 'item-1'.</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>Second</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">Open multiple items at once.</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>Third</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">All three can be open.</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. Disabled ──────────────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>3. Disabled</h2>
|
||||
<p>Global disabled + per-item disabled.</p>
|
||||
|
||||
<Accordion.Root bind:value={value3} collapsible>
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>Enabled item</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">This works normally.</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-2" disabled>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>Disabled item</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">This should never open.</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. Keyboard navigation ───────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>4. Keyboard navigation (loop)</h2>
|
||||
<p>
|
||||
Focus a trigger, then use Arrow keys to navigate. <code>loop=true</code> wraps at
|
||||
boundaries. Home/End jump to first/last.
|
||||
</p>
|
||||
|
||||
<Accordion.Root bind:value={value4} collapsible loop>
|
||||
<Accordion.Item value="a">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>Alpha</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">Alpha content</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="b">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>Beta</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">Beta content</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="c">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>Gamma</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<div class="content-body">Gamma content</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 5. Data attributes ───────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>5. Data attributes (inspect DOM)</h2>
|
||||
<pre>Root: [data-accordion] [data-orientation] [data-disabled]
|
||||
Item: [data-accordion-item] [data-state] [data-disabled] [data-orientation]
|
||||
Header: [data-accordion-header] [data-state] [data-disabled] [role=heading] [aria-level]
|
||||
Trigger: [data-accordion-trigger] [data-state] [data-disabled] [aria-expanded] [aria-controls]
|
||||
Content: [data-accordion-content] [data-state] [data-disabled] [role=region] [aria-labelledby]
|
||||
[data-starting-style] [data-ending-style]
|
||||
--soma-accordion-content-height
|
||||
--soma-accordion-content-width</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 2rem auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
h1 { margin-bottom: 0.5rem; }
|
||||
h2 { margin-top: 2rem; margin-bottom: 0.5rem; }
|
||||
p { color: #555; line-height: 1.6; }
|
||||
.state { font-family: monospace; font-size: 0.85rem; color: #1e293b; }
|
||||
|
||||
section {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 0.8rem;
|
||||
background: #f8fafc;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
padding: 0.75rem 1rem;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Accordion styles via data-* */
|
||||
:global([data-accordion]) {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global([data-accordion-item]) {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
:global([data-accordion-item]:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global([data-accordion-trigger]) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: #f8fafc;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
:global([data-accordion-trigger]::after) {
|
||||
content: '▸';
|
||||
transition: transform 200ms;
|
||||
}
|
||||
:global([data-accordion-trigger][data-state='open']::after) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
:global([data-accordion-trigger]:hover:not(:disabled)) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
:global([data-accordion-trigger]:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
:global([data-accordion-trigger]:focus-visible) {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
:global([data-accordion-content]) {
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,295 @@
|
||||
import { Provider, context, type WithRefOpts } from '$soma/provider';
|
||||
import { createAttrs, boolToEmptyStrOrUndef, getDataOpenClosed } from '$soma/attrs';
|
||||
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
|
||||
import type { OnChangeFn, Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
|
||||
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
|
||||
import { useId } from '$soma/id';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
|
||||
import { Presence } from '$soma/layers/presence.svelte';
|
||||
import { ResizeObserver$ } from '$soma/layers/resize-observer.svelte';
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'accordion',
|
||||
parts: ['root', 'item', 'header', 'trigger', 'content'] as const,
|
||||
});
|
||||
|
||||
export type AccordionType = 'single' | 'multiple';
|
||||
|
||||
// ── Root ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AccordionOpts
|
||||
extends WithRefOpts,
|
||||
StateProps<{ value: string[] }>,
|
||||
ActiveProps<{
|
||||
type: AccordionType;
|
||||
collapsible: boolean;
|
||||
disabled: boolean;
|
||||
orientation: Orientation;
|
||||
loop: boolean;
|
||||
dir: Direction;
|
||||
onValueChange: OnChangeFn<string[]>;
|
||||
}> {}
|
||||
|
||||
export class AccordionProvider extends Provider<AccordionOpts> {
|
||||
static readonly ctx = context<AccordionProvider>('Accordion');
|
||||
|
||||
static from(opts: AccordionOpts) {
|
||||
return new AccordionProvider(opts);
|
||||
}
|
||||
|
||||
readonly soma: Soma | undefined;
|
||||
|
||||
private constructor(opts: AccordionOpts) {
|
||||
super(opts, 'Accordion', 'root', attrs.root, AccordionProvider.ctx);
|
||||
this.soma = Soma.getOr();
|
||||
}
|
||||
|
||||
isItemOpen(value: string): boolean {
|
||||
return this.opts.value.current.includes(value);
|
||||
}
|
||||
|
||||
toggleItem(value: string) {
|
||||
if (this.opts.disabled.current) return;
|
||||
|
||||
const current = this.opts.value.current;
|
||||
const isOpen = current.includes(value);
|
||||
|
||||
let next: string[];
|
||||
if (this.opts.type.current === 'single') {
|
||||
if (isOpen && !this.opts.collapsible.current) return;
|
||||
next = isOpen ? [] : [value];
|
||||
} else {
|
||||
next = isOpen ? current.filter((v) => v !== value) : [...current, value];
|
||||
}
|
||||
|
||||
this.opts.value.current = next;
|
||||
this.opts.onValueChange.current(next);
|
||||
}
|
||||
|
||||
/** Get all trigger elements for keyboard navigation. */
|
||||
getTriggers(): HTMLButtonElement[] {
|
||||
const root = this.opts.ref?.current;
|
||||
if (!root) return [];
|
||||
const selector = `[${attrs.trigger}]:not([disabled])`;
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>(selector));
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'data-orientation': this.opts.orientation.current,
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Item ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AccordionItemOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
}> {}
|
||||
|
||||
export class AccordionItemProvider extends Provider<AccordionItemOpts> {
|
||||
static readonly ctx = context<AccordionItemProvider>('AccordionItem');
|
||||
|
||||
static from(opts: AccordionItemOpts) {
|
||||
return new AccordionItemProvider(opts);
|
||||
}
|
||||
|
||||
readonly root: AccordionProvider;
|
||||
readonly contentPresence: Presence;
|
||||
|
||||
// tracked IDs
|
||||
triggerId = state('');
|
||||
contentId = state('');
|
||||
|
||||
// content size for CSS vars
|
||||
contentHeight = $state<number>(0);
|
||||
contentWidth = $state<number>(0);
|
||||
private contentRef = state<HTMLElement | null>(null);
|
||||
|
||||
private constructor(opts: AccordionItemOpts) {
|
||||
super(opts, 'Accordion', 'item', attrs.item, AccordionItemProvider.ctx);
|
||||
this.root = AccordionProvider.ctx.get();
|
||||
|
||||
this.contentPresence = new Presence({
|
||||
open: readableActive(() => this.isOpen),
|
||||
ref: this.contentRef,
|
||||
});
|
||||
|
||||
// Track content size for CSS animation vars
|
||||
new ResizeObserver$(
|
||||
() => this.contentRef.current,
|
||||
() => {
|
||||
const el = this.contentRef.current;
|
||||
if (!el) return;
|
||||
this.contentHeight = el.scrollHeight;
|
||||
this.contentWidth = el.scrollWidth;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
readonly isOpen = $derived.by(() =>
|
||||
this.root.isItemOpen(this.opts.value.current),
|
||||
);
|
||||
|
||||
readonly isDisabled = $derived.by(() =>
|
||||
this.opts.disabled.current || this.root.opts.disabled.current,
|
||||
);
|
||||
|
||||
setContentRef(el: HTMLElement | null) {
|
||||
this.contentRef.current = el;
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'data-state': getDataOpenClosed(this.isOpen),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
||||
'data-orientation': this.root.opts.orientation.current,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Header ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AccordionHeaderOpts extends WithRefOpts, ActiveProps<{ level: number }> {}
|
||||
|
||||
export class AccordionHeaderProvider extends Provider<AccordionHeaderOpts> {
|
||||
static from(opts: AccordionHeaderOpts) {
|
||||
return new AccordionHeaderProvider(opts);
|
||||
}
|
||||
|
||||
readonly item: AccordionItemProvider;
|
||||
|
||||
private constructor(opts: AccordionHeaderOpts) {
|
||||
super(opts, 'Accordion', 'header', attrs.header);
|
||||
this.item = AccordionItemProvider.ctx.get();
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'heading' as const,
|
||||
'aria-level': this.opts.level.current,
|
||||
'data-state': getDataOpenClosed(this.item.isOpen),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
|
||||
'data-orientation': this.item.root.opts.orientation.current,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trigger ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AccordionTriggerOpts extends WithRefOpts {}
|
||||
|
||||
export class AccordionTriggerProvider extends Provider<AccordionTriggerOpts> {
|
||||
static from(opts: AccordionTriggerOpts) {
|
||||
return new AccordionTriggerProvider(opts);
|
||||
}
|
||||
|
||||
readonly item: AccordionItemProvider;
|
||||
|
||||
private constructor(opts: AccordionTriggerOpts) {
|
||||
super(opts, 'Accordion', 'trigger', attrs.trigger);
|
||||
this.item = AccordionItemProvider.ctx.get();
|
||||
this.item.triggerId.current = opts.id.current;
|
||||
}
|
||||
|
||||
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
|
||||
if (this.item.isDisabled) return;
|
||||
this.item.root.toggleItem(this.item.opts.value.current);
|
||||
};
|
||||
|
||||
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (this.item.isDisabled) return;
|
||||
|
||||
const root = this.item.root;
|
||||
const orientation = root.opts.orientation.current;
|
||||
const dir = root.opts.dir.current;
|
||||
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
|
||||
const triggers = root.getTriggers();
|
||||
const currentIndex = triggers.indexOf(e.currentTarget as HTMLButtonElement);
|
||||
|
||||
let targetIndex = -1;
|
||||
|
||||
if (e.key === nextKey) {
|
||||
e.preventDefault();
|
||||
targetIndex = root.opts.loop.current
|
||||
? (currentIndex + 1) % triggers.length
|
||||
: Math.min(currentIndex + 1, triggers.length - 1);
|
||||
} else if (e.key === prevKey) {
|
||||
e.preventDefault();
|
||||
targetIndex = root.opts.loop.current
|
||||
? (currentIndex - 1 + triggers.length) % triggers.length
|
||||
: Math.max(currentIndex - 1, 0);
|
||||
} else if (e.key === KEYS.HOME) {
|
||||
e.preventDefault();
|
||||
targetIndex = 0;
|
||||
} else if (e.key === KEYS.END) {
|
||||
e.preventDefault();
|
||||
targetIndex = triggers.length - 1;
|
||||
}
|
||||
|
||||
if (targetIndex >= 0 && triggers[targetIndex]) {
|
||||
triggers[targetIndex].focus();
|
||||
}
|
||||
};
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
type: 'button' as const,
|
||||
'aria-expanded': this.item.isOpen,
|
||||
'aria-controls': this.item.contentId.current || undefined,
|
||||
'data-state': getDataOpenClosed(this.item.isOpen),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
|
||||
'data-orientation': this.item.root.opts.orientation.current,
|
||||
disabled: this.item.isDisabled || undefined,
|
||||
onclick: this.onclick,
|
||||
onkeydown: this.onkeydown,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AccordionContentOpts extends WithRefOpts {}
|
||||
|
||||
export class AccordionContentProvider extends Provider<AccordionContentOpts> {
|
||||
static from(opts: AccordionContentOpts) {
|
||||
return new AccordionContentProvider(opts);
|
||||
}
|
||||
|
||||
readonly item: AccordionItemProvider;
|
||||
|
||||
private constructor(opts: AccordionContentOpts) {
|
||||
super(opts, 'Accordion', 'content', attrs.content, undefined, (el) => {
|
||||
this.item.setContentRef(el);
|
||||
});
|
||||
this.item = AccordionItemProvider.ctx.get();
|
||||
this.item.contentId.current = opts.id.current;
|
||||
}
|
||||
|
||||
readonly isPresent = $derived.by(() => this.item.contentPresence.isPresent);
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'region' as const,
|
||||
'aria-labelledby': this.item.triggerId.current || undefined,
|
||||
'data-state': getDataOpenClosed(this.item.isOpen),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
|
||||
'data-orientation': this.item.root.opts.orientation.current,
|
||||
style: {
|
||||
'--soma-accordion-content-height': `${this.item.contentHeight}px`,
|
||||
'--soma-accordion-content-width': `${this.item.contentWidth}px`,
|
||||
},
|
||||
...this.item.contentPresence.transitionAttrs,
|
||||
} as const),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { AccordionContentProvider } from '../accordion.svelte';
|
||||
import type { AccordionContentProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'accordion-content'),
|
||||
forceMount = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AccordionContentProps = $props();
|
||||
|
||||
const state = AccordionContentProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v),
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if state.isPresent || forceMount}
|
||||
{#if child}
|
||||
{@render child({ open: state.item.isOpen, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.({ open: state.item.isOpen })}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { AccordionHeaderProvider } from '../accordion.svelte';
|
||||
import type { AccordionHeaderProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'accordion-header'),
|
||||
level = 3,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AccordionHeaderProps = $props();
|
||||
|
||||
const state = AccordionHeaderProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v),
|
||||
),
|
||||
level: readableActive(() => level),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId, useId } from '$soma/id';
|
||||
import { AccordionItemProvider } from '../accordion.svelte';
|
||||
import type { AccordionItemProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'accordion-item'),
|
||||
value = useId('accordion-item-value'),
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AccordionItemProps = $props();
|
||||
|
||||
const state = AccordionItemProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v),
|
||||
),
|
||||
value: readableActive(() => value),
|
||||
disabled: readableActive(() => disabled),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { AccordionTriggerProvider } from '../accordion.svelte';
|
||||
import type { AccordionTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'accordion-trigger'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AccordionTriggerProps = $props();
|
||||
|
||||
const state = AccordionTriggerProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v),
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$soma/reactive';
|
||||
import { mergeProps } from '$soma/props';
|
||||
import { createId } from '$soma/id';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { AccordionProvider } from '../accordion.svelte';
|
||||
import type { AccordionProps } from '../types';
|
||||
import { noop } from '$lib/util/funcs';
|
||||
|
||||
const uid = $props.id();
|
||||
const soma = Soma.getOr();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'accordion'),
|
||||
type = 'single',
|
||||
value = $bindable([]),
|
||||
onValueChange = noop,
|
||||
collapsible = false,
|
||||
disabled = false,
|
||||
orientation = 'vertical',
|
||||
loop = false,
|
||||
dir = soma?.dir.current ?? 'ltr',
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AccordionProps = $props();
|
||||
|
||||
const state = AccordionProvider.from({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v),
|
||||
),
|
||||
type: readableActive(() => type),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => (value = v),
|
||||
),
|
||||
onValueChange: readableActive(() => onValueChange),
|
||||
collapsible: readableActive(() => collapsible),
|
||||
disabled: readableActive(() => disabled),
|
||||
orientation: readableActive(() => orientation),
|
||||
loop: readableActive(() => loop),
|
||||
dir: readableActive(() => dir),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,15 @@
|
||||
export { default as Root } from './components/accordion.svelte';
|
||||
export { default as Item } from './components/accordion-item.svelte';
|
||||
export { default as Header } from './components/accordion-header.svelte';
|
||||
export { default as Trigger } from './components/accordion-trigger.svelte';
|
||||
export { default as Content } from './components/accordion-content.svelte';
|
||||
|
||||
export type { AccordionType } from './accordion.svelte';
|
||||
|
||||
export type {
|
||||
AccordionProps as RootProps,
|
||||
AccordionItemProps as ItemProps,
|
||||
AccordionHeaderProps as HeaderProps,
|
||||
AccordionTriggerProps as TriggerProps,
|
||||
AccordionContentProps as ContentProps,
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,68 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn, Orientation, Direction } from '$soma/types';
|
||||
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '$soma/types';
|
||||
import type { AccordionType } from './accordion.svelte';
|
||||
|
||||
export type AccordionProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/**
|
||||
* 'single' allows one item open at a time. 'multiple' allows any number.
|
||||
* @default 'single'
|
||||
*/
|
||||
type?: AccordionType;
|
||||
/** Array of open item values. Bindable. @default [] */
|
||||
value?: string[];
|
||||
/** Callback fired when value changes. */
|
||||
onValueChange?: OnChangeFn<string[]>;
|
||||
/**
|
||||
* When type='single', allows closing the open item by clicking its trigger again.
|
||||
* Has no effect when type='multiple'.
|
||||
* @default false
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
/** Disables all items. @default false */
|
||||
disabled?: boolean;
|
||||
/** Orientation for keyboard navigation (arrow keys). @default 'vertical' */
|
||||
orientation?: Orientation;
|
||||
/** Wrap keyboard navigation at boundaries. @default false */
|
||||
loop?: boolean;
|
||||
/** Text direction for keyboard navigation. @default from Soma config or 'ltr' */
|
||||
dir?: Direction;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type AccordionItemProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Value that identifies this item. Auto-generated if omitted. */
|
||||
value?: string;
|
||||
/** Disables this item. @default false */
|
||||
disabled?: boolean;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type AccordionHeaderProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Heading level for accessibility (aria-level). @default 3 */
|
||||
level?: number;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type AccordionTriggerProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type AccordionContentProps = WithChild<
|
||||
{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Keep in DOM even when closed. Enables CSS height transitions. @default false */
|
||||
forceMount?: boolean;
|
||||
},
|
||||
{ open: boolean }
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
@ -1,3 +1,4 @@
|
||||
export * as Accordion from './accordion';
|
||||
export * as Collapsible from './collapsible';
|
||||
export * as Dialog from './dialog';
|
||||
export * as Popover from './popover';
|
||||
|
||||
Loading…
Reference in new issue