diff --git a/.storybook/modules/toc.module.css b/.storybook/modules/toc.module.css new file mode 100644 index 0000000000..9ea699e95e --- /dev/null +++ b/.storybook/modules/toc.module.css @@ -0,0 +1,422 @@ +.Root { + display: flex; + flex-direction: row; + gap: 2rem; + align-items: flex-start; + width: 100%; + max-width: 56rem; +} + +.Title { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--demo-neutral-fg-muted); + padding-inline: 0.5rem; +} + +.List { + list-style: none; + padding: 0; + margin: 0; + position: relative; +} + +.Item { + padding-inline-start: 0; +} + +.ItemNested { + padding-inline-start: calc((var(--depth) - 2) * 1rem); +} + +.Nav { + display: flex; + flex-direction: column; + gap: 0.75rem; + position: sticky; + top: 0; + width: 12rem; + align-self: flex-start; +} + +.HoverRoot { + gap: 0; + + & .Content { + padding-right: 2rem; + } +} + +.NavHover { + position: fixed; + right: 1.5rem; + top: 50%; + transform: translateY(-50%); + width: 0.875rem; + overflow: hidden; + border-radius: 0.5rem; + z-index: 50; + transition: + width 0.2s cubic-bezier(0.4, 0, 0.2, 1), + background 0.2s, + box-shadow 0.2s, + padding 0.15s; + + &[data-expanded] { + width: 11rem; + background: var(--demo-bg, white); + box-shadow: + 0 0 0 1px var(--demo-neutral-border, rgba(0, 0, 0, 0.08)), + 0 4px 16px rgba(0, 0, 0, 0.07); + padding: 0.375rem 0.5rem; + } +} + +.HoverList { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.HoverSwap { + display: grid; + perspective: 300px; + + & > * { + grid-area: 1 / 1; + backface-visibility: hidden; + transform-origin: top center; + } + + & > *[data-state='open'] { + animation: hover-flip-in 0.25s cubic-bezier(0.4, 0, 0.2, 1) 0.06s both; + } + + & > *[data-state='closed'] { + animation: hover-flip-out 0.15s cubic-bezier(0.4, 0, 0.2, 1) both; + } +} + +.HoverSkeletons { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.125rem 0.25rem; + color: var(--demo-neutral-fg-muted); +} + +.HoverLink { + display: block; + padding: 0.2rem 0.25rem; + font-size: 0.8125rem; + text-decoration: none; + border-radius: 0.3rem; + white-space: nowrap; + color: var(--demo-neutral-fg-muted); + transition: + color 0.15s, + background 0.15s; + + &:hover { + background: var(--demo-neutral-subtle); + } + + &[data-active] { + color: var(--demo-coral-fg); + font-weight: 500; + } +} +.HoverSwap [data-state] { + animation: none !important; + transition: none !important; +} + +.SkeletonBar { + display: block; + height: 2px; + border-radius: 9999px; + background: currentColor; + opacity: 0.3; +} + +.PinButton { + display: none; + align-items: center; + justify-content: flex-end; + width: 100%; + padding: 0 0.125rem 0.25rem; + background: none; + border: none; + cursor: pointer; + color: var(--demo-neutral-fg-muted); + + .NavHover[data-expanded] & { + display: flex; + } + + &:hover { + color: var(--demo-neutral-fg); + } +} + +@keyframes hover-flip-in { + from { + opacity: 0; + transform: rotateX(-20deg) translateY(-4px); + } + to { + opacity: 1; + transform: rotateX(0deg) translateY(0); + } +} + +@keyframes hover-flip-out { + from { + opacity: 1; + transform: rotateX(0deg) translateY(0); + } + to { + opacity: 0; + transform: rotateX(20deg) translateY(4px); + } +} + +.RootStacked { + flex-direction: column; + + & .Nav { + width: 100%; + order: -1; + z-index: 100; + background: var(--demo-bg, white); + padding-block: 0.5rem; + } + + & .Content { + height: auto; + overflow-y: visible; + padding-right: 0; + } +} + +.Link, +.LinkAnimated, +.LinkNumbered { + padding: 0.3rem 0.5rem; + font-size: 0.875rem; + color: var(--demo-neutral-fg-muted); + text-decoration: none; + border-radius: 0.375rem; + + &:hover { + color: var(--demo-neutral-fg); + } + + &[data-active] { + color: var(--demo-coral-fg); + font-weight: 500; + } + + &:focus-visible { + outline: 2px solid var(--demo-coral-focus-ring); + outline-offset: 2px; + } +} + +.Link, +.LinkAnimated { + display: block; + transition: color 0.15s; +} + +.LinkAnimated { + transition: + color 0.15s, + transform 0.2s; + + &[data-active] { + background-color: transparent; + transform: translateX(0); + transition: + color 0.15s, + background-color 0.7s ease-out, + transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + + @starting-style { + background-color: color-mix(in srgb, var(--demo-coral-solid) 28%, transparent); + transform: translateX(-8px); + } + } +} + +.TreeLink { + text-decoration: none; + color: inherit; + transition: color 0.15s; + + &[data-active] { + color: var(--demo-coral-fg); + font-weight: 500; + } +} + +.LinkNumbered { + display: flex; + align-items: center; + gap: 0.5rem; + transition: color 0.15s; +} + +.Number { + font-variant-numeric: tabular-nums; + font-size: 0.75rem; + opacity: 0.5; + min-width: 1.25rem; + flex-shrink: 0; +} + +.Indicator { + position: absolute; + top: var(--top); + left: 0; + height: var(--height); + width: 2px; + background: var(--demo-coral-solid); + border-radius: 9999px; + transition: + top 0.2s ease, + height 0.2s ease; +} + +.Content { + flex: 1; + min-width: 0; + height: 42rem; + overflow-y: auto; + padding-right: 1rem; + + & h2, + & h3, + & h4 { + color: var(--demo-neutral-fg); + margin-block: 1.5rem 0.5rem; + + &:first-child { + margin-top: 0; + } + } + + & h2 { + font-size: 1.125rem; + font-weight: 700; + } + + & h3 { + font-size: 1rem; + font-weight: 600; + } + + & h4 { + font-size: 0.875rem; + font-weight: 600; + } + + & p { + font-size: 0.875rem; + line-height: 1.7; + color: var(--demo-neutral-fg-muted); + margin-bottom: 0.75rem; + } +} + +.Group { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.GroupLabel { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--demo-neutral-fg-muted); + padding-inline: 0.5rem; + padding-block: 0.5rem 0.25rem; +} + +.TriggerContent { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.ProgressRing { + flex-shrink: 0; + color: var(--demo-neutral-fg-muted); +} + +.TriggerLabel { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + animation: toc-label-in 0.25s ease; +} + +.ProgressIndexText { + transform-origin: center; + transform-box: fill-box; + animation: toc-index-pop 0.2s ease-out; +} + +@keyframes toc-label-in { + from { + opacity: 0; + transform: translateX(-4px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes toc-index-pop { + from { + opacity: 0; + transform: scale(0.75); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@media (max-width: 640px) { + .Root { + flex-direction: column; + } + + .Nav { + width: 100%; + order: -1; + position: sticky; + top: 0; + z-index: 100; + background: var(--demo-bg, white); + padding-block: 0.5rem; + } + + .Content { + height: auto; + overflow-y: visible; + padding-right: 0; + } +} diff --git a/bun.lock b/bun.lock index 9555c8c296..74c8265da6 100644 --- a/bun.lock +++ b/bun.lock @@ -221,6 +221,7 @@ "@zag-js/tree-view": "1.40.0", "@zag-js/types": "1.40.0", "@zag-js/utils": "1.40.0", + "lorem-ipsum": "2.0.8", }, "devDependencies": { "@biomejs/biome": "2.4.9", @@ -2177,7 +2178,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -2941,6 +2942,8 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lorem-ipsum": ["lorem-ipsum@2.0.8", "", { "dependencies": { "commander": "^9.3.0" }, "bin": { "lorem-ipsum": "dist/bin/lorem-ipsum.bin.js" } }, "sha512-5RIwHuCb979RASgCJH0VKERn9cQo/+NcAi2BMe9ddj+gp7hujl6BI+qdOG4nVsLDpwWEJwTVYXNKP6BGgbcoGA=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], @@ -4207,6 +4210,8 @@ "@better-auth/core/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@bomb.sh/tab/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "@changesets/apply-release-plan/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], @@ -4803,6 +4808,8 @@ "stylehacks/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "svelte/aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], "svelte-check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], diff --git a/packages/react/package.json b/packages/react/package.json index 226e7403ce..c40efedea4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -41,6 +41,7 @@ "tags input", "timer", "toast", + "toc", "toggle group", "tooltip", "tree view", diff --git a/packages/react/src/components/anatomy.ts b/packages/react/src/components/anatomy.ts index 70a0da37c5..e24f5109d0 100644 --- a/packages/react/src/components/anatomy.ts +++ b/packages/react/src/components/anatomy.ts @@ -45,6 +45,7 @@ export { tabsAnatomy } from './tabs/tabs.anatomy' export { tagsInputAnatomy } from './tags-input/tags-input.anatomy' export { timerAnatomy } from './timer/timer.anatomy' export { toastAnatomy } from './toast/toast.anatomy' +export { tocAnatomy } from './toc/toc.anatomy' export { toggleAnatomy } from './toggle/toggle.anatomy' export { toggleGroupAnatomy } from './toggle-group/toggle-group.anatomy' export { tooltipAnatomy } from './tooltip/tooltip.anatomy' diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 9d05042d52..0c13683a0d 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -54,6 +54,7 @@ export * from './tabs' export * from './tags-input' export * from './timer' export * from './toast' +export * from './toc' export * from './toggle' export * from './toggle-group' export * from './tooltip' diff --git a/packages/react/src/components/toc/examples/basic.tsx b/packages/react/src/components/toc/examples/basic.tsx new file mode 100644 index 0000000000..e4a887908c --- /dev/null +++ b/packages/react/src/components/toc/examples/basic.tsx @@ -0,0 +1,43 @@ +import { Toc } from '@ark-ui/react/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const items = [ + { value: 'introduction', depth: 5, label: 'Introduction' }, + { value: 'getting-started', depth: 2, label: 'Getting Started' }, + { value: 'installation', depth: 2, label: 'Installation' }, + { value: 'usage', depth: 2, label: 'Usage' }, + { value: 'configuration', depth: 2, label: 'Configuration' }, + { value: 'migration', depth: 2, label: 'Migration' }, + { value: 'faq', depth: 2, label: 'FAQ' }, + { value: 'troubleshooting', depth: 2, label: 'Troubleshooting' }, + { value: 'api-reference', depth: 2, label: 'API Reference' }, + { value: 'conclusion', depth: 2, label: 'Conclusion' }, +] + +const paragraphs = loremIpsum({ count: 6, units: 'paragraphs' }) + +export const Basic = () => ( + + + {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + On this page + + {items.map((item) => ( + + + {item.label} + + + ))} + + +
+) diff --git a/packages/react/src/components/toc/examples/grouped.tsx b/packages/react/src/components/toc/examples/grouped.tsx new file mode 100644 index 0000000000..08f5e6bd2f --- /dev/null +++ b/packages/react/src/components/toc/examples/grouped.tsx @@ -0,0 +1,61 @@ +import { Toc } from '@ark-ui/react/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const groups = [ + { + label: 'Getting Started', + items: [ + { value: 'overview', depth: 2, label: 'Overview' }, + { value: 'installation', depth: 2, label: 'Installation' }, + ], + }, + { + label: 'Advanced', + items: [ + { value: 'configuration', depth: 2, label: 'Configuration' }, + { value: 'plugins', depth: 2, label: 'Plugins' }, + ], + }, + { + label: 'Reference', + items: [ + { value: 'api', depth: 2, label: 'API' }, + { value: 'changelog', depth: 2, label: 'Changelog' }, + ], + }, +] + +const paragraphs = loremIpsum({ count: 5, units: 'paragraphs' }) + +const allItems = groups.flatMap((g) => g.items) + +export const Grouped = () => ( + + + {allItems.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + + {groups.map((group) => ( +
+ {group.label} + + {group.items.map((item) => ( + + + {item.label} + + + ))} + +
+ ))} +
+
+) diff --git a/packages/react/src/components/toc/examples/nested.tsx b/packages/react/src/components/toc/examples/nested.tsx new file mode 100644 index 0000000000..6f3977b42f --- /dev/null +++ b/packages/react/src/components/toc/examples/nested.tsx @@ -0,0 +1,45 @@ +import { Toc } from '@ark-ui/react/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const paragraphs = loremIpsum({ count: 5, units: 'paragraphs' }) + +const items = [ + { value: 'introduction', depth: 2, label: 'Introduction' }, + { value: 'getting-started', depth: 2, label: 'Getting Started' }, + { value: 'installation', depth: 3, label: 'Installation' }, + { value: 'configuration', depth: 3, label: 'Configuration' }, + { value: 'api-reference', depth: 2, label: 'API Reference' }, + { value: 'hooks', depth: 3, label: 'Hooks' }, + { value: 'components', depth: 3, label: 'Components' }, + { value: 'examples', depth: 2, label: 'Examples' }, +] + +export const Nested = () => ( + + + {items.map((item) => { + const Heading = item.depth === 2 ? 'h2' : 'h3' + return ( +
+ {item.label} +

{paragraphs}

