`. |
diff --git a/packages/react/accordion/package.json b/packages/react/accordion/package.json
new file mode 100644
index 0000000..9f96817
--- /dev/null
+++ b/packages/react/accordion/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@dunky.dev/react-accordion",
+ "version": "0.0.0",
+ "description": "React binding for @dunky.dev/accordion.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/react/accordion"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "publishConfig": {
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsdown"
+ },
+ "dependencies": {
+ "@dunky.dev/accordion": "workspace:^",
+ "@dunky.dev/react-state-machine": "^0.1.0"
+ },
+ "devDependencies": {
+ "@testing-library/react": "^16.1.0",
+ "@types/react": "^19.2.15",
+ "@types/react-dom": "^19.2.3",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6"
+ },
+ "peerDependencies": {
+ "react": "*"
+ }
+}
diff --git a/packages/react/accordion/src/accordion.tsx b/packages/react/accordion/src/accordion.tsx
new file mode 100644
index 0000000..fd95ab6
--- /dev/null
+++ b/packages/react/accordion/src/accordion.tsx
@@ -0,0 +1,142 @@
+import {
+ forwardRef,
+ useEffect,
+ type ComponentPropsWithoutRef,
+ type ForwardRefExoticComponent,
+ type ReactNode,
+ type RefAttributes,
+} from 'react'
+import type { AccordionOptions } from '@dunky.dev/accordion'
+
+import { mergeProps, normalize } from '@dunky.dev/react-state-machine'
+import {
+ AccordionContext,
+ AccordionItemContext,
+ useAccordionContext,
+ useAccordionItemContext,
+} from './context'
+import { useAccordion } from './use-accordion'
+
+// Explicit so the exports satisfy --isolatedDeclarations (a bare forwardRef
+// call gives the variable no annotatable type).
+type PartComponent
= ForwardRefExoticComponent>
+
+// =============================================================================
+// — root, owns the machine and renders no DOM
+// =============================================================================
+
+export type AccordionProps = AccordionOptions & { children?: ReactNode }
+
+export const Accordion: ((props: AccordionProps) => ReactNode) & Parts = ({
+ children,
+ ...options
+}) => {
+ const value = useAccordion(options)
+ return {children}
+}
+
+// =============================================================================
+// — one disclosure; registers itself and scopes its parts
+// =============================================================================
+
+export interface AccordionItemProps extends ComponentPropsWithoutRef<'div'> {
+ /** The item's identity in the accordion's value. */
+ value: string
+ /** Disables this item. @default false */
+ disabled?: boolean
+}
+
+export const Item: PartComponent = forwardRef<
+ HTMLDivElement,
+ AccordionItemProps
+>(({ value, disabled = false, ...props }, forwardedRef) => {
+ const { api, machine } = useAccordionContext()
+
+ // Registration order is the keyboard navigation order. A disabled flip
+ // re-registers (the machine upserts in place); only unmount unregisters.
+ useEffect(() => {
+ machine.send({ type: 'item.register', value, disabled })
+ }, [machine, value, disabled])
+ useEffect(() => () => machine.send({ type: 'item.unregister', value }), [machine, value])
+
+ const merged = mergeProps(
+ props as Record,
+ normalize(api.parts.item({ value, disabled })),
+ )
+ return (
+
+
+
+ )
+})
+
+// =============================================================================
+// — the heading wrapper that gives the trigger its level
+// =============================================================================
+
+export interface AccordionHeaderProps extends ComponentPropsWithoutRef<'h3'> {}
+
+export const Header: PartComponent = forwardRef<
+ HTMLHeadingElement,
+ AccordionHeaderProps
+>((props, forwardedRef) => {
+ const { api } = useAccordionContext()
+ const item = useAccordionItemContext()
+ const merged = mergeProps(props as Record, normalize(api.parts.header(item)))
+ return
+})
+
+// =============================================================================
+// — toggles its item; hosts the arrow-key navigation
+// =============================================================================
+
+export interface AccordionTriggerProps extends ComponentPropsWithoutRef<'button'> {}
+
+export const Trigger: PartComponent = forwardRef<
+ HTMLButtonElement,
+ AccordionTriggerProps
+>((props, forwardedRef) => {
+ const { api } = useAccordionContext()
+ const item = useAccordionItemContext()
+ const merged = mergeProps(
+ { type: 'button' as const, ...props },
+ normalize(api.parts.trigger(item)),
+ )
+ return
+})
+
+// =============================================================================
+// — the section the trigger controls; hidden while closed
+// =============================================================================
+
+export interface AccordionContentProps extends ComponentPropsWithoutRef<'div'> {}
+
+export const Content: PartComponent = forwardRef<
+ HTMLDivElement,
+ AccordionContentProps
+>((props, forwardedRef) => {
+ const { api } = useAccordionContext()
+ const item = useAccordionItemContext()
+ const merged = mergeProps(props as Record, {
+ ...normalize(api.parts.content(item)),
+ // Content stays mounted while closed — native `hidden` is the substrate's
+ // translation of the closed state and keeps the ARIA references resolvable.
+ hidden: api.isItemOpen(item.value) ? undefined : true,
+ })
+ return
+})
+
+// Parts
+// -----------------------------------------------------------------------------
+
+export interface Parts {
+ Item: typeof Item
+ Header: typeof Header
+ Trigger: typeof Trigger
+ Content: typeof Content
+}
+
+Accordion.Item = Item
+Accordion.Header = Header
+Accordion.Trigger = Trigger
+Accordion.Content = Content
diff --git a/packages/react/accordion/src/context.ts b/packages/react/accordion/src/context.ts
new file mode 100644
index 0000000..c25ceb4
--- /dev/null
+++ b/packages/react/accordion/src/context.ts
@@ -0,0 +1,38 @@
+import { createContext, useContext, type Context } from 'react'
+import type { AccordionApi, AccordionMachine } from '@dunky.dev/accordion'
+
+export interface AccordionContextValue {
+ api: AccordionApi
+ machine: AccordionMachine
+}
+
+export const AccordionContext: Context = createContext<
+ AccordionContextValue | undefined
+>(undefined)
+
+export const useAccordionContext = (): AccordionContextValue => {
+ const context = useContext(AccordionContext)
+ if (context === undefined) {
+ throw new Error('Accordion parts must be rendered within an root')
+ }
+ return context
+}
+
+// The item's identity for the parts inside it — Header, Trigger, and Content
+// read their item's value and disabled flag from here.
+export interface AccordionItemContextValue {
+ value: string
+ disabled: boolean
+}
+
+export const AccordionItemContext: Context = createContext<
+ AccordionItemContextValue | undefined
+>(undefined)
+
+export const useAccordionItemContext = (): AccordionItemContextValue => {
+ const context = useContext(AccordionItemContext)
+ if (context === undefined) {
+ throw new Error('Accordion item parts must be rendered within an ')
+ }
+ return context
+}
diff --git a/packages/react/accordion/src/effects.ts b/packages/react/accordion/src/effects.ts
new file mode 100644
index 0000000..044d9f8
--- /dev/null
+++ b/packages/react/accordion/src/effects.ts
@@ -0,0 +1,61 @@
+import type { ComponentEffect } from '@dunky.dev/react-state-machine'
+import { accordionIds } from '@dunky.dev/accordion'
+import type { AccordionMachine, AccordionOptions } from '@dunky.dev/accordion'
+
+// Substrate effects: prop-driven or document-level work the machine can't own.
+// useMachine runs one useEffect per entry, keyed on the listed prop deps.
+type AccordionEffect = ComponentEffect
+
+// Controlled value: follow the `value` prop, translated to the canonical array
+// shape. The machine dedupes, so a same-content array is a no-op.
+const syncControlledValue: AccordionEffect = [
+ (machine, props) => {
+ if (props.value === undefined) return
+ const value =
+ props.type === 'multiple' ? props.value : props.value === null ? [] : [props.value]
+ machine.send({ type: 'value.set', value })
+ },
+ ['value'],
+]
+
+// Config that lives in machine context is synced through events, so guards
+// keep working at runtime — the machine never reads props.
+const syncDisabled: AccordionEffect = [
+ (machine, props) => {
+ const disabled = props.disabled ?? false
+ if (machine.context.disabled !== disabled) {
+ machine.send({ type: 'disabled.set', disabled })
+ }
+ },
+ ['disabled'],
+]
+
+const syncOrientation: AccordionEffect = [
+ (machine, props) => {
+ const orientation = props.orientation ?? 'vertical'
+ if (machine.context.orientation !== orientation) {
+ machine.send({ type: 'orientation.set', orientation })
+ }
+ },
+ ['orientation'],
+]
+
+// The machine decides which trigger holds focus; carrying that decision to the
+// DOM is substrate work. Every mailbox token is fresh, so every move fires —
+// even one that lands on the same trigger.
+const moveDomFocus: AccordionEffect = [
+ machine => {
+ const ids = accordionIds(machine.context.id)
+ return machine.select.context('focusTarget').subscribe(target => {
+ if (target !== null) document.getElementById(ids.trigger(target.value))?.focus()
+ })
+ },
+ [],
+]
+
+export const accordionEffects: AccordionEffect[] = [
+ syncControlledValue,
+ syncDisabled,
+ syncOrientation,
+ moveDomFocus,
+]
diff --git a/packages/react/accordion/src/index.ts b/packages/react/accordion/src/index.ts
new file mode 100644
index 0000000..494b31f
--- /dev/null
+++ b/packages/react/accordion/src/index.ts
@@ -0,0 +1,15 @@
+export {
+ Accordion,
+ type AccordionProps,
+ type AccordionItemProps,
+ type AccordionHeaderProps,
+ type AccordionTriggerProps,
+ type AccordionContentProps,
+} from './accordion'
+export type {
+ AccordionMultipleOptions,
+ AccordionOptions,
+ AccordionOrientation,
+ AccordionSingleOptions,
+ AccordionType,
+} from '@dunky.dev/accordion'
diff --git a/packages/react/accordion/src/use-accordion.ts b/packages/react/accordion/src/use-accordion.ts
new file mode 100644
index 0000000..7cdde77
--- /dev/null
+++ b/packages/react/accordion/src/use-accordion.ts
@@ -0,0 +1,17 @@
+import { useId } from 'react'
+import { useMachine } from '@dunky.dev/react-state-machine'
+import { accordionMachine, accordionConnect } from '@dunky.dev/accordion'
+import type { AccordionOptions } from '@dunky.dev/accordion'
+
+import type { AccordionContextValue } from './context'
+import { accordionEffects } from './effects'
+
+export function useAccordion(options: AccordionOptions): AccordionContextValue {
+ const id = useId()
+ // `?? id` (not spread order): an explicit `id={undefined}` must not knock out
+ // the generated fallback — the focus effect resolves triggers by these ids.
+ return useMachine(accordionMachine, accordionConnect, accordionEffects, {
+ ...options,
+ id: options.id ?? id,
+ })
+}
diff --git a/packages/react/accordion/stories/accordion.stories.tsx b/packages/react/accordion/stories/accordion.stories.tsx
new file mode 100644
index 0000000..58cc6cb
--- /dev/null
+++ b/packages/react/accordion/stories/accordion.stories.tsx
@@ -0,0 +1,184 @@
+import { useState, type CSSProperties, type ReactNode } from 'react'
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { Accordion } from '@dunky.dev/react-accordion'
+
+const meta: Meta = {
+ title: 'Primitives/Accordion',
+ component: Accordion,
+}
+
+export default meta
+type StoryType = StoryObj
+
+// The primitive ships headless — the story is the consumer, so it brings the
+// styles. `data-state` / `data-disabled` on every part are the real styling hooks.
+const list: CSSProperties = {
+ width: 360,
+ border: '1px solid #ccc',
+ borderRadius: 8,
+ overflow: 'hidden',
+}
+const item: CSSProperties = {
+ borderBottom: '1px solid #ccc',
+}
+const header: CSSProperties = {
+ margin: 0,
+}
+const trigger: CSSProperties = {
+ width: '100%',
+ display: 'flex',
+ justifyContent: 'space-between',
+ padding: '12px 16px',
+ border: 'none',
+ background: 'white',
+ font: 'inherit',
+ fontWeight: 600,
+ textAlign: 'start',
+ cursor: 'pointer',
+}
+const content: CSSProperties = {
+ padding: '0 16px 12px',
+ color: '#444',
+}
+const horizontalList: CSSProperties = {
+ display: 'flex',
+ border: '1px solid #ccc',
+ borderRadius: 8,
+ overflow: 'hidden',
+}
+const horizontalItem: CSSProperties = {
+ borderInlineEnd: '1px solid #ccc',
+}
+
+const Section = ({
+ value,
+ title,
+ children,
+ disabled = false,
+}: {
+ value: string
+ title: string
+ children: ReactNode
+ disabled?: boolean
+}) => (
+
+
+ {title}
+
+ {children}
+
+)
+
+export const single: StoryType = {
+ render: () => (
+
+
+
+ Orders ship within 2 business days; delivery takes 3-5 more.
+
+
+ Free returns within 30 days — one open section at a time, and it stays open
+ (non-collapsible is the single-mode default).
+
+
+ Reach support any time; ArrowUp/ArrowDown, Home, and End move between headers.
+
+
+
+ ),
+}
+
+export const collapsible: StoryType = {
+ render: () => (
+
+
+
+ Re-pressing the open header closes it, so the accordion can be fully closed.
+
+
+ Keeping one section open by default matches the APG single-expansion pattern.
+
+
+
+ ),
+}
+
+export const multiple: StoryType = {
+ render: () => (
+
+
+
+ Any number of sections can be open at once.
+
+
+ The multiple mode speaks string[] in value, defaultValue, and onValueChange.
+
+
+ Every item collapses on re-press — collapsibility is inherent to multiple mode.
+
+
+
+ ),
+}
+
+export const disabledItems: StoryType = {
+ render: () => (
+
+
+
+ Arrow navigation skips the disabled header below.
+
+
+ Never opens; presses are ignored by the machine.
+
+
+ ArrowDown from the first header lands here.
+
+
+
+ ),
+}
+
+export const horizontal: StoryType = {
+ render: () => (
+
+
+
+
+ Horizontal orientation swaps the navigation axis to ArrowLeft/ArrowRight.
+
+
+
+
+ The vertical arrows are left alone for the page to handle.
+
+
+
+
+ ),
+}
+
+// Controlled: the consumer owns the value; every press reports through
+// onValueChange and the accordion follows the prop.
+const ControlledAccordion = () => {
+ const [value, setValue] = useState('a')
+ return (
+
+
+ Open section: {value ?? 'none'}
+
+
+
+ The open value lives in the consumer's state.
+
+
+ Presses report intent; the prop drives the machine.
+
+
+
+ )
+}
+
+export const controlled: StoryType = {
+ render: () => ,
+}
diff --git a/packages/react/accordion/tests/accordion.test.tsx b/packages/react/accordion/tests/accordion.test.tsx
new file mode 100644
index 0000000..b2a8103
--- /dev/null
+++ b/packages/react/accordion/tests/accordion.test.tsx
@@ -0,0 +1,242 @@
+// @vitest-environment jsdom
+// The React edge of the accordion — behavior only; the machine's own contract
+// is covered in @dunky.dev/accordion's tests.
+import { StrictMode } from 'react'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Accordion, type AccordionProps } from '@dunky.dev/react-accordion'
+
+type FixtureProps = AccordionProps & { disabledItems?: string[]; items?: string[] }
+
+const DefaultAccordion = ({
+ disabledItems = [],
+ items = ['a', 'b', 'c'],
+ ...props
+}: FixtureProps) => (
+
+ {items.map(value => (
+
+
+ Trigger {value}
+
+ Content {value}
+
+ ))}
+
+)
+
+const trigger = (value: string): HTMLElement =>
+ screen.getByRole('button', { name: `Trigger ${value}` })
+const content = (value: string): HTMLElement => screen.getByText(`Content ${value}`)
+const press = (value: string): void => act(() => trigger(value).click())
+const focusTrigger = (value: string): void => act(() => trigger(value).focus())
+const pressKey = (value: string, key: string): void => {
+ fireEvent.keyDown(trigger(value), { key })
+}
+
+// RTL auto-cleanup needs vitest globals; this repo runs with globals: false.
+afterEach(cleanup)
+
+describe('Accordion', () => {
+ describe('open / close', () => {
+ it('opens on trigger press', () => {
+ render()
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('false')
+
+ press('a')
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('true')
+ })
+
+ it('single: pressing another trigger closes the open item', () => {
+ render()
+ press('b')
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('false')
+ expect(trigger('b').getAttribute('aria-expanded')).toBe('true')
+ })
+
+ it('single: re-press closes the open item when collapsible', () => {
+ render()
+ press('a')
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('multiple: items open independently', () => {
+ render()
+ press('a')
+ press('c')
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('true')
+ expect(trigger('c').getAttribute('aria-expanded')).toBe('true')
+ })
+ })
+
+ describe('keyboard navigation', () => {
+ it('arrows move DOM focus across enabled triggers, skipping disabled and wrapping', () => {
+ render()
+ focusTrigger('a')
+
+ pressKey('a', 'ArrowDown')
+ expect(document.activeElement).toBe(trigger('c'))
+
+ pressKey('c', 'ArrowDown')
+ expect(document.activeElement).toBe(trigger('a'))
+
+ pressKey('a', 'ArrowUp')
+ expect(document.activeElement).toBe(trigger('c'))
+ })
+
+ it('Home and End jump to the first and last triggers', () => {
+ render()
+ focusTrigger('b')
+
+ pressKey('b', 'End')
+ expect(document.activeElement).toBe(trigger('c'))
+
+ pressKey('c', 'Home')
+ expect(document.activeElement).toBe(trigger('a'))
+ })
+
+ it('horizontal: ArrowRight moves focus; the cross-axis key is left alone', () => {
+ render()
+ focusTrigger('a')
+
+ pressKey('a', 'ArrowDown')
+ expect(document.activeElement).toBe(trigger('a'))
+
+ pressKey('a', 'ArrowRight')
+ expect(document.activeElement).toBe(trigger('b'))
+ })
+
+ it('an unmounted item unregisters and leaves the navigation order', () => {
+ const { rerender } = render()
+ rerender()
+ focusTrigger('a')
+
+ pressKey('a', 'ArrowDown')
+ expect(document.activeElement).toBe(trigger('c'))
+ })
+
+ it('registers items once under StrictMode', () => {
+ render(
+
+
+ ,
+ )
+ focusTrigger('a')
+
+ pressKey('a', 'End')
+ expect(document.activeElement).toBe(trigger('c'))
+ })
+ })
+
+ describe('controlled', () => {
+ it('single: follows the value prop in both directions', () => {
+ const { rerender } = render()
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('false')
+
+ rerender()
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('true')
+
+ rerender()
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('multiple: follows the value array prop', () => {
+ const { rerender } = render()
+ rerender()
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('true')
+ expect(trigger('b').getAttribute('aria-expanded')).toBe('true')
+ })
+
+ it('a press the consumer declines leaves the prop authoritative', () => {
+ render()
+ press('b')
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('true')
+ expect(trigger('b').getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('reports presses through onValueChange in the mode shape', () => {
+ const onValueChange = vi.fn()
+ render()
+
+ press('b')
+ expect(onValueChange).toHaveBeenLastCalledWith('b')
+
+ press('b')
+ expect(onValueChange).toHaveBeenLastCalledWith(null)
+ })
+ })
+
+ describe('prop re-sync', () => {
+ it('a disabled flip gates presses, and flipping back restores them', () => {
+ const { rerender } = render()
+
+ rerender()
+ press('a')
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('false')
+
+ rerender()
+ press('a')
+ expect(trigger('a').getAttribute('aria-expanded')).toBe('true')
+ })
+
+ it('an orientation flip moves the arrow axis', () => {
+ const { rerender } = render()
+ rerender()
+ focusTrigger('a')
+
+ pressKey('a', 'ArrowRight')
+ expect(document.activeElement).toBe(trigger('b'))
+ })
+ })
+
+ describe('aria wiring', () => {
+ it('trigger and content reference each other', () => {
+ render()
+ const region = screen.getByRole('region')
+ expect(region).toBe(content('a'))
+ expect(trigger('a').getAttribute('aria-controls')).toBe(region.id)
+ expect(region.getAttribute('aria-labelledby')).toBe(trigger('a').id)
+ })
+
+ it('content is natively hidden while closed', () => {
+ render()
+ expect(content('a').hidden).toBe(true)
+
+ press('a')
+ expect(content('a').hidden).toBe(false)
+ })
+
+ it('disabled trigger is aria-disabled and ignores presses', () => {
+ render()
+ expect(trigger('b').getAttribute('aria-disabled')).toBe('true')
+
+ press('b')
+ expect(trigger('b').getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('part ids derive from the id prop, generated when absent — even an explicit undefined', () => {
+ render()
+ expect(trigger('a').id).toBe('my-acc-trigger-a')
+ cleanup()
+
+ // `accordion` is the core's bare fallback: reaching it would mean the
+ // explicit undefined knocked out the substrate's generated SSR-safe id.
+ render()
+ expect(trigger('a').id).not.toBe('accordion-trigger-a')
+ })
+
+ it('parts carry the data attributes for styling', () => {
+ render()
+ expect(screen.getByTestId('item-a').getAttribute('data-state')).toBe('open')
+ expect(screen.getByTestId('item-b').getAttribute('data-state')).toBe('closed')
+ expect(screen.getByTestId('item-b').getAttribute('data-disabled')).toBe('')
+ expect(trigger('a').getAttribute('data-orientation')).toBe('vertical')
+ expect(content('a').getAttribute('data-state')).toBe('open')
+ })
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2c9ff48..41daf12 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,15 @@ importers:
packages/core: {}
+ packages/core/accordion:
+ dependencies:
+ '@dunky.dev/state-machine':
+ specifier: ^0.1.0
+ version: 0.1.0
+ '@dunky.dev/state-machine-bindings':
+ specifier: ^0.1.0
+ version: 0.1.0
+
packages/core/dialog:
dependencies:
'@dunky.dev/state-machine':
@@ -87,6 +96,31 @@ importers:
specifier: ^8.1.4
version: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)
+ packages/react/accordion:
+ dependencies:
+ '@dunky.dev/accordion':
+ specifier: workspace:^
+ version: link:../../core/accordion
+ '@dunky.dev/react-state-machine':
+ specifier: ^0.1.0
+ version: 0.1.0(react@19.2.7)
+ devDependencies:
+ '@testing-library/react':
+ specifier: ^16.1.0
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@types/react':
+ specifier: ^19.2.15
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.17)
+ react:
+ specifier: ^19.2.6
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.2.6
+ version: 19.2.7(react@19.2.7)
+
packages/react/dialog:
dependencies:
'@dunky.dev/dialog':
diff --git a/tsconfig.json b/tsconfig.json
index cbffd69..b1af869 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,6 +14,8 @@
"skipLibCheck": true,
"strict": true,
"paths": {
+ "@dunky.dev/accordion": ["./packages/core/accordion/src"],
+ "@dunky.dev/react-accordion": ["./packages/react/accordion/src"],
"@dunky.dev/dialog": ["./packages/core/dialog/src"],
"@dunky.dev/react-dialog": ["./packages/react/dialog/src"],
"@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"],
diff --git a/tsdown.config.ts b/tsdown.config.ts
index d2af0e4..75e12fa 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -12,9 +12,11 @@ export default defineConfig({
// real primitive replaces it. tsdown errors on an empty list, so this is never [].
workspace: [
'packages/core',
+ 'packages/core/accordion',
'packages/core/dialog',
'packages/dom/utils/focus-trap',
'packages/dom/utils/scroll-lock',
+ 'packages/react/accordion',
'packages/react/dialog',
'packages/react/hooks/use-focus-trap',
'packages/react/hooks/use-scroll-lock',