+
+ ) + })} +
+ + + On this page + + {items.map((item) => ( + 2 ? styles.ItemNested : styles.Item} key={item.value} item={item}> + + {item.label} + + + ))} + + +
+) diff --git a/packages/react/src/components/toc/examples/root-provider.tsx b/packages/react/src/components/toc/examples/root-provider.tsx new file mode 100644 index 0000000000..1732b1dbe1 --- /dev/null +++ b/packages/react/src/components/toc/examples/root-provider.tsx @@ -0,0 +1,50 @@ +import { Toc, useToc } from '@ark-ui/react/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const items = [ + { value: 'introduction', depth: 2, label: 'Introduction' }, + { value: 'getting-started', depth: 2, label: 'Getting Started' }, + { value: 'installation', depth: 2, label: 'Installation' }, + { value: 'usage', depth: 2, label: 'Usage' }, + { value: 'configuration', depth: 2, label: 'Configuration' }, + { value: 'migration', depth: 2, label: 'Migration' }, + { value: 'faq', depth: 2, label: 'FAQ' }, + { value: 'troubleshooting', depth: 2, label: 'Troubleshooting' }, + { value: 'api-reference', depth: 2, label: 'API Reference' }, + { value: 'conclusion', depth: 2, label: 'Conclusion' }, +] + +const paragraphs = loremIpsum({ count: 6, units: 'paragraphs' }) + +export const RootProvider = () => { + const toc = useToc({ items }) + + return ( + + + {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + +

Active: {toc.activeIds.length > 0 ? toc.activeIds.join(', ') : '—'}

+ On this page + + + {items.map((item) => ( + + + {item.label} + + + ))} + +
+
+ ) +} diff --git a/packages/react/src/components/toc/examples/with-collapsible.tsx b/packages/react/src/components/toc/examples/with-collapsible.tsx new file mode 100644 index 0000000000..6a213bafc1 --- /dev/null +++ b/packages/react/src/components/toc/examples/with-collapsible.tsx @@ -0,0 +1,108 @@ +import { Collapsible } from '@ark-ui/react/collapsible' +import { Toc } from '@ark-ui/react/toc' +import { loremIpsum } from 'lorem-ipsum' +import { ChevronRightIcon } from 'lucide-react' +import CollapsibleStyles from 'styles/Collapsible.module.css' +import styles from 'styles/toc.module.css' + +const p = loremIpsum({ count: 7, units: 'paragraphs' }) + +const items = [ + { value: 'introduction', depth: 2, label: 'Introduction' }, + { value: 'installation', depth: 2, label: 'Installation' }, + { value: 'usage', depth: 2, label: 'Usage' }, + { value: 'api-reference', depth: 2, label: 'API Reference' }, + { value: 'examples', depth: 2, label: 'Examples' }, +] + +const RADIUS = 14 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +export const WithCollapsible = () => ( + + + {items.map((item) => ( +
+

{item.label}

+

{p}

+
+ ))} +
+ + + + + {({ activeItems }) => { + const activeIndex = activeItems[0] ? items.findIndex((i) => i.value === activeItems[0].value) : -1 + const activeLabel = activeIndex >= 0 ? items[activeIndex].label : undefined + const progress = activeIndex >= 0 ? (activeIndex + 1) / items.length : 0 + const dashArray = `${progress * CIRCUMFERENCE} ${CIRCUMFERENCE}` + + return ( + + + + + + + {activeLabel ?? 'On this page'} + + + + + + + ) + }} + + + + {items.map((item, index) => ( + + + {String(index + 1).padStart(2, '0')} + {item.label} + + + ))} + + + + +
+) diff --git a/packages/react/src/components/toc/examples/with-combobox.tsx b/packages/react/src/components/toc/examples/with-combobox.tsx new file mode 100644 index 0000000000..7edaf1f25d --- /dev/null +++ b/packages/react/src/components/toc/examples/with-combobox.tsx @@ -0,0 +1,83 @@ +import { Combobox, useListCollection } from '@ark-ui/react/combobox' +import { useFilter } from '@ark-ui/react/locale' +import { Portal } from '@ark-ui/react/portal' +import { Toc } from '@ark-ui/react/toc' +import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react' +import comboboxStyles from 'styles/combobox.module.css' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const p = loremIpsum({ count: 6, units: 'paragraphs' }) + +const items = [ + { value: 'introduction', depth: 1, label: 'Introduction' }, + { value: 'installation', depth: 1, label: 'Installation' }, + { value: 'usage', depth: 1, label: 'Usage' }, + { value: 'api-reference', depth: 1, label: 'API Reference' }, + { value: 'examples', depth: 1, label: 'Examples' }, +] + +export const WithCombobox = () => { + const { contains } = useFilter({ sensitivity: 'base' }) + const { collection, filter } = useListCollection({ + initialItems: items.map(({ label, value }) => ({ label, value })), + filter: contains, + }) + + return ( + + + {items.map((item) => ( +
+

{item.label}

+

{p}

+
+ ))} +
+ + + filter(d.inputValue)} + onValueChange={(d) => { + document.getElementById(d.value[0])?.scrollIntoView({ behavior: 'smooth' }) + }} + > + + +
+ + + + + + +
+
+ + + + {collection.items.map((item) => ( + + {item.label} + + + + + ))} + + + +
+ + {items.map((item) => ( + + {item.label} + + ))} + +
+
+ ) +} diff --git a/packages/react/src/components/toc/examples/with-hover.tsx b/packages/react/src/components/toc/examples/with-hover.tsx new file mode 100644 index 0000000000..d14c7832c0 --- /dev/null +++ b/packages/react/src/components/toc/examples/with-hover.tsx @@ -0,0 +1,84 @@ +import { Toc } from '@ark-ui/react/toc' +import { Swap } from '@ark-ui/react/swap' +import { Pin, PinOff } from 'lucide-react' +import { useState } from 'react' +import { loremIpsum } from 'lorem-ipsum' +import styles from 'styles/toc.module.css' + +const items = [ + { value: 'introduction', depth: 2, label: 'Introduction' }, + { value: 'getting-started', depth: 2, label: 'Getting Started' }, + { value: 'installation', depth: 2, label: 'Installation' }, + { value: 'usage', depth: 2, label: 'Usage' }, + { value: 'api', depth: 2, label: 'API' }, + { value: 'examples', depth: 2, label: 'Examples' }, +] + +const paragraphs = loremIpsum({ count: 7, units: 'paragraphs' }) + +export const WithHover = () => { + const [pinned, setPinned] = useState(false) + const [hovered, setHovered] = useState(false) + + return ( + +
+ {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + + +
+ {items.map((item) => ( + + ))} +
+
+ +
+ + {items.map((item) => ( + + + {item.label} + + + ))} + +
+
+
+
+
+ ) +} diff --git a/packages/react/src/components/toc/examples/with-indicator.tsx b/packages/react/src/components/toc/examples/with-indicator.tsx new file mode 100644 index 0000000000..496a0272c8 --- /dev/null +++ b/packages/react/src/components/toc/examples/with-indicator.tsx @@ -0,0 +1,42 @@ +import { Toc } from '@ark-ui/react/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const items = [ + { value: 'overview', depth: 2, label: 'Overview' }, + { value: 'architecture', depth: 2, label: 'Architecture' }, + { value: 'state-machines', depth: 2, label: 'State Machines' }, + { value: 'components', depth: 2, label: 'Components' }, + { value: 'theming', depth: 2, label: 'Theming' }, + { value: 'accessibility', depth: 2, label: 'Accessibility' }, + { value: 'conclusion', depth: 2, label: 'Conclusion' }, +] + +const paragraphs = loremIpsum({ count: 6, units: 'paragraphs' }) + +export const WithIndicator = () => ( + + + {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + + On this page + + + {items.map((item) => ( + + + {item.label} + + + ))} + + +
+) diff --git a/packages/react/src/components/toc/examples/with-numbers.tsx b/packages/react/src/components/toc/examples/with-numbers.tsx new file mode 100644 index 0000000000..e4ee5b3c9a --- /dev/null +++ b/packages/react/src/components/toc/examples/with-numbers.tsx @@ -0,0 +1,39 @@ +import { Toc } from '@ark-ui/react/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const items = [ + { value: 'executive-summary', depth: 2, label: 'Executive Summary' }, + { value: 'methodology', depth: 2, label: 'Methodology' }, + { value: 'findings', depth: 2, label: 'Findings' }, + { value: 'recommendations', depth: 2, label: 'Recommendations' }, + { value: 'conclusion', depth: 2, label: 'Conclusion' }, +] + +const paragraphs = loremIpsum({ count: 5, units: 'paragraphs' }) + +export const WithNumbers = () => ( + + + {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + Contents + + {items.map((item, index) => ( + + + {String(index + 1).padStart(2, '0')} + {item.label} + + + ))} + + +
+) diff --git a/packages/react/src/components/toc/examples/with-tree-view.tsx b/packages/react/src/components/toc/examples/with-tree-view.tsx new file mode 100644 index 0000000000..fefac3bf41 --- /dev/null +++ b/packages/react/src/components/toc/examples/with-tree-view.tsx @@ -0,0 +1,145 @@ +import { Toc, useTocContext } from '@ark-ui/react/toc' +import { TreeView, createTreeCollection } from '@ark-ui/react/tree-view' +import { loremIpsum } from 'lorem-ipsum' +import { ChevronRightIcon } from 'lucide-react' +import { useState } from 'react' +import tocStyles from 'styles/toc.module.css' +import treeStyles from 'styles/tree-view.module.css' + +const p = loremIpsum({ count: 7, units: 'paragraphs' }) + +type TocNode = { + id: string + name: string + depth: number + children?: TocNode[] +} + +const sections: TocNode[] = [ + { + id: 'guides', + name: 'Guides', + depth: 2, + children: [ + { id: 'quick-start', name: 'Quick Start', depth: 3 }, + { id: 'manual-setup', name: 'Manual Setup', depth: 3 }, + ], + }, + { + id: 'core-concepts', + name: 'Core Concepts', + depth: 2, + children: [ + { id: 'props', name: 'Props', depth: 3 }, + { id: 'events', name: 'Events', depth: 3 }, + { id: 'context', name: 'Context', depth: 3 }, + ], + }, + { + id: 'advanced', + name: 'Advanced', + depth: 2, + children: [ + { id: 'root-provider', name: 'Root Provider', depth: 3 }, + { id: 'custom-rendering', name: 'Custom Rendering', depth: 3 }, + ], + }, +] + +const collection = createTreeCollection({ + nodeToValue: (node) => node.id, + nodeToString: (node) => node.name, + rootNode: { id: 'ROOT', name: '', depth: 0, children: sections }, +}) + +const allItems = sections.flatMap((section) => [ + { value: section.id, depth: section.depth }, + ...(section.children ?? []).map((child) => ({ value: child.id, depth: child.depth })), +]) + +const TocTreeNode = ({ node, indexPath }: TreeView.NodeProviderProps) => { + const toc = useTocContext() + return ( + + {node.children ? ( + + + + + + + + {node.name} + + + + + + {node.children.map((child, index) => ( + + ))} + + + ) : ( + + + + {node.name} + + + + )} + + ) +} + +export const WithTreeView = () => { + const [expandedValue, setExpandedValue] = useState([]) + + return ( + { + const activeIds = new Set(activeItems.map((i) => i.value)) + const next = sections + .filter( + (section) => activeIds.has(section.id) || (section.children ?? []).some((child) => activeIds.has(child.id)), + ) + .map((s) => s.id) + setExpandedValue(next) + }} + > + + {sections.map((section) => ( +
+

{section.name}

+

{p}

+ {section.children?.map((child) => ( +
+

{child.name}

+

{p}

+
+ ))} +
+ ))} +
+ + + On this page + setExpandedValue(next)} + > + + {sections.map((node, index) => ( + + ))} + + + +
+ ) +} diff --git a/packages/react/src/components/toc/index.ts b/packages/react/src/components/toc/index.ts new file mode 100644 index 0000000000..af93ee014c --- /dev/null +++ b/packages/react/src/components/toc/index.ts @@ -0,0 +1,16 @@ +export type { ActiveChangeDetails as TocActiveChangeDetails, TocItem as TocItemData } from '@zag-js/toc' +export { TocContent, type TocContentBaseProps, type TocContentProps } from './toc-content' +export { TocContext, type TocContextProps } from './toc-context' +export { TocIndicator, type TocIndicatorBaseProps, type TocIndicatorProps } from './toc-indicator' +export { TocItem, type TocItemBaseProps, type TocItemProps } from './toc-item' +export { TocLink, type TocLinkBaseProps, type TocLinkProps } from './toc-link' +export { TocList, type TocListBaseProps, type TocListProps } from './toc-list' +export { TocNav, type TocNavBaseProps, type TocNavProps } from './toc-nav' +export { TocRoot, type TocRootBaseProps, type TocRootProps } from './toc-root' +export { TocRootProvider, type TocRootProviderBaseProps, type TocRootProviderProps } from './toc-root-provider' +export { TocTitle, type TocTitleBaseProps, type TocTitleProps } from './toc-title' +export { tocAnatomy } from './toc.anatomy' +export { useToc, type UseTocProps, type UseTocReturn } from './use-toc' +export { useTocContext, type UseTocContext } from './use-toc-context' + +export * as Toc from './toc' diff --git a/packages/react/src/components/toc/tests/basic.tsx b/packages/react/src/components/toc/tests/basic.tsx new file mode 100644 index 0000000000..75a88bb49b --- /dev/null +++ b/packages/react/src/components/toc/tests/basic.tsx @@ -0,0 +1,32 @@ +import { Toc } from '../' + +export const items = [ + { value: 'introduction', depth: 2 }, + { value: 'getting-started', depth: 2 }, + { value: 'installation', depth: 2 }, + { value: 'usage', depth: 2 }, + { value: 'api-reference', depth: 2 }, +] + +export const ComponentUnderTest = (props: Omit) => ( + + +

Introduction

+

Getting Started

+

Installation

+

Usage

+

API Reference

+
+ + On this page + + + {items.map((item) => ( + + {item.value.replace(/-/g, ' ')} + + ))} + + +
+) diff --git a/packages/react/src/components/toc/tests/toc.test.tsx b/packages/react/src/components/toc/tests/toc.test.tsx new file mode 100644 index 0000000000..98d2a8b940 --- /dev/null +++ b/packages/react/src/components/toc/tests/toc.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { axe } from 'vitest-axe' +import { Toc } from '../' +import { ComponentUnderTest, items } from './basic' + +class MockIntersectionObserver { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() +} + +beforeAll(() => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) +}) + +afterAll(() => { + vi.unstubAllGlobals() +}) + +describe('Toc', () => { + it('should have no a11y violations', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('should render Nav as a nav landmark', () => { + render() + expect(screen.getByRole('navigation')).toBeInTheDocument() + }) + + it('should render Content as an article', () => { + const { container } = render() + expect(container.querySelector('article')).toBeInTheDocument() + }) + + it('should render the title', () => { + render() + expect(screen.getByText('On this page')).toBeInTheDocument() + }) + + it('should render all links with correct hrefs', () => { + render() + expect(screen.getByRole('link', { name: 'introduction' })).toHaveAttribute('href', '#introduction') + expect(screen.getByRole('link', { name: 'usage' })).toHaveAttribute('href', '#usage') + expect(screen.getByRole('link', { name: 'api reference' })).toHaveAttribute('href', '#api-reference') + }) + + it('should render the indicator', () => { + render() + expect(screen.getByTestId('indicator')).toHaveAttribute('data-part', 'indicator') + }) + + it('should apply data-part attributes to all parts', () => { + const { container } = render() + expect(container.querySelector('[data-part="title"]')).toBeInTheDocument() + expect(container.querySelector('[data-part="list"]')).toBeInTheDocument() + expect(container.querySelector('[data-part="item"]')).toBeInTheDocument() + expect(container.querySelector('[data-part="link"]')).toBeInTheDocument() + expect(container.querySelector('[data-part="indicator"]')).toBeInTheDocument() + }) + + it('should set data-active on the link matching defaultActiveIds', () => { + const { container } = render() + expect(container.querySelector('[data-part="link"][data-value="usage"]')).toHaveAttribute('data-active', '') + expect(container.querySelector('[data-part="link"][data-value="introduction"]')).not.toHaveAttribute('data-active') + }) + + it('should support multiple defaultActiveIds', () => { + const { container } = render() + expect(container.querySelector('[data-part="link"][data-value="introduction"]')).toHaveAttribute('data-active', '') + expect(container.querySelector('[data-part="link"][data-value="usage"]')).toHaveAttribute('data-active', '') + expect(container.querySelector('[data-part="link"][data-value="installation"]')).not.toHaveAttribute('data-active') + }) + + it('should reflect controlled activeIds', () => { + const { container } = render() + expect(container.querySelector('[data-part="link"][data-value="installation"]')).toHaveAttribute('data-active', '') + expect(container.querySelector('[data-part="link"][data-value="introduction"]')).not.toHaveAttribute('data-active') + }) + + it('should update active link when controlled activeIds change', () => { + const { container, rerender } = render() + expect(container.querySelector('[data-part="link"][data-value="introduction"]')).toHaveAttribute('data-active', '') + + rerender() + expect(container.querySelector('[data-part="link"][data-value="usage"]')).toHaveAttribute('data-active', '') + expect(container.querySelector('[data-part="link"][data-value="introduction"]')).not.toHaveAttribute('data-active') + }) + + it('should expose activeIds via Context render prop', () => { + render( + + + {(ctx) => {ctx.activeIds.length}} + + + introduction + + + + , + ) + expect(screen.getByTestId('active-count')).toHaveTextContent('1') + }) +}) diff --git a/packages/react/src/components/toc/toc-content.tsx b/packages/react/src/components/toc/toc-content.tsx new file mode 100644 index 0000000000..074267e90c --- /dev/null +++ b/packages/react/src/components/toc/toc-content.tsx @@ -0,0 +1,11 @@ +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' + +export interface TocContentBaseProps extends PolymorphicProps {} +export interface TocContentProps extends HTMLProps<'article'>, TocContentBaseProps {} + +export const TocContent = forwardRef((props, ref) => { + return +}) + +TocContent.displayName = 'TocContent' diff --git a/packages/react/src/components/toc/toc-context.tsx b/packages/react/src/components/toc/toc-context.tsx new file mode 100644 index 0000000000..a977b1a1de --- /dev/null +++ b/packages/react/src/components/toc/toc-context.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react' +import { type UseTocContext, useTocContext } from './use-toc-context' + +export interface TocContextProps { + children: (context: UseTocContext) => ReactNode +} + +export const TocContext = (props: TocContextProps) => props.children(useTocContext()) diff --git a/packages/react/src/components/toc/toc-indicator.tsx b/packages/react/src/components/toc/toc-indicator.tsx new file mode 100644 index 0000000000..8209b48c33 --- /dev/null +++ b/packages/react/src/components/toc/toc-indicator.tsx @@ -0,0 +1,16 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' + +export interface TocIndicatorBaseProps extends PolymorphicProps {} +export interface TocIndicatorProps extends HTMLProps<'div'>, TocIndicatorBaseProps {} + +export const TocIndicator = forwardRef((props, ref) => { + const toc = useTocContext() + const mergedProps = mergeProps(toc.getIndicatorProps(), props) + + return +}) + +TocIndicator.displayName = 'TocIndicator' diff --git a/packages/react/src/components/toc/toc-item.tsx b/packages/react/src/components/toc/toc-item.tsx new file mode 100644 index 0000000000..4c68ab7952 --- /dev/null +++ b/packages/react/src/components/toc/toc-item.tsx @@ -0,0 +1,26 @@ +import { mergeProps } from '@zag-js/react' +import type { ItemProps } from '@zag-js/toc' +import { forwardRef } from 'react' +import { createSplitProps } from '../../utils/create-split-props' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' +import { TocItemPropsProvider } from './use-toc-item-props-context' + +export interface TocItemBaseProps extends ItemProps, PolymorphicProps {} +export interface TocItemProps extends HTMLProps<'li'>, TocItemBaseProps {} + +const splitItemProps = createSplitProps() + +export const TocItem = forwardRef((props, ref) => { + const [itemProps, localProps] = splitItemProps(props, ['item']) + const toc = useTocContext() + const mergedProps = mergeProps(toc.getItemProps(itemProps), localProps) + + return ( + + + + ) +}) + +TocItem.displayName = 'TocItem' diff --git a/packages/react/src/components/toc/toc-link.tsx b/packages/react/src/components/toc/toc-link.tsx new file mode 100644 index 0000000000..ca090f8f29 --- /dev/null +++ b/packages/react/src/components/toc/toc-link.tsx @@ -0,0 +1,18 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' +import { useTocItemPropsContext } from './use-toc-item-props-context' + +export interface TocLinkBaseProps extends PolymorphicProps {} +export interface TocLinkProps extends HTMLProps<'a'>, TocLinkBaseProps {} + +export const TocLink = forwardRef((props, ref) => { + const toc = useTocContext() + const itemProps = useTocItemPropsContext() + const mergedProps = mergeProps(toc.getLinkProps(itemProps), props) + + return +}) + +TocLink.displayName = 'TocLink' diff --git a/packages/react/src/components/toc/toc-list.tsx b/packages/react/src/components/toc/toc-list.tsx new file mode 100644 index 0000000000..2cd7dceb5a --- /dev/null +++ b/packages/react/src/components/toc/toc-list.tsx @@ -0,0 +1,16 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' + +export interface TocListBaseProps extends PolymorphicProps {} +export interface TocListProps extends HTMLProps<'ul'>, TocListBaseProps {} + +export const TocList = forwardRef((props, ref) => { + const toc = useTocContext() + const mergedProps = mergeProps(toc.getListProps(), props) + + return +}) + +TocList.displayName = 'TocList' diff --git a/packages/react/src/components/toc/toc-nav.tsx b/packages/react/src/components/toc/toc-nav.tsx new file mode 100644 index 0000000000..3bb8172829 --- /dev/null +++ b/packages/react/src/components/toc/toc-nav.tsx @@ -0,0 +1,19 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' + +export interface TocNavBaseProps extends PolymorphicProps { + placement?: 'left' | 'right' +} +export interface TocNavProps extends HTMLProps<'nav'>, TocNavBaseProps {} + +export const TocNav = forwardRef((props, ref) => { + const { placement, ...rest } = props + const toc = useTocContext() + const mergedProps = mergeProps(toc.getRootProps(), rest) + + return +}) + +TocNav.displayName = 'TocNav' diff --git a/packages/react/src/components/toc/toc-root-provider.tsx b/packages/react/src/components/toc/toc-root-provider.tsx new file mode 100644 index 0000000000..1e99b170d3 --- /dev/null +++ b/packages/react/src/components/toc/toc-root-provider.tsx @@ -0,0 +1,27 @@ +import { forwardRef } from 'react' +import type { Assign } from '../../types' +import { createSplitProps } from '../../utils/create-split-props' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import type { UseTocReturn } from './use-toc' +import { TocProvider } from './use-toc-context' + +interface RootProviderProps { + value: UseTocReturn +} + +export interface TocRootProviderBaseProps extends RootProviderProps, PolymorphicProps {} +export interface TocRootProviderProps extends Assign, TocRootProviderBaseProps> {} + +const splitRootProviderProps = createSplitProps() + +export const TocRootProvider = forwardRef((props, ref) => { + const [{ value: toc }, localProps] = splitRootProviderProps(props, ['value']) + + return ( + + + + ) +}) + +TocRootProvider.displayName = 'TocRootProvider' diff --git a/packages/react/src/components/toc/toc-root.tsx b/packages/react/src/components/toc/toc-root.tsx new file mode 100644 index 0000000000..4d640c10bd --- /dev/null +++ b/packages/react/src/components/toc/toc-root.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from 'react' +import type { Assign } from '../../types' +import { createSplitProps } from '../../utils/create-split-props' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { type UseTocProps, useToc } from './use-toc' +import { TocProvider } from './use-toc-context' + +export interface TocRootBaseProps extends UseTocProps, PolymorphicProps {} +export interface TocRootProps extends Assign, TocRootBaseProps> {} + +const splitRootProps = createSplitProps() + +export const TocRoot = forwardRef((props, ref) => { + const [useTocProps, localProps] = splitRootProps(props, [ + 'activeIds', + 'autoScroll', + 'defaultActiveIds', + 'getScrollEl', + 'id', + 'ids', + 'items', + 'onActiveChange', + 'rootMargin', + 'scrollBehavior', + 'threshold', + ]) + const toc = useToc(useTocProps) + + return ( + + + + ) +}) + +TocRoot.displayName = 'TocRoot' diff --git a/packages/react/src/components/toc/toc-title.tsx b/packages/react/src/components/toc/toc-title.tsx new file mode 100644 index 0000000000..c092cd67c4 --- /dev/null +++ b/packages/react/src/components/toc/toc-title.tsx @@ -0,0 +1,16 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' + +export interface TocTitleBaseProps extends PolymorphicProps {} +export interface TocTitleProps extends HTMLProps<'h2'>, TocTitleBaseProps {} + +export const TocTitle = forwardRef((props, ref) => { + const toc = useTocContext() + const mergedProps = mergeProps(toc.getTitleProps(), props) + + return +}) + +TocTitle.displayName = 'TocTitle' diff --git a/packages/react/src/components/toc/toc.anatomy.ts b/packages/react/src/components/toc/toc.anatomy.ts new file mode 100644 index 0000000000..560bfdc21b --- /dev/null +++ b/packages/react/src/components/toc/toc.anatomy.ts @@ -0,0 +1 @@ +export { anatomy as tocAnatomy } from '@zag-js/toc' diff --git a/packages/react/src/components/toc/toc.stories.tsx b/packages/react/src/components/toc/toc.stories.tsx new file mode 100644 index 0000000000..d971a821d1 --- /dev/null +++ b/packages/react/src/components/toc/toc.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta } from '@storybook/react-vite' + +const meta: Meta = { + title: 'Components / Toc', +} + +export default meta + +export { Basic } from './examples/basic' +export { Grouped } from './examples/grouped' +export { Nested } from './examples/nested' +export { RootProvider } from './examples/root-provider' +export { WithCollapsible } from './examples/with-collapsible' +export { WithHover } from './examples/with-hover' +export { WithIndicator } from './examples/with-indicator' +export { WithNumbers } from './examples/with-numbers' +export { WithTreeView } from './examples/with-tree-view' diff --git a/packages/react/src/components/toc/toc.ts b/packages/react/src/components/toc/toc.ts new file mode 100644 index 0000000000..c5813b27fc --- /dev/null +++ b/packages/react/src/components/toc/toc.ts @@ -0,0 +1,27 @@ +export type { ActiveChangeDetails as TocActiveChangeDetails, TocItem as TocItemData } from '@zag-js/toc' +export { + TocContent as Content, + type TocContentBaseProps as ContentBaseProps, + type TocContentProps as ContentProps, +} from './toc-content' +export { TocContext as Context, type TocContextProps as ContextProps } from './toc-context' +export { + TocIndicator as Indicator, + type TocIndicatorBaseProps as IndicatorBaseProps, + type TocIndicatorProps as IndicatorProps, +} from './toc-indicator' +export { TocItem as Item, type TocItemBaseProps as ItemBaseProps, type TocItemProps as ItemProps } from './toc-item' +export { TocLink as Link, type TocLinkBaseProps as LinkBaseProps, type TocLinkProps as LinkProps } from './toc-link' +export { TocList as List, type TocListBaseProps as ListBaseProps, type TocListProps as ListProps } from './toc-list' +export { TocNav as Nav, type TocNavBaseProps as NavBaseProps, type TocNavProps as NavProps } from './toc-nav' +export { TocRoot as Root, type TocRootBaseProps as RootBaseProps, type TocRootProps as RootProps } from './toc-root' +export { + TocRootProvider as RootProvider, + type TocRootProviderBaseProps as RootProviderBaseProps, + type TocRootProviderProps as RootProviderProps, +} from './toc-root-provider' +export { + TocTitle as Title, + type TocTitleBaseProps as TitleBaseProps, + type TocTitleProps as TitleProps, +} from './toc-title' diff --git a/packages/react/src/components/toc/use-toc-context.ts b/packages/react/src/components/toc/use-toc-context.ts new file mode 100644 index 0000000000..b59c7764b5 --- /dev/null +++ b/packages/react/src/components/toc/use-toc-context.ts @@ -0,0 +1,10 @@ +import { createContext } from '../../utils/create-context' +import type { UseTocReturn } from './use-toc' + +export interface UseTocContext extends UseTocReturn {} + +export const [TocProvider, useTocContext] = createContext({ + name: 'TocContext', + hookName: 'useTocContext', + providerName: '', +}) diff --git a/packages/react/src/components/toc/use-toc-item-props-context.ts b/packages/react/src/components/toc/use-toc-item-props-context.ts new file mode 100644 index 0000000000..4b63341aa2 --- /dev/null +++ b/packages/react/src/components/toc/use-toc-item-props-context.ts @@ -0,0 +1,10 @@ +import type { ItemProps } from '@zag-js/toc' +import { createContext } from '../../utils/create-context' + +export interface UseTocItemPropsContext extends ItemProps {} + +export const [TocItemPropsProvider, useTocItemPropsContext] = createContext({ + name: 'TocItemPropsContext', + hookName: 'useTocItemPropsContext', + providerName: '', +}) diff --git a/packages/react/src/components/toc/use-toc.ts b/packages/react/src/components/toc/use-toc.ts new file mode 100644 index 0000000000..c5b047968d --- /dev/null +++ b/packages/react/src/components/toc/use-toc.ts @@ -0,0 +1,26 @@ +import { type PropTypes, normalizeProps, useMachine } from '@zag-js/react' +import * as toc from '@zag-js/toc' +import { useId } from 'react' +import { useEnvironmentContext, useLocaleContext } from '../../providers' +import type { Optional } from '../../types' + +export interface UseTocProps extends Optional, 'id'> {} + +export interface UseTocReturn extends toc.Api {} + +export const useToc = (props?: UseTocProps): UseTocReturn => { + const id = useId() + const { getRootNode } = useEnvironmentContext() + const { dir } = useLocaleContext() + + const machineProps = { + id, + dir, + getRootNode, + items: [], + ...props, + } as toc.Props + + const service = useMachine(toc.machine as any, machineProps) + return toc.connect(service as any, normalizeProps) +} diff --git a/packages/solid/package.json b/packages/solid/package.json index 6a0ac3a693..bc8a70367f 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -39,6 +39,7 @@ "tabs", "tags input", "toast", + "toc", "toggle group", "tooltip", "tour", @@ -110,9 +111,9 @@ "@zag-js/editable": "1.40.0", "@zag-js/file-upload": "1.40.0", "@zag-js/file-utils": "1.40.0", - "@zag-js/focus-visible": "1.40.0", "@zag-js/floating-panel": "1.40.0", "@zag-js/focus-trap": "1.40.0", + "@zag-js/focus-visible": "1.40.0", "@zag-js/highlight-word": "1.40.0", "@zag-js/hover-card": "1.40.0", "@zag-js/i18n-utils": "1.40.0", @@ -150,7 +151,8 @@ "@zag-js/tour": "1.40.0", "@zag-js/tree-view": "1.40.0", "@zag-js/types": "1.40.0", - "@zag-js/utils": "1.40.0" + "@zag-js/utils": "1.40.0", + "lorem-ipsum": "2.0.8" }, "devDependencies": { "@biomejs/biome": "2.4.9", diff --git a/packages/solid/src/components/anatomy.ts b/packages/solid/src/components/anatomy.ts index 70a0da37c5..e24f5109d0 100644 --- a/packages/solid/src/components/anatomy.ts +++ b/packages/solid/src/components/anatomy.ts @@ -45,6 +45,7 @@ export { tabsAnatomy } from './tabs/tabs.anatomy' export { tagsInputAnatomy } from './tags-input/tags-input.anatomy' export { timerAnatomy } from './timer/timer.anatomy' export { toastAnatomy } from './toast/toast.anatomy' +export { tocAnatomy } from './toc/toc.anatomy' export { toggleAnatomy } from './toggle/toggle.anatomy' export { toggleGroupAnatomy } from './toggle-group/toggle-group.anatomy' export { tooltipAnatomy } from './tooltip/tooltip.anatomy' diff --git a/packages/solid/src/components/index.tsx b/packages/solid/src/components/index.tsx index 9f918231ff..2d45002b92 100644 --- a/packages/solid/src/components/index.tsx +++ b/packages/solid/src/components/index.tsx @@ -53,6 +53,7 @@ export * from './tabs' export * from './tags-input' export * from './timer' export * from './toast' +export * from './toc' export * from './toggle-group' export * from './toggle' export * from './tooltip' diff --git a/packages/solid/src/components/popover/popover-root.tsx b/packages/solid/src/components/popover/popover-root.tsx index 3ae62ff7ba..ca37530201 100644 --- a/packages/solid/src/components/popover/popover-root.tsx +++ b/packages/solid/src/components/popover/popover-root.tsx @@ -17,7 +17,6 @@ export const PopoverRoot = (props: PopoverRootProps) => { 'closeOnEscape', 'closeOnInteractOutside', 'defaultOpen', - 'finalFocusEl', 'id', 'ids', 'initialFocusEl', @@ -32,12 +31,11 @@ export const PopoverRoot = (props: PopoverRootProps) => { 'persistentElements', 'portalled', 'positioning', - 'restoreFocus', 'translations', 'triggerValue', 'defaultTriggerValue', 'onTriggerValueChange', - ]) + ] as const) const api = usePopover(usePopoverProps) const apiPresence = usePresence(mergeProps(presenceProps, () => ({ present: api().open }))) diff --git a/packages/solid/src/components/toc/examples/basic.tsx b/packages/solid/src/components/toc/examples/basic.tsx new file mode 100644 index 0000000000..07d653dde4 --- /dev/null +++ b/packages/solid/src/components/toc/examples/basic.tsx @@ -0,0 +1,43 @@ +import { Toc } from '@ark-ui/solid/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const items = [ + { value: 'introduction', depth: 5, label: 'Introduction' }, + { value: 'getting-started', depth: 2, label: 'Getting Started' }, + { value: 'installation', depth: 2, label: 'Installation' }, + { value: 'usage', depth: 2, label: 'Usage' }, + { value: 'configuration', depth: 2, label: 'Configuration' }, + { value: 'migration', depth: 2, label: 'Migration' }, + { value: 'faq', depth: 2, label: 'FAQ' }, + { value: 'troubleshooting', depth: 2, label: 'Troubleshooting' }, + { value: 'api-reference', depth: 2, label: 'API Reference' }, + { value: 'conclusion', depth: 2, label: 'Conclusion' }, +] + +const paragraphs = loremIpsum({ count: 6, units: 'paragraphs' }) + +export const Basic = () => ( + + + {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + On this page + + {items.map((item) => ( + + + {item.label} + + + ))} + + +
+) diff --git a/packages/solid/src/components/toc/examples/grouped.tsx b/packages/solid/src/components/toc/examples/grouped.tsx new file mode 100644 index 0000000000..36e5f52577 --- /dev/null +++ b/packages/solid/src/components/toc/examples/grouped.tsx @@ -0,0 +1,61 @@ +import { Toc } from '@ark-ui/solid/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const groups = [ + { + label: 'Getting Started', + items: [ + { value: 'overview', depth: 2, label: 'Overview' }, + { value: 'installation', depth: 2, label: 'Installation' }, + ], + }, + { + label: 'Advanced', + items: [ + { value: 'configuration', depth: 2, label: 'Configuration' }, + { value: 'plugins', depth: 2, label: 'Plugins' }, + ], + }, + { + label: 'Reference', + items: [ + { value: 'api', depth: 2, label: 'API' }, + { value: 'changelog', depth: 2, label: 'Changelog' }, + ], + }, +] + +const paragraphs = loremIpsum({ count: 5, units: 'paragraphs' }) + +const allItems = groups.flatMap((g) => g.items) + +export const Grouped = () => ( + + + {allItems.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + + {groups.map((group) => ( +
+ {group.label} + + {group.items.map((item) => ( + + + {item.label} + + + ))} + +
+ ))} +
+
+) diff --git a/packages/solid/src/components/toc/examples/nested.tsx b/packages/solid/src/components/toc/examples/nested.tsx new file mode 100644 index 0000000000..63a157cc31 --- /dev/null +++ b/packages/solid/src/components/toc/examples/nested.tsx @@ -0,0 +1,45 @@ +import { Toc } from '@ark-ui/solid/toc' +import { Dynamic } from 'solid-js/web' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const paragraphs = loremIpsum({ count: 5, units: 'paragraphs' }) + +const items = [ + { value: 'introduction', depth: 2, label: 'Introduction' }, + { value: 'getting-started', depth: 2, label: 'Getting Started' }, + { value: 'installation', depth: 3, label: 'Installation' }, + { value: 'configuration', depth: 3, label: 'Configuration' }, + { value: 'api-reference', depth: 2, label: 'API Reference' }, + { value: 'hooks', depth: 3, label: 'Hooks' }, + { value: 'components', depth: 3, label: 'Components' }, + { value: 'examples', depth: 2, label: 'Examples' }, +] + +export const Nested = () => ( + + + {items.map((item) => ( +
+ + {item.label} + +

{paragraphs}

+
+ ))} +
+ + + On this page + + {items.map((item) => ( + 2 ? styles.ItemNested : styles.Item} item={item}> + + {item.label} + + + ))} + + +
+) diff --git a/packages/solid/src/components/toc/examples/root-provider.tsx b/packages/solid/src/components/toc/examples/root-provider.tsx new file mode 100644 index 0000000000..6a10f4b8cd --- /dev/null +++ b/packages/solid/src/components/toc/examples/root-provider.tsx @@ -0,0 +1,49 @@ +import { Toc, useToc } from '@ark-ui/solid/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const items = [ + { value: 'introduction', depth: 2, label: 'Introduction' }, + { value: 'getting-started', depth: 2, label: 'Getting Started' }, + { value: 'installation', depth: 2, label: 'Installation' }, + { value: 'usage', depth: 2, label: 'Usage' }, + { value: 'configuration', depth: 2, label: 'Configuration' }, + { value: 'migration', depth: 2, label: 'Migration' }, + { value: 'faq', depth: 2, label: 'FAQ' }, + { value: 'troubleshooting', depth: 2, label: 'Troubleshooting' }, + { value: 'api-reference', depth: 2, label: 'API Reference' }, + { value: 'conclusion', depth: 2, label: 'Conclusion' }, +] + +const paragraphs = loremIpsum({ count: 6, units: 'paragraphs' }) + +export const RootProvider = () => { + const toc = useToc({ items }) + + return ( + + + {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + +

Active: {toc().activeIds.length > 0 ? toc().activeIds.join(', ') : '—'}

+ On this page + + {items.map((item) => ( + + + {item.label} + + + ))} + +
+
+ ) +} diff --git a/packages/solid/src/components/toc/examples/with-collapsible.tsx b/packages/solid/src/components/toc/examples/with-collapsible.tsx new file mode 100644 index 0000000000..3439a9dba1 --- /dev/null +++ b/packages/solid/src/components/toc/examples/with-collapsible.tsx @@ -0,0 +1,115 @@ +import { Collapsible } from '@ark-ui/solid/collapsible' +import { Toc } from '@ark-ui/solid/toc' +import { loremIpsum } from 'lorem-ipsum' +import { ChevronRightIcon } from 'lucide-solid' +import { createMemo } from 'solid-js' +import CollapsibleStyles from 'styles/Collapsible.module.css' +import styles from 'styles/toc.module.css' + +const p = loremIpsum({ count: 7, units: 'paragraphs' }) + +const items = [ + { value: 'introduction', depth: 2, label: 'Introduction' }, + { value: 'installation', depth: 2, label: 'Installation' }, + { value: 'usage', depth: 2, label: 'Usage' }, + { value: 'api-reference', depth: 2, label: 'API Reference' }, + { value: 'examples', depth: 2, label: 'Examples' }, +] + +const RADIUS = 14 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +export const WithCollapsible = () => ( + + + {items.map((item) => ( +
+

{item.label}

+

{p}

+
+ ))} +
+ + + + + {(toc) => { + const api = toc() + const activeIndex = createMemo(() => { + const items_ = api.activeItems + return items_[0] ? items.findIndex((i) => i.value === items_[0].value) : -1 + }) + const activeLabel = createMemo(() => { + const idx = activeIndex() + return idx >= 0 ? items[idx].label : undefined + }) + const progress = createMemo(() => { + const idx = activeIndex() + return idx >= 0 ? (idx + 1) / items.length : 0 + }) + const dashArray = createMemo(() => `${progress() * CIRCUMFERENCE} ${CIRCUMFERENCE}`) + return ( + + + + + + {activeLabel() ?? 'On this page'} + + + + + + ) + }} + + + + {items.map((item, index) => ( + + + {String(index + 1).padStart(2, '0')} + {item.label} + + + ))} + + + + +
+) diff --git a/packages/solid/src/components/toc/examples/with-hover.tsx b/packages/solid/src/components/toc/examples/with-hover.tsx new file mode 100644 index 0000000000..9931a2d4d0 --- /dev/null +++ b/packages/solid/src/components/toc/examples/with-hover.tsx @@ -0,0 +1,83 @@ +import { Toc } from '@ark-ui/solid/toc' +import { Swap } from '@ark-ui/solid/swap' +import { Pin, PinOff } from 'lucide-solid' +import { createSignal } from 'solid-js' +import { loremIpsum } from 'lorem-ipsum' +import styles from 'styles/toc.module.css' + +const items = [ + { value: 'introduction', depth: 2, label: 'Introduction' }, + { value: 'getting-started', depth: 2, label: 'Getting Started' }, + { value: 'installation', depth: 2, label: 'Installation' }, + { value: 'usage', depth: 2, label: 'Usage' }, + { value: 'api', depth: 2, label: 'API' }, + { value: 'examples', depth: 2, label: 'Examples' }, +] + +const paragraphs = loremIpsum({ count: 7, units: 'paragraphs' }) + +export const WithHover = () => { + const [pinned, setPinned] = createSignal(false) + const [hovered, setHovered] = createSignal(false) + + return ( + +
+ {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + + +
+ {items.map((item) => ( + + ))} +
+
+ +
+ + {items.map((item) => ( + + + {item.label} + + + ))} + +
+
+
+
+
+ ) +} diff --git a/packages/solid/src/components/toc/examples/with-indicator.tsx b/packages/solid/src/components/toc/examples/with-indicator.tsx new file mode 100644 index 0000000000..35e4a8dc3c --- /dev/null +++ b/packages/solid/src/components/toc/examples/with-indicator.tsx @@ -0,0 +1,42 @@ +import { Toc } from '@ark-ui/solid/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const items = [ + { value: 'overview', depth: 2, label: 'Overview' }, + { value: 'architecture', depth: 2, label: 'Architecture' }, + { value: 'state-machines', depth: 2, label: 'State Machines' }, + { value: 'components', depth: 2, label: 'Components' }, + { value: 'theming', depth: 2, label: 'Theming' }, + { value: 'accessibility', depth: 2, label: 'Accessibility' }, + { value: 'conclusion', depth: 2, label: 'Conclusion' }, +] + +const paragraphs = loremIpsum({ count: 6, units: 'paragraphs' }) + +export const WithIndicator = () => ( + + + {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + + On this page + + + {items.map((item) => ( + + + {item.label} + + + ))} + + +
+) diff --git a/packages/solid/src/components/toc/examples/with-numbers.tsx b/packages/solid/src/components/toc/examples/with-numbers.tsx new file mode 100644 index 0000000000..1f9d86a4c3 --- /dev/null +++ b/packages/solid/src/components/toc/examples/with-numbers.tsx @@ -0,0 +1,39 @@ +import { Toc } from '@ark-ui/solid/toc' +import styles from 'styles/toc.module.css' +import { loremIpsum } from 'lorem-ipsum' + +const items = [ + { value: 'executive-summary', depth: 2, label: 'Executive Summary' }, + { value: 'methodology', depth: 2, label: 'Methodology' }, + { value: 'findings', depth: 2, label: 'Findings' }, + { value: 'recommendations', depth: 2, label: 'Recommendations' }, + { value: 'conclusion', depth: 2, label: 'Conclusion' }, +] + +const paragraphs = loremIpsum({ count: 5, units: 'paragraphs' }) + +export const WithNumbers = () => ( + + + {items.map((item) => ( +
+

{item.label}

+

{paragraphs}

+
+ ))} +
+ + Contents + + {items.map((item, index) => ( + + + {String(index + 1).padStart(2, '0')} + {item.label} + + + ))} + + +
+) diff --git a/packages/solid/src/components/toc/examples/with-tree-view.tsx b/packages/solid/src/components/toc/examples/with-tree-view.tsx new file mode 100644 index 0000000000..52130f3d04 --- /dev/null +++ b/packages/solid/src/components/toc/examples/with-tree-view.tsx @@ -0,0 +1,146 @@ +import { Toc, useTocContext } from '@ark-ui/solid/toc' +import { TreeView, createTreeCollection } from '@ark-ui/solid/tree-view' +import { loremIpsum } from 'lorem-ipsum' +import { ChevronRightIcon } from 'lucide-solid' +import { createSignal } from 'solid-js' +import tocStyles from 'styles/toc.module.css' +import treeStyles from 'styles/tree-view.module.css' + +const p = loremIpsum({ count: 7, units: 'paragraphs' }) + +type TocNode = { + id: string + name: string + depth: number + children?: TocNode[] +} + +const sections: TocNode[] = [ + { + id: 'guides', + name: 'Guides', + depth: 2, + children: [ + { id: 'quick-start', name: 'Quick Start', depth: 3 }, + { id: 'manual-setup', name: 'Manual Setup', depth: 3 }, + ], + }, + { + id: 'core-concepts', + name: 'Core Concepts', + depth: 2, + children: [ + { id: 'props', name: 'Props', depth: 3 }, + { id: 'events', name: 'Events', depth: 3 }, + { id: 'context', name: 'Context', depth: 3 }, + ], + }, + { + id: 'advanced', + name: 'Advanced', + depth: 2, + children: [ + { id: 'root-provider', name: 'Root Provider', depth: 3 }, + { id: 'custom-rendering', name: 'Custom Rendering', depth: 3 }, + ], + }, +] + +const collection = createTreeCollection({ + nodeToValue: (node) => node.id, + nodeToString: (node) => node.name, + rootNode: { id: 'ROOT', name: '', depth: 0, children: sections }, +}) + +const allItems = sections.flatMap((section) => [ + { value: section.id, depth: section.depth }, + ...(section.children ?? []).map((child) => ({ value: child.id, depth: child.depth })), +]) + +const TocTreeNode = ({ node, indexPath }: TreeView.NodeProviderProps) => { + const toc = useTocContext() + return ( + + {node.children ? ( + + + + + + + + {node.name} + + + + + + {node.children.map((child, index) => ( + + ))} + + + ) : ( + + + + {node.name} + + + + )} + + ) +} + +export const WithTreeView = () => { + const [expandedValue, setExpandedValue] = createSignal([]) + + return ( + { + const activeIds = new Set(activeItems.map((i) => i.value)) + const next = sections + .filter( + (section) => activeIds.has(section.id) || (section.children ?? []).some((child) => activeIds.has(child.id)), + ) + .map((s) => s.id) + setExpandedValue(next) + }} + > + + {sections.map((section) => ( +
+

{section.name}

+

{p}

+ {section.children?.map((child) => ( +
+

{child.name}

+

{p}

+
+ ))} +
+ ))} +
+ + + On this page + setExpandedValue(next)} + > + + {sections.map((node, index) => ( + + ))} + + + +
+ ) +} diff --git a/packages/solid/src/components/toc/index.tsx b/packages/solid/src/components/toc/index.tsx new file mode 100644 index 0000000000..af93ee014c --- /dev/null +++ b/packages/solid/src/components/toc/index.tsx @@ -0,0 +1,16 @@ +export type { ActiveChangeDetails as TocActiveChangeDetails, TocItem as TocItemData } from '@zag-js/toc' +export { TocContent, type TocContentBaseProps, type TocContentProps } from './toc-content' +export { TocContext, type TocContextProps } from './toc-context' +export { TocIndicator, type TocIndicatorBaseProps, type TocIndicatorProps } from './toc-indicator' +export { TocItem, type TocItemBaseProps, type TocItemProps } from './toc-item' +export { TocLink, type TocLinkBaseProps, type TocLinkProps } from './toc-link' +export { TocList, type TocListBaseProps, type TocListProps } from './toc-list' +export { TocNav, type TocNavBaseProps, type TocNavProps } from './toc-nav' +export { TocRoot, type TocRootBaseProps, type TocRootProps } from './toc-root' +export { TocRootProvider, type TocRootProviderBaseProps, type TocRootProviderProps } from './toc-root-provider' +export { TocTitle, type TocTitleBaseProps, type TocTitleProps } from './toc-title' +export { tocAnatomy } from './toc.anatomy' +export { useToc, type UseTocProps, type UseTocReturn } from './use-toc' +export { useTocContext, type UseTocContext } from './use-toc-context' + +export * as Toc from './toc' diff --git a/packages/solid/src/components/toc/toc-content.tsx b/packages/solid/src/components/toc/toc-content.tsx new file mode 100644 index 0000000000..c5ea251590 --- /dev/null +++ b/packages/solid/src/components/toc/toc-content.tsx @@ -0,0 +1,8 @@ +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' + +export interface TocContentBaseProps extends PolymorphicProps<'article'> {} +export interface TocContentProps extends HTMLProps<'article'>, TocContentBaseProps {} + +export const TocContent = (props: TocContentProps) => { + return +} diff --git a/packages/solid/src/components/toc/toc-context.tsx b/packages/solid/src/components/toc/toc-context.tsx new file mode 100644 index 0000000000..3f99e01247 --- /dev/null +++ b/packages/solid/src/components/toc/toc-context.tsx @@ -0,0 +1,8 @@ +import type { JSX } from 'solid-js' +import { type UseTocContext, useTocContext } from './use-toc-context' + +export interface TocContextProps { + children: (context: UseTocContext) => JSX.Element +} + +export const TocContext = (props: TocContextProps) => props.children(useTocContext()) diff --git a/packages/solid/src/components/toc/toc-indicator.tsx b/packages/solid/src/components/toc/toc-indicator.tsx new file mode 100644 index 0000000000..d8f95812be --- /dev/null +++ b/packages/solid/src/components/toc/toc-indicator.tsx @@ -0,0 +1,12 @@ +import { mergeProps } from '@zag-js/solid' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' + +export interface TocIndicatorBaseProps extends PolymorphicProps<'div'> {} +export interface TocIndicatorProps extends HTMLProps<'div'>, TocIndicatorBaseProps {} + +export const TocIndicator = (props: TocIndicatorProps) => { + const toc = useTocContext() + const mergedProps = mergeProps(() => toc().getIndicatorProps(), props) + return +} diff --git a/packages/solid/src/components/toc/toc-item.tsx b/packages/solid/src/components/toc/toc-item.tsx new file mode 100644 index 0000000000..a393a23bc8 --- /dev/null +++ b/packages/solid/src/components/toc/toc-item.tsx @@ -0,0 +1,22 @@ +import { mergeProps } from '@zag-js/solid' +import type { ItemProps } from '@zag-js/toc' +import { createSplitProps } from '../../utils/create-split-props' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' +import { TocItemPropsProvider } from './use-toc-item-props-context' + +export interface TocItemBaseProps extends ItemProps, PolymorphicProps<'li'> {} +export interface TocItemProps extends HTMLProps<'li'>, TocItemBaseProps {} + +const splitItemProps = createSplitProps() + +export const TocItem = (props: TocItemProps) => { + const [itemProps, localProps] = splitItemProps(props, ['item']) + const toc = useTocContext() + const mergedProps = mergeProps(() => toc().getItemProps(itemProps), localProps) + return ( + + + + ) +} diff --git a/packages/solid/src/components/toc/toc-link.tsx b/packages/solid/src/components/toc/toc-link.tsx new file mode 100644 index 0000000000..4c9d8e2c49 --- /dev/null +++ b/packages/solid/src/components/toc/toc-link.tsx @@ -0,0 +1,15 @@ +import { mergeProps } from '@zag-js/solid' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' +import { useTocItemPropsContext } from './use-toc-item-props-context' +import type { Assign } from 'src/types' + +export interface TocLinkBaseProps extends PolymorphicProps<'a'> {} +export interface TocLinkProps extends Assign, TocLinkBaseProps> {} + +export const TocLink = (props: TocLinkProps) => { + const toc = useTocContext() + const itemProps = useTocItemPropsContext() + const mergedProps = mergeProps(() => toc().getLinkProps(itemProps), props) + return +} diff --git a/packages/solid/src/components/toc/toc-list.tsx b/packages/solid/src/components/toc/toc-list.tsx new file mode 100644 index 0000000000..926f020a3b --- /dev/null +++ b/packages/solid/src/components/toc/toc-list.tsx @@ -0,0 +1,13 @@ +import { mergeProps } from '@zag-js/solid' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' + +export interface TocListBaseProps extends PolymorphicProps<'ul'> {} +export interface TocListProps extends HTMLProps<'ul'>, TocListBaseProps {} + +export const TocList = (props: TocListProps) => { + const api = useTocContext() + const mergedProps = mergeProps(() => api().getListProps(), props) + + return +} diff --git a/packages/solid/src/components/toc/toc-nav.tsx b/packages/solid/src/components/toc/toc-nav.tsx new file mode 100644 index 0000000000..98c56a166f --- /dev/null +++ b/packages/solid/src/components/toc/toc-nav.tsx @@ -0,0 +1,15 @@ +import { mergeProps } from '@zag-js/solid' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' + +export interface TocNavBaseProps extends PolymorphicProps<'nav'> { + placement?: 'left' | 'right' +} +export interface TocNavProps extends HTMLProps<'nav'>, TocNavBaseProps {} + +export const TocNav = (props: TocNavProps) => { + const { placement, ...rest } = props + const toc = useTocContext() + const mergedProps = mergeProps(() => toc().getRootProps(), rest) + return +} diff --git a/packages/solid/src/components/toc/toc-root-provider.tsx b/packages/solid/src/components/toc/toc-root-provider.tsx new file mode 100644 index 0000000000..2d8917838f --- /dev/null +++ b/packages/solid/src/components/toc/toc-root-provider.tsx @@ -0,0 +1,23 @@ +import { mergeProps } from '@zag-js/solid' +import { createSplitProps } from '../../utils/create-split-props' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import type { UseTocReturn } from './use-toc' +import { TocProvider } from './use-toc-context' + +interface RootProviderProps { + value: UseTocReturn +} + +export interface TocRootProviderBaseProps extends RootProviderProps, PolymorphicProps<'div'> {} +export interface TocRootProviderProps extends HTMLProps<'div'>, TocRootProviderBaseProps {} + +export const TocRootProvider = (props: TocRootProviderProps) => { + const [{ value: toc }, localProps] = createSplitProps()(props, ['value']) + const mergedProps = mergeProps(() => toc().getRootProps(), localProps) + + return ( + + + + ) +} diff --git a/packages/solid/src/components/toc/toc-root.tsx b/packages/solid/src/components/toc/toc-root.tsx new file mode 100644 index 0000000000..88d227dd02 --- /dev/null +++ b/packages/solid/src/components/toc/toc-root.tsx @@ -0,0 +1,35 @@ +import { mergeProps } from '@zag-js/solid' +import type { Assign } from '../../types' +import { createSplitProps } from '../../utils/create-split-props' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { type UseTocProps, useToc } from './use-toc' +import { TocProvider } from './use-toc-context' + +export interface TocRootBaseProps extends UseTocProps, PolymorphicProps<'div'> {} +export interface TocRootProps extends Assign, TocRootBaseProps> {} + +const splitRootProps = createSplitProps() + +export const TocRoot = (props: TocRootProps) => { + const [useTocProps, localProps] = splitRootProps(props, [ + 'activeIds', + 'autoScroll', + 'defaultActiveIds', + 'getScrollEl', + 'id', + 'ids', + 'items', + 'onActiveChange', + 'rootMargin', + 'scrollBehavior', + 'threshold', + ]) + const toc = useToc(useTocProps) + const mergedProps = mergeProps(() => toc().getRootProps(), localProps) + + return ( + + + + ) +} diff --git a/packages/solid/src/components/toc/toc-title.tsx b/packages/solid/src/components/toc/toc-title.tsx new file mode 100644 index 0000000000..958f8e032c --- /dev/null +++ b/packages/solid/src/components/toc/toc-title.tsx @@ -0,0 +1,12 @@ +import { mergeProps } from '@zag-js/solid' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useTocContext } from './use-toc-context' + +export interface TocTitleBaseProps extends PolymorphicProps<'h2'> {} +export interface TocTitleProps extends HTMLProps<'h2'>, TocTitleBaseProps {} + +export const TocTitle = (props: TocTitleProps) => { + const toc = useTocContext() + const mergedProps = mergeProps(() => toc().getTitleProps(), props) + return +} diff --git a/packages/solid/src/components/toc/toc.anatomy.ts b/packages/solid/src/components/toc/toc.anatomy.ts new file mode 100644 index 0000000000..560bfdc21b --- /dev/null +++ b/packages/solid/src/components/toc/toc.anatomy.ts @@ -0,0 +1 @@ +export { anatomy as tocAnatomy } from '@zag-js/toc' diff --git a/packages/solid/src/components/toc/toc.stories.tsx b/packages/solid/src/components/toc/toc.stories.tsx new file mode 100644 index 0000000000..6109b4cae2 --- /dev/null +++ b/packages/solid/src/components/toc/toc.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta } from 'storybook-solidjs-vite' + +const meta: Meta = { + title: 'Components / Toc', +} + +export default meta + +export { Basic } from './examples/basic' +export { Grouped } from './examples/grouped' +export { Nested } from './examples/nested' +export { RootProvider } from './examples/root-provider' +export { WithCollapsible } from './examples/with-collapsible' +export { WithHover } from './examples/with-hover' +export { WithIndicator } from './examples/with-indicator' +export { WithNumbers } from './examples/with-numbers' +export { WithTreeView } from './examples/with-tree-view' diff --git a/packages/solid/src/components/toc/toc.ts b/packages/solid/src/components/toc/toc.ts new file mode 100644 index 0000000000..c5813b27fc --- /dev/null +++ b/packages/solid/src/components/toc/toc.ts @@ -0,0 +1,27 @@ +export type { ActiveChangeDetails as TocActiveChangeDetails, TocItem as TocItemData } from '@zag-js/toc' +export { + TocContent as Content, + type TocContentBaseProps as ContentBaseProps, + type TocContentProps as ContentProps, +} from './toc-content' +export { TocContext as Context, type TocContextProps as ContextProps } from './toc-context' +export { + TocIndicator as Indicator, + type TocIndicatorBaseProps as IndicatorBaseProps, + type TocIndicatorProps as IndicatorProps, +} from './toc-indicator' +export { TocItem as Item, type TocItemBaseProps as ItemBaseProps, type TocItemProps as ItemProps } from './toc-item' +export { TocLink as Link, type TocLinkBaseProps as LinkBaseProps, type TocLinkProps as LinkProps } from './toc-link' +export { TocList as List, type TocListBaseProps as ListBaseProps, type TocListProps as ListProps } from './toc-list' +export { TocNav as Nav, type TocNavBaseProps as NavBaseProps, type TocNavProps as NavProps } from './toc-nav' +export { TocRoot as Root, type TocRootBaseProps as RootBaseProps, type TocRootProps as RootProps } from './toc-root' +export { + TocRootProvider as RootProvider, + type TocRootProviderBaseProps as RootProviderBaseProps, + type TocRootProviderProps as RootProviderProps, +} from './toc-root-provider' +export { + TocTitle as Title, + type TocTitleBaseProps as TitleBaseProps, + type TocTitleProps as TitleProps, +} from './toc-title' diff --git a/packages/solid/src/components/toc/use-toc-context.ts b/packages/solid/src/components/toc/use-toc-context.ts new file mode 100644 index 0000000000..e8228bea43 --- /dev/null +++ b/packages/solid/src/components/toc/use-toc-context.ts @@ -0,0 +1,9 @@ +import { createContext } from '../../utils/create-context' +import type { UseTocReturn } from './use-toc' + +export interface UseTocContext extends UseTocReturn {} + +export const [TocProvider, useTocContext] = createContext({ + hookName: 'useTocContext', + providerName: '', +}) diff --git a/packages/solid/src/components/toc/use-toc-item-props-context.ts b/packages/solid/src/components/toc/use-toc-item-props-context.ts new file mode 100644 index 0000000000..fdca2143e6 --- /dev/null +++ b/packages/solid/src/components/toc/use-toc-item-props-context.ts @@ -0,0 +1,9 @@ +import type { ItemProps } from '@zag-js/toc' +import { createContext } from '../../utils/create-context' + +export interface UseTocItemPropsContext extends ItemProps {} + +export const [TocItemPropsProvider, useTocItemPropsContext] = createContext({ + hookName: 'useTocItemPropsContext', + providerName: '', +}) diff --git a/packages/solid/src/components/toc/use-toc.ts b/packages/solid/src/components/toc/use-toc.ts new file mode 100644 index 0000000000..5e0b39c9a4 --- /dev/null +++ b/packages/solid/src/components/toc/use-toc.ts @@ -0,0 +1,28 @@ +import { type PropTypes, normalizeProps, useMachine } from '@zag-js/solid' +import * as toc from '@zag-js/toc' +import { type Accessor, createMemo, createUniqueId } from 'solid-js' +import { useEnvironmentContext, useLocaleContext } from '../../providers' +import type { Optional } from '../../types' + +export interface UseTocProps extends Optional, 'id'> {} +export interface UseTocReturn extends Accessor> {} + +export const useToc = (props?: UseTocProps): UseTocReturn => { + const id = createUniqueId() + const locale = useLocaleContext() + const environment = useEnvironmentContext() + + const machineProps = createMemo( + () => + ({ + id: props?.id ?? id, + dir: locale().dir, + getRootNode: environment().getRootNode, + items: props?.items ?? [], + ...props, + }) as toc.Props, + ) + + const service = useMachine(toc.machine as any, machineProps) + return createMemo(() => toc.connect(service as any, normalizeProps)) +} diff --git a/packages/svelte/.storybook/main.ts b/packages/svelte/.storybook/main.ts index 5511b75842..b6fcdd3736 100644 --- a/packages/svelte/.storybook/main.ts +++ b/packages/svelte/.storybook/main.ts @@ -16,6 +16,12 @@ const config: StorybookConfig = { config.resolve.alias ??= {} // @ts-expect-error - alias type mismatch config.resolve.alias['styles'] = resolve(__dirname, '../../../.storybook/modules') + + // Prevent Vite from pre-bundling .svelte files through esbuild + config.optimizeDeps ??= {} + config.optimizeDeps.noDiscovery = true + config.optimizeDeps.exclude = ['svelte', '@sveltejs/kit', '@ark-ui/svelte'] + return config }, } diff --git a/packages/svelte/.storybook/preview.ts b/packages/svelte/.storybook/preview.ts index 85ac1f120a..76c4009dc6 100644 --- a/packages/svelte/.storybook/preview.ts +++ b/packages/svelte/.storybook/preview.ts @@ -15,6 +15,10 @@ const preview: Preview = { controls: { disable: true }, backgrounds: { disable: true }, viewport: { disable: true }, + sveltekit_experimental: { + // Disable the experimental SvelteKit mocks that use Svelte 5 runes + skip: true, + }, }, } diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 53f748b793..3541bb7cc6 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -41,6 +41,7 @@ "tags input", "timer", "toast", + "toc", "toggle group", "tooltip", "tree view", diff --git a/packages/svelte/src/lib/components/anatomy.ts b/packages/svelte/src/lib/components/anatomy.ts index 70a0da37c5..e24f5109d0 100644 --- a/packages/svelte/src/lib/components/anatomy.ts +++ b/packages/svelte/src/lib/components/anatomy.ts @@ -45,6 +45,7 @@ export { tabsAnatomy } from './tabs/tabs.anatomy' export { tagsInputAnatomy } from './tags-input/tags-input.anatomy' export { timerAnatomy } from './timer/timer.anatomy' export { toastAnatomy } from './toast/toast.anatomy' +export { tocAnatomy } from './toc/toc.anatomy' export { toggleAnatomy } from './toggle/toggle.anatomy' export { toggleGroupAnatomy } from './toggle-group/toggle-group.anatomy' export { tooltipAnatomy } from './tooltip/tooltip.anatomy' diff --git a/packages/svelte/src/lib/components/index.ts b/packages/svelte/src/lib/components/index.ts index 59ce9945a1..6675fee575 100644 --- a/packages/svelte/src/lib/components/index.ts +++ b/packages/svelte/src/lib/components/index.ts @@ -54,6 +54,7 @@ export * from './tabs' export * from './tags-input' export * from './timer' export * from './toast' +export * from './toc' export * from './toggle' export * from './toggle-group' export * from './tooltip' diff --git a/packages/svelte/src/lib/components/toc/examples/basic.svelte b/packages/svelte/src/lib/components/toc/examples/basic.svelte new file mode 100644 index 0000000000..9a2c6eb8f9 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/basic.svelte @@ -0,0 +1,44 @@ + + + + + {#each items as item (item.value)} +
+

{item.label}

+

{paragraphs}

+
+ {/each} +
+ + On this page + + {#each items as item (item.value)} + + + {item.label} + + + {/each} + + +
+ diff --git a/packages/svelte/src/lib/components/toc/examples/grouped.svelte b/packages/svelte/src/lib/components/toc/examples/grouped.svelte new file mode 100644 index 0000000000..2aeddc28b2 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/grouped.svelte @@ -0,0 +1,63 @@ + + +
+
+ {#each groups as group (group.label)} +
+

{group.label}

+ {#each group.items as item (item.value)} +
+

{item.label}

+

{p}

+
+ {/each} +
+ {/each} +
+ + {#each groups as group (group.label)} +
+ {group.label} + + {#each group.items as item (item.value)} + + {item.label} + + {/each} + +
+ {/each} +
+
diff --git a/packages/svelte/src/lib/components/toc/examples/nested.svelte b/packages/svelte/src/lib/components/toc/examples/nested.svelte new file mode 100644 index 0000000000..78af00d936 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/nested.svelte @@ -0,0 +1,41 @@ + + +
+
+ {#each items as item (item.value)} +
+ + {item.label} + +

{p}

+
+ {/each} +
+ + On this page + + {#each items as item (item.value)} + + {item.label} + + {/each} + + +
diff --git a/packages/svelte/src/lib/components/toc/examples/root-provider.svelte b/packages/svelte/src/lib/components/toc/examples/root-provider.svelte new file mode 100644 index 0000000000..c6ae8c820e --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/root-provider.svelte @@ -0,0 +1,37 @@ + + +
+
+ {#each items as item (item.value)} +
+

{item.label}

+

{p}

+
+ {/each} +
+ + On this page + + {#each items as item (item.value)} + + {item.label} + + {/each} + + +
diff --git a/packages/svelte/src/lib/components/toc/examples/with-collapsible.svelte b/packages/svelte/src/lib/components/toc/examples/with-collapsible.svelte new file mode 100644 index 0000000000..145562ca09 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/with-collapsible.svelte @@ -0,0 +1,96 @@ + + +
+
+ {#each items as item (item.value)} +
+

{item.label}

+

{p}

+
+ {/each} +
+ + + + {#snippet render(toc)} + {@const activeIndex = toc().activeItems[0] + ? items.findIndex((i) => i.value === toc().activeItems[0].value) + : -1} + {@const progress = activeIndex >= 0 ? (activeIndex + 1) / items.length : 0} + {@const dashArray = `${progress * CIRCUMFERENCE} ${CIRCUMFERENCE}`} + + + + + + + {activeIndex >= 0 ? items[activeIndex].label : 'On this page'} + + + + + + + {/snippet} + + + + {#each items as item, index (item.value)} + + + {String(index + 1).padStart(2, '0')} + {item.label} + + + {/each} + + + + +
diff --git a/packages/svelte/src/lib/components/toc/examples/with-hover.svelte b/packages/svelte/src/lib/components/toc/examples/with-hover.svelte new file mode 100644 index 0000000000..a8fb3144f8 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/with-hover.svelte @@ -0,0 +1,79 @@ + + +
+
+ {#each items as item (item.value)} +
+

{item.label}

+

{p}

+
+ {/each} +
+ (hovered = true)} + onmouseleave={() => (hovered = false)} + > + + + + +
+ {#each items as item (item.value)} + + {/each} +
+
+ +
+ + {#each items as item (item.value)} + + {item.label} + + {/each} + +
+
+
+
+
diff --git a/packages/svelte/src/lib/components/toc/examples/with-indicator.svelte b/packages/svelte/src/lib/components/toc/examples/with-indicator.svelte new file mode 100644 index 0000000000..d1991b3cf0 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/with-indicator.svelte @@ -0,0 +1,38 @@ + + +
+
+ {#each items as item (item.value)} +
+

{item.label}

+

{p}

+
+ {/each} +
+ + On this page + + + {#each items as item (item.value)} + + {item.label} + + {/each} + + +
diff --git a/packages/svelte/src/lib/components/toc/examples/with-numbers.svelte b/packages/svelte/src/lib/components/toc/examples/with-numbers.svelte new file mode 100644 index 0000000000..4789158c8e --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/with-numbers.svelte @@ -0,0 +1,39 @@ + + +
+
+ {#each items as item (item.value)} +
+

{item.label}

+

{p}

+
+ {/each} +
+ + Contents + + {#each items as item, index (item.value)} + + + {String(index + 1).padStart(2, '0')} + {item.label} + + + {/each} + + +
diff --git a/packages/svelte/src/lib/components/toc/examples/with-tree-view.svelte b/packages/svelte/src/lib/components/toc/examples/with-tree-view.svelte new file mode 100644 index 0000000000..b61d852db9 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/examples/with-tree-view.svelte @@ -0,0 +1,149 @@ + + +
+
+ {#each sections as section (section.id)} +
+

{section.name}

+

{p}

+ {#each section.children ?? [] as child (child.id)} +
+

{child.name}

+

{p}

+
+ {/each} +
+ {/each} +
+ { + const activeIds = new Set(activeItems.map((i) => i.value)) + expandedValue = sections + .filter( + (section) => + activeIds.has(section.id) || + (section.children ?? []).some((child) => activeIds.has(child.id)), + ) + .map((s) => s.id) + }} + > + On this page + (expandedValue = next)} + > + + {#each sections as node, index (node.id)} + {@render renderNode(node, [index])} + {/each} + + + +
+ +{#snippet renderNode(node: TocNode, indexPath: number[])} + + {#if node.children} + + + + + + + + {#snippet render(toc)} + {node.name} + {/snippet} + + + + + + {#each node.children as child, i (child.id)} + {@render renderNode(child, [...indexPath, i])} + {/each} + + + {:else} + + + + {#snippet render(toc)} + {node.name} + {/snippet} + + + + {/if} + +{/snippet} diff --git a/packages/svelte/src/lib/components/toc/index.ts b/packages/svelte/src/lib/components/toc/index.ts new file mode 100644 index 0000000000..fc11164268 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/index.ts @@ -0,0 +1,19 @@ +export type { ActiveChangeDetails as TocActiveChangeDetails, TocItem as TocItemData } from '@zag-js/toc' +export { default as TocContent, type TocContentBaseProps, type TocContentProps } from './toc-content.svelte' +export { default as TocContext, type TocContextProps } from './toc-context.svelte' +export { default as TocIndicator, type TocIndicatorBaseProps, type TocIndicatorProps } from './toc-indicator.svelte' +export { default as TocItem, type TocItemBaseProps, type TocItemProps } from './toc-item.svelte' +export { default as TocLink, type TocLinkBaseProps, type TocLinkProps } from './toc-link.svelte' +export { default as TocList, type TocListBaseProps, type TocListProps } from './toc-list.svelte' +export { default as TocNav, type TocNavBaseProps, type TocNavProps } from './toc-nav.svelte' +export { default as TocRoot, type TocRootBaseProps, type TocRootProps } from './toc-root.svelte' +export { + default as TocRootProvider, + type TocRootProviderBaseProps, + type TocRootProviderProps, +} from './toc-root-provider.svelte' +export { default as TocTitle, type TocTitleBaseProps, type TocTitleProps } from './toc-title.svelte' +export { tocAnatomy } from './toc.anatomy' +export { useToc, type UseTocProps, type UseTocReturn } from './use-toc.svelte' +export { useTocContext, type UseTocContext } from './use-toc-context' +export * as Toc from './toc' diff --git a/packages/svelte/src/lib/components/toc/toc-content.svelte b/packages/svelte/src/lib/components/toc/toc-content.svelte new file mode 100644 index 0000000000..9bcc6d9623 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-content.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc-context.svelte b/packages/svelte/src/lib/components/toc/toc-context.svelte new file mode 100644 index 0000000000..7a5e676692 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-context.svelte @@ -0,0 +1,18 @@ + + + + +{@render render(toc)} diff --git a/packages/svelte/src/lib/components/toc/toc-indicator.svelte b/packages/svelte/src/lib/components/toc/toc-indicator.svelte new file mode 100644 index 0000000000..67b8636190 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-indicator.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc-item.svelte b/packages/svelte/src/lib/components/toc/toc-item.svelte new file mode 100644 index 0000000000..992ea90b7c --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-item.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc-link.svelte b/packages/svelte/src/lib/components/toc/toc-link.svelte new file mode 100644 index 0000000000..6dc3cc789d --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-link.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc-list.svelte b/packages/svelte/src/lib/components/toc/toc-list.svelte new file mode 100644 index 0000000000..2f27fb303b --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-list.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc-nav.svelte b/packages/svelte/src/lib/components/toc/toc-nav.svelte new file mode 100644 index 0000000000..fe1fdb4d00 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-nav.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc-root-provider.svelte b/packages/svelte/src/lib/components/toc/toc-root-provider.svelte new file mode 100644 index 0000000000..86c24cf87d --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-root-provider.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc-root.svelte b/packages/svelte/src/lib/components/toc/toc-root.svelte new file mode 100644 index 0000000000..687a94ba04 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-root.svelte @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc-title.svelte b/packages/svelte/src/lib/components/toc/toc-title.svelte new file mode 100644 index 0000000000..c3fac0a325 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc-title.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/svelte/src/lib/components/toc/toc.anatomy.ts b/packages/svelte/src/lib/components/toc/toc.anatomy.ts new file mode 100644 index 0000000000..560bfdc21b --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc.anatomy.ts @@ -0,0 +1 @@ +export { anatomy as tocAnatomy } from '@zag-js/toc' diff --git a/packages/svelte/src/lib/components/toc/toc.stories.ts b/packages/svelte/src/lib/components/toc/toc.stories.ts new file mode 100644 index 0000000000..281cd70436 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc.stories.ts @@ -0,0 +1,70 @@ +import type { Meta } from '@storybook/svelte' +import BasicExample from './examples/basic.svelte' +// import WithIndicatorExample from './examples/with-indicator.svelte' +// import GroupedExample from './examples/grouped.svelte' +// import RootProviderExample from './examples/root-provider.svelte' +// import WithNumbersExample from './examples/with-numbers.svelte' +// import NestedExample from './examples/nested.svelte' +// import WithHoverExample from './examples/with-hover.svelte' +// import WithTreeViewExample from './examples/with-tree-view.svelte' +// import WithCollapsibleExample from './examples/with-collapsible.svelte' + +const meta: Meta = { + title: 'Components/Toc', +} + +export default meta + +export const Basic = { + render: () => ({ + Component: BasicExample, + }), +} + +// export const Nested = { +// render: () => ({ +// Component: NestedExample, +// }), +// } + +// export const Grouped = { +// render: () => ({ +// Component: GroupedExample, +// }), +// } + +// export const WithHover = { +// render: () => ({ +// Component: WithHoverExample, +// }), +// } + +// export const WithNumbers = { +// render: () => ({ +// Component: WithNumbersExample, +// }), +// } + +// export const RootProvider = { +// render: () => ({ +// Component: RootProviderExample, +// }), +// } + +// export const WithTreeView = { +// render: () => ({ +// Component: WithTreeViewExample, +// }), +// } + +// export const WithIndicator = { +// render: () => ({ +// Component: WithIndicatorExample, +// }), +// } + +// export const WithCollapsible = { +// render: () => ({ +// Component: WithCollapsibleExample, +// }), +// } diff --git a/packages/svelte/src/lib/components/toc/toc.ts b/packages/svelte/src/lib/components/toc/toc.ts new file mode 100644 index 0000000000..f12300212b --- /dev/null +++ b/packages/svelte/src/lib/components/toc/toc.ts @@ -0,0 +1,43 @@ +export type { ActiveChangeDetails as TocActiveChangeDetails, TocItem as TocItemData } from '@zag-js/toc' +export { + default as Content, + type TocContentBaseProps as ContentBaseProps, + type TocContentProps as ContentProps, +} from './toc-content.svelte' +export { default as Context, type TocContextProps as ContextProps } from './toc-context.svelte' +export { + default as Indicator, + type TocIndicatorBaseProps as IndicatorBaseProps, + type TocIndicatorProps as IndicatorProps, +} from './toc-indicator.svelte' +export { + default as Item, + type TocItemBaseProps as ItemBaseProps, + type TocItemProps as ItemProps, +} from './toc-item.svelte' +export { + default as Link, + type TocLinkBaseProps as LinkBaseProps, + type TocLinkProps as LinkProps, +} from './toc-link.svelte' +export { + default as List, + type TocListBaseProps as ListBaseProps, + type TocListProps as ListProps, +} from './toc-list.svelte' +export { default as Nav, type TocNavBaseProps as NavBaseProps, type TocNavProps as NavProps } from './toc-nav.svelte' +export { + default as Root, + type TocRootBaseProps as RootBaseProps, + type TocRootProps as RootProps, +} from './toc-root.svelte' +export { + default as RootProvider, + type TocRootProviderBaseProps as RootProviderBaseProps, + type TocRootProviderProps as RootProviderProps, +} from './toc-root-provider.svelte' +export { + default as Title, + type TocTitleBaseProps as TitleBaseProps, + type TocTitleProps as TitleProps, +} from './toc-title.svelte' diff --git a/packages/svelte/src/lib/components/toc/use-toc-context.ts b/packages/svelte/src/lib/components/toc/use-toc-context.ts new file mode 100644 index 0000000000..4b210911af --- /dev/null +++ b/packages/svelte/src/lib/components/toc/use-toc-context.ts @@ -0,0 +1,9 @@ +import { createContext } from '../../utils/create-context' +import type { UseTocReturn } from './use-toc.svelte' + +export interface UseTocContext extends UseTocReturn {} +export const [TocProvider, useTocContext, TocContextId] = createContext({ + name: 'TocContext', + hookName: 'useTocContext', + providerName: '', +}) diff --git a/packages/svelte/src/lib/components/toc/use-toc-item-props-context.ts b/packages/svelte/src/lib/components/toc/use-toc-item-props-context.ts new file mode 100644 index 0000000000..d01bbcdd33 --- /dev/null +++ b/packages/svelte/src/lib/components/toc/use-toc-item-props-context.ts @@ -0,0 +1,8 @@ +import { createContext } from '../../utils/create-context' +import type { ItemProps } from '@zag-js/toc' + +export const [TocItemPropsProvider, useTocItemPropsContext] = createContext<() => ItemProps>({ + name: 'TocItemPropsContext', + hookName: 'useTocItemPropsContext', + providerName: '', +}) diff --git a/packages/svelte/src/lib/components/toc/use-toc.svelte.ts b/packages/svelte/src/lib/components/toc/use-toc.svelte.ts new file mode 100644 index 0000000000..5eaec5808f --- /dev/null +++ b/packages/svelte/src/lib/components/toc/use-toc.svelte.ts @@ -0,0 +1,27 @@ +import { useEnvironmentContext } from '$lib/providers/environment' +import { useLocaleContext } from '$lib/providers/locale' +import type { Accessor, Optional } from '$lib/types' +import * as toc from '@zag-js/toc' +import { type PropTypes, normalizeProps, useMachine } from '@zag-js/svelte' +import { type MaybeFunction, runIfFn } from '@zag-js/utils' + +export interface UseTocProps extends Optional, 'id'> {} +export interface UseTocReturn extends Accessor> {} + +export const useToc = (props?: MaybeFunction): UseTocReturn => { + const env = useEnvironmentContext() + const locale = useLocaleContext() + + const machineProps = $derived.by(() => { + const resolvedProps = runIfFn(props) + return { + dir: locale().dir, + getRootNode: env().getRootNode, + ...resolvedProps, + } + }) + + const service = useMachine(toc.machine, () => machineProps) + const api = $derived(toc.connect(service, normalizeProps)) + return () => api +} diff --git a/packages/vue/package.json b/packages/vue/package.json index 2d67b78f86..4dd6cf0bd6 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -39,6 +39,7 @@ "tabs", "tags input", "toast", + "toc", "toggle group", "tooltip", "tour", diff --git a/packages/vue/src/components/anatomy.ts b/packages/vue/src/components/anatomy.ts index a54556895e..827134bf7d 100644 --- a/packages/vue/src/components/anatomy.ts +++ b/packages/vue/src/components/anatomy.ts @@ -45,6 +45,7 @@ export { tabsAnatomy } from './tabs/tabs.anatomy' export { tagsInputAnatomy } from './tags-input/tags-input.anatomy' export { timerAnatomy } from './timer/timer.anatomy' export { toastAnatomy } from './toast/toast.anatomy' +export { tocAnatomy } from './toc/toc.anatomy' export { toggleGroupAnatomy } from './toggle-group/toggle-group.anatomy' export { toggleAnatomy } from './toggle/toggle.anatomy' export { tooltipAnatomy } from './tooltip/tooltip.anatomy' diff --git a/packages/vue/src/components/index.ts b/packages/vue/src/components/index.ts index 196d48a08b..fd584e61e3 100644 --- a/packages/vue/src/components/index.ts +++ b/packages/vue/src/components/index.ts @@ -53,6 +53,7 @@ export * from './tabs' export * from './tags-input' export * from './timer' export * from './toast' +export * from './toc' export * from './toggle' export * from './toggle-group' export * from './tooltip' diff --git a/packages/vue/src/components/toc/examples/basic.vue b/packages/vue/src/components/toc/examples/basic.vue new file mode 100644 index 0000000000..0ae4fb9a43 --- /dev/null +++ b/packages/vue/src/components/toc/examples/basic.vue @@ -0,0 +1,38 @@ + + diff --git a/packages/vue/src/components/toc/examples/grouped.vue b/packages/vue/src/components/toc/examples/grouped.vue new file mode 100644 index 0000000000..46bcee1a18 --- /dev/null +++ b/packages/vue/src/components/toc/examples/grouped.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/vue/src/components/toc/examples/nested.vue b/packages/vue/src/components/toc/examples/nested.vue new file mode 100644 index 0000000000..eb8ee04138 --- /dev/null +++ b/packages/vue/src/components/toc/examples/nested.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/vue/src/components/toc/examples/root-provider.vue b/packages/vue/src/components/toc/examples/root-provider.vue new file mode 100644 index 0000000000..31276642b0 --- /dev/null +++ b/packages/vue/src/components/toc/examples/root-provider.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/vue/src/components/toc/examples/toc-tree-node.vue b/packages/vue/src/components/toc/examples/toc-tree-node.vue new file mode 100644 index 0000000000..d4f6e7f706 --- /dev/null +++ b/packages/vue/src/components/toc/examples/toc-tree-node.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/vue/src/components/toc/examples/with-collapsible.vue b/packages/vue/src/components/toc/examples/with-collapsible.vue new file mode 100644 index 0000000000..352305153f --- /dev/null +++ b/packages/vue/src/components/toc/examples/with-collapsible.vue @@ -0,0 +1,117 @@ + + + diff --git a/packages/vue/src/components/toc/examples/with-hover.vue b/packages/vue/src/components/toc/examples/with-hover.vue new file mode 100644 index 0000000000..5d427f4dc1 --- /dev/null +++ b/packages/vue/src/components/toc/examples/with-hover.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/vue/src/components/toc/examples/with-indicator.vue b/packages/vue/src/components/toc/examples/with-indicator.vue new file mode 100644 index 0000000000..e8d74165e8 --- /dev/null +++ b/packages/vue/src/components/toc/examples/with-indicator.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/vue/src/components/toc/examples/with-numbers.vue b/packages/vue/src/components/toc/examples/with-numbers.vue new file mode 100644 index 0000000000..46aaa64484 --- /dev/null +++ b/packages/vue/src/components/toc/examples/with-numbers.vue @@ -0,0 +1,35 @@ + + diff --git a/packages/vue/src/components/toc/examples/with-tree-view.vue b/packages/vue/src/components/toc/examples/with-tree-view.vue new file mode 100644 index 0000000000..dd864ba29c --- /dev/null +++ b/packages/vue/src/components/toc/examples/with-tree-view.vue @@ -0,0 +1,94 @@ + + + diff --git a/packages/vue/src/components/toc/index.ts b/packages/vue/src/components/toc/index.ts new file mode 100644 index 0000000000..56cc03e13d --- /dev/null +++ b/packages/vue/src/components/toc/index.ts @@ -0,0 +1,20 @@ +export type { ActiveChangeDetails as TocActiveChangeDetails, TocItem as TocItemData } from '@zag-js/toc' +export { default as TocContent, type TocContentBaseProps, type TocContentProps } from './toc-content.vue' +export { default as TocContext, type TocContextProps } from './toc-context.vue' +export { default as TocIndicator, type TocIndicatorBaseProps, type TocIndicatorProps } from './toc-indicator.vue' +export { default as TocItem, type TocItemBaseProps, type TocItemProps } from './toc-item.vue' +export { default as TocLink, type TocLinkBaseProps, type TocLinkProps } from './toc-link.vue' +export { default as TocList, type TocListBaseProps, type TocListProps } from './toc-list.vue' +export { default as TocNav, type TocNavBaseProps, type TocNavProps } from './toc-nav.vue' +export { default as TocRoot, type TocRootBaseProps, type TocRootEmits, type TocRootProps } from './toc-root.vue' +export { + default as TocRootProvider, + type TocRootProviderBaseProps, + type TocRootProviderProps, +} from './toc-root-provider.vue' +export { default as TocTitle, type TocTitleBaseProps, type TocTitleProps } from './toc-title.vue' +export { tocAnatomy } from './toc.anatomy' +export { useToc, type UseTocProps, type UseTocReturn } from './use-toc' +export { useTocContext, type UseTocContext } from './use-toc-context' + +export * as Toc from './toc' diff --git a/packages/vue/src/components/toc/toc-content.vue b/packages/vue/src/components/toc/toc-content.vue new file mode 100644 index 0000000000..d307cbcfd3 --- /dev/null +++ b/packages/vue/src/components/toc/toc-content.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-context.vue b/packages/vue/src/components/toc/toc-context.vue new file mode 100644 index 0000000000..994a8a9492 --- /dev/null +++ b/packages/vue/src/components/toc/toc-context.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-indicator.vue b/packages/vue/src/components/toc/toc-indicator.vue new file mode 100644 index 0000000000..c20b5f37e0 --- /dev/null +++ b/packages/vue/src/components/toc/toc-indicator.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-item.vue b/packages/vue/src/components/toc/toc-item.vue new file mode 100644 index 0000000000..cc8962af6f --- /dev/null +++ b/packages/vue/src/components/toc/toc-item.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-link.vue b/packages/vue/src/components/toc/toc-link.vue new file mode 100644 index 0000000000..cb66bda1ac --- /dev/null +++ b/packages/vue/src/components/toc/toc-link.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-list.vue b/packages/vue/src/components/toc/toc-list.vue new file mode 100644 index 0000000000..6f0cdeb899 --- /dev/null +++ b/packages/vue/src/components/toc/toc-list.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-nav.vue b/packages/vue/src/components/toc/toc-nav.vue new file mode 100644 index 0000000000..30e6aa92ef --- /dev/null +++ b/packages/vue/src/components/toc/toc-nav.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-root-provider.vue b/packages/vue/src/components/toc/toc-root-provider.vue new file mode 100644 index 0000000000..45e6a42555 --- /dev/null +++ b/packages/vue/src/components/toc/toc-root-provider.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-root.vue b/packages/vue/src/components/toc/toc-root.vue new file mode 100644 index 0000000000..4c292077fb --- /dev/null +++ b/packages/vue/src/components/toc/toc-root.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc-title.vue b/packages/vue/src/components/toc/toc-title.vue new file mode 100644 index 0000000000..33712a4231 --- /dev/null +++ b/packages/vue/src/components/toc/toc-title.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/packages/vue/src/components/toc/toc.anatomy.ts b/packages/vue/src/components/toc/toc.anatomy.ts new file mode 100644 index 0000000000..560bfdc21b --- /dev/null +++ b/packages/vue/src/components/toc/toc.anatomy.ts @@ -0,0 +1 @@ +export { anatomy as tocAnatomy } from '@zag-js/toc' diff --git a/packages/vue/src/components/toc/toc.stories.ts b/packages/vue/src/components/toc/toc.stories.ts new file mode 100644 index 0000000000..494a989dc1 --- /dev/null +++ b/packages/vue/src/components/toc/toc.stories.ts @@ -0,0 +1,80 @@ +import type { Meta } from '@storybook/vue3-vite' + +import BasicExample from './examples/basic.vue' +import GroupedExample from './examples/grouped.vue' +import NestedExample from './examples/nested.vue' +import RootProviderExample from './examples/root-provider.vue' +import WithCollapsibleExample from './examples/with-collapsible.vue' +import WithHoverExample from './examples/with-hover.vue' +import WithIndicatorExample from './examples/with-indicator.vue' +import WithNumbersExample from './examples/with-numbers.vue' +import WithTreeViewExample from './examples/with-tree-view.vue' + +const meta: Meta = { + title: 'Components / Toc', +} + +export default meta + +export const Basic = { + render: () => ({ + components: { Component: BasicExample }, + template: '', + }), +} + +export const Grouped = { + render: () => ({ + components: { Component: GroupedExample }, + template: '', + }), +} + +export const Nested = { + render: () => ({ + components: { Component: NestedExample }, + template: '', + }), +} + +export const RootProvider = { + render: () => ({ + components: { Component: RootProviderExample }, + template: '', + }), +} + +export const WithCollapsible = { + render: () => ({ + components: { Component: WithCollapsibleExample }, + template: '', + }), +} + +export const WithHover = { + render: () => ({ + components: { Component: WithHoverExample }, + template: '', + }), +} + +export const WithIndicator = { + render: () => ({ + components: { Component: WithIndicatorExample }, + template: '', + }), +} + +export const WithNumbers = { + render: () => ({ + components: { Component: WithNumbersExample }, + template: '', + }), +} + +export const WithTreeView = { + render: () => ({ + components: { Component: WithTreeViewExample }, + template: '', + }), +} diff --git a/packages/vue/src/components/toc/toc.ts b/packages/vue/src/components/toc/toc.ts new file mode 100644 index 0000000000..dd96d3f34f --- /dev/null +++ b/packages/vue/src/components/toc/toc.ts @@ -0,0 +1,32 @@ +export type { ActiveChangeDetails as TocActiveChangeDetails, TocItem as TocItemData } from '@zag-js/toc' +export { + default as Content, + type TocContentBaseProps as ContentBaseProps, + type TocContentProps as ContentProps, +} from './toc-content.vue' +export { default as Context, type TocContextProps as ContextProps } from './toc-context.vue' +export { + default as Indicator, + type TocIndicatorBaseProps as IndicatorBaseProps, + type TocIndicatorProps as IndicatorProps, +} from './toc-indicator.vue' +export { default as Item, type TocItemBaseProps as ItemBaseProps, type TocItemProps as ItemProps } from './toc-item.vue' +export { default as Link, type TocLinkBaseProps as LinkBaseProps, type TocLinkProps as LinkProps } from './toc-link.vue' +export { default as List, type TocListBaseProps as ListBaseProps, type TocListProps as ListProps } from './toc-list.vue' +export { default as Nav, type TocNavBaseProps as NavBaseProps, type TocNavProps as NavProps } from './toc-nav.vue' +export { + default as Root, + type TocRootBaseProps as RootBaseProps, + type TocRootEmits as RootEmits, + type TocRootProps as RootProps, +} from './toc-root.vue' +export { + default as RootProvider, + type TocRootProviderBaseProps as RootProviderBaseProps, + type TocRootProviderProps as RootProviderProps, +} from './toc-root-provider.vue' +export { + default as Title, + type TocTitleBaseProps as TitleBaseProps, + type TocTitleProps as TitleProps, +} from './toc-title.vue' diff --git a/packages/vue/src/components/toc/toc.types.ts b/packages/vue/src/components/toc/toc.types.ts new file mode 100644 index 0000000000..c5762cdf0b --- /dev/null +++ b/packages/vue/src/components/toc/toc.types.ts @@ -0,0 +1,65 @@ +import type * as toc from '@zag-js/toc' + +export interface RootProps { + /** + * Whether to auto-scroll the TOC container so the first active item + * is visible when active headings change. + * @default true + */ + autoScroll?: boolean + /** + * The controlled active heading ids. + */ + activeIds?: string[] + /** + * The default active heading ids when rendered. + * Use when you don't need to control the active headings. + */ + defaultActiveIds?: string[] + /** + * Function that returns the scroll container element to observe within. + * Defaults to the document/viewport. + */ + getScrollEl?: () => HTMLElement | null + /** + * The unique identifier of the machine. + */ + id?: string + /** + * The ids of the elements in the TOC. Useful for composition. + */ + ids?: Partial<{ + root: string + title: string + list: string + item: (value: string) => string + link: (value: string) => string + indicator: string + }> + /** + * The TOC items with `value` (slug/id) and `depth` (heading level). + */ + items: toc.TocItem[] + /** + * The root margin for the IntersectionObserver. + * @default "-20px 0px -40% 0px" + */ + rootMargin?: string + /** + * The scroll behavior for auto-scrolling the TOC container. + * @default "smooth" + */ + scrollBehavior?: ScrollBehavior + /** + * The IntersectionObserver threshold. + * @default 0 + */ + threshold?: number | number[] +} + +export type RootEmits = { + /** + * Callback when the active (visible) headings change. + */ + activeChange: [details: toc.ActiveChangeDetails] +} diff --git a/packages/vue/src/components/toc/use-toc-context.ts b/packages/vue/src/components/toc/use-toc-context.ts new file mode 100644 index 0000000000..92211cf6ce --- /dev/null +++ b/packages/vue/src/components/toc/use-toc-context.ts @@ -0,0 +1,6 @@ +import { createContext } from '../../utils/create-context' +import type { UseTocReturn } from './use-toc' + +export interface UseTocContext extends UseTocReturn {} + +export const [TocProvider, useTocContext] = createContext('TocContext') diff --git a/packages/vue/src/components/toc/use-toc-item-props-context.ts b/packages/vue/src/components/toc/use-toc-item-props-context.ts new file mode 100644 index 0000000000..e803b5a64a --- /dev/null +++ b/packages/vue/src/components/toc/use-toc-item-props-context.ts @@ -0,0 +1,6 @@ +import type { ItemProps } from '@zag-js/toc' +import type { ComputedRef } from 'vue' +import { createContext } from '../../utils/create-context' + +export const [TocItemPropsProvider, useTocItemPropsContext] = + createContext>('TocItemPropsContext') diff --git a/packages/vue/src/components/toc/use-toc.ts b/packages/vue/src/components/toc/use-toc.ts new file mode 100644 index 0000000000..88d3dcb97f --- /dev/null +++ b/packages/vue/src/components/toc/use-toc.ts @@ -0,0 +1,35 @@ +import * as toc from '@zag-js/toc' +import { type PropTypes, normalizeProps, useMachine } from '@zag-js/vue' +import { type ComputedRef, type MaybeRef, computed, toValue, useId } from 'vue' +import { DEFAULT_ENVIRONMENT, DEFAULT_LOCALE, useEnvironmentContext, useLocaleContext } from '../../providers' +import type { EmitFn, Optional } from '../../types' +import { cleanProps } from '../../utils/clean-props' +import type { RootEmits } from './toc.types' + +export interface UseTocProps extends Optional, 'id'> {} + +export interface UseTocReturn extends ComputedRef> {} + +export const useToc = (props: MaybeRef, emits?: EmitFn): UseTocReturn => { + const id = useId() + const env = useEnvironmentContext(DEFAULT_ENVIRONMENT) + const locale = useLocaleContext(DEFAULT_LOCALE) + + const context = computed(() => { + const localProps = toValue(props) + + return { + id, + dir: locale.value.dir, + getRootNode: env?.value.getRootNode, + ...cleanProps(localProps), + onActiveChange: (details) => { + emits?.('activeChange', details) + localProps.onActiveChange?.(details) + }, + } + }) + + const service = useMachine(toc.machine, context) + return computed(() => toc.connect(service, normalizeProps)) +}