diff options
author | Chris Swithinbank | 2023-05-12 23:07:42 +0200 |
---|---|---|
committer | GitHub | 2023-05-12 23:07:42 +0200 |
commit | 623b577319b1dea2d6c42f1b680139fb858d85d6 (patch) | |
tree | f9fff83ac9d32e48edd47663afb3d237b1fcb4bf | |
parent | 97418a5c69e1bdff2f75b931cff35aa6e643b6cf (diff) | |
download | IT.starlight-623b577319b1dea2d6c42f1b680139fb858d85d6.tar.gz IT.starlight-623b577319b1dea2d6c42f1b680139fb858d85d6.tar.bz2 IT.starlight-623b577319b1dea2d6c42f1b680139fb858d85d6.zip |
Add tab components (#38)
-rw-r--r-- | .changeset/slimy-icons-itch.md | 5 | ||||
-rw-r--r-- | docs/astro.config.mjs | 6 | ||||
-rw-r--r-- | docs/src/content/docs/getting-started.mdx (renamed from docs/src/content/docs/getting-started.md) | 24 | ||||
-rw-r--r-- | docs/src/content/docs/guides/components.mdx | 60 | ||||
-rw-r--r-- | packages/starlight/components.ts | 2 | ||||
-rw-r--r-- | packages/starlight/package.json | 5 | ||||
-rw-r--r-- | packages/starlight/user-components/TabItem.astro | 17 | ||||
-rw-r--r-- | packages/starlight/user-components/Tabs.astro | 148 | ||||
-rw-r--r-- | packages/starlight/user-components/rehype-tabs.ts | 82 | ||||
-rw-r--r-- | pnpm-lock.yaml | 6 |
10 files changed, 351 insertions, 4 deletions
diff --git a/.changeset/slimy-icons-itch.md b/.changeset/slimy-icons-itch.md new file mode 100644 index 00000000..a46bf26f --- /dev/null +++ b/.changeset/slimy-icons-itch.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Add tab components for use in MDX. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 41223fe0..721600ac 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -37,9 +37,9 @@ export default defineConfig({ }, { label: 'Guides', - items: [ - { label: 'Internationalization (i18n)', link: 'guides/i18n' }, - ], + autogenerate: { + directory: 'guides', + }, }, { label: 'Reference', diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.mdx index b157bc83..9a261ba1 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.mdx @@ -3,6 +3,8 @@ title: Getting Started description: Learn how to start building your next documentation site with Starlight by Astro. --- +import { Tabs, TabItem } from '@astrojs/starlight/components'; + :::caution[Work in progress] Starlight is in early development so expect bugs and changes as we develop it. If you find something that’s not working, [open an issue on GitHub](https://github.com/withastro/starlight/issues/new/choose) or let us know on [Discord](https://astro.build/chat). @@ -14,11 +16,33 @@ Welcome to Starlight, an intuitive and user-friendly framework ideal for documen Starlight is built on top of the [Astro](https://astro.build) all-in-one framework. You can create a new Astro + Starlight project using the following command: +<Tabs> +<TabItem label="npm"> + ```sh # create a new project with npm npm create astro --template starlight ``` +</TabItem> +<TabItem label="pnpm"> + +```sh +# create a new project with pnpm +pnpm create astro --template starlight +``` + +</TabItem> +<TabItem label="Yarn"> + +```sh +# create a new project with pnpm +yarn create astro --template starlight +``` + +</TabItem> +</Tabs> + This will create a new project directory with all the necessary files and configurations for your site. ## What’s in the box? diff --git a/docs/src/content/docs/guides/components.mdx b/docs/src/content/docs/guides/components.mdx new file mode 100644 index 00000000..fd13d091 --- /dev/null +++ b/docs/src/content/docs/guides/components.mdx @@ -0,0 +1,60 @@ +--- +title: Components +description: Using components in MDX with Starlight. +--- + +Components let you easily re-use a piece of UI or styling consistently. +Examples might include a link card or a YouTube embed. +Starlight supports the use of components in [MDX](https://mdxjs.com/) files and provides some common components for you to use. + +## Using a component + +You can use a component by importing it into your MDX file and then calling it as a JSX tag. +These look like HTML tags but start with an uppercase letter matching the name in your `import` statement: + +```mdx +--- +# src/content/docs/index.mdx +title: Welcome to my docs +--- + +import SomeComponent from '../../components/SomeComponent.astro'; +import AnotherComponent from '../../components/AnotherComponent.astro'; + +<SomeComponent prop="something" /> + +<AnotherComponent> + Components can also contain **nested content**. +</AnotherComponent> +``` + +Because Starlight is powered by Astro, you can use components built with any UI framework in your MDX files. +Learn more about [using components in MDX](https://docs.astro.build/en/guides/markdown-content/#using-components-in-mdx) in the Astro docs. + +## Built-in components + +Starlight provides built-in components for common documentation use cases. +These components are available from the `@astrojs/starlight/components` package. + +### Tabs + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +You can display a tabbed interface using the `<Tabs>` and `<TabItem>` components. +Each `<TabItem>` must have a `label` to display to users. + +```mdx +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +<Tabs> + <TabItem label="Stars">Sirius, Vega, Betelgeuse</TabItem> + <TabItem label="Moons">Io, Europa, Ganymede</TabItem> +</Tabs> +``` + +The code above generates the following tabs on the page: + +<Tabs> + <TabItem label="Stars">Sirius, Vega, Betelgeuse</TabItem> + <TabItem label="Moons">Io, Europa, Ganymede</TabItem> +</Tabs> diff --git a/packages/starlight/components.ts b/packages/starlight/components.ts new file mode 100644 index 00000000..1e25b6cc --- /dev/null +++ b/packages/starlight/components.ts @@ -0,0 +1,2 @@ +export { default as Tabs } from './user-components/Tabs.astro'; +export { default as TabItem } from './user-components/TabItem.astro'; diff --git a/packages/starlight/package.json b/packages/starlight/package.json index e70295e4..509ddfdb 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -21,6 +21,7 @@ "type": "module", "exports": { ".": "./index.ts", + "./components": "./components.ts", "./schema": "./schema.ts", "./types": "./types.ts", "./index.astro": "./index.astro", @@ -41,9 +42,11 @@ "execa": "^7.1.1", "hastscript": "^7.2.0", "pagefind": "^0.12.0", + "rehype": "^12.0.1", "remark-directive": "^2.0.1", "unified": "^10.1.2", "unist-util-remove": "^3.1.1", - "unist-util-visit": "^4.1.2" + "unist-util-visit": "^4.1.2", + "vfile": "^5.3.7" } } diff --git a/packages/starlight/user-components/TabItem.astro b/packages/starlight/user-components/TabItem.astro new file mode 100644 index 00000000..be52c6f6 --- /dev/null +++ b/packages/starlight/user-components/TabItem.astro @@ -0,0 +1,17 @@ +--- +import { TabItemTagname } from './rehype-tabs'; + +interface Props { + label: string; +} + +const { label } = Astro.props; + +if (!label) { + throw new Error('Missing prop `label` on `<TabItem>` component.'); +} +--- + +<TabItemTagname data-label={label}> + <slot /> +</TabItemTagname> diff --git a/packages/starlight/user-components/Tabs.astro b/packages/starlight/user-components/Tabs.astro new file mode 100644 index 00000000..dc4bc744 --- /dev/null +++ b/packages/starlight/user-components/Tabs.astro @@ -0,0 +1,148 @@ +--- +import { processPanels } from './rehype-tabs'; + +const panelHtml = await Astro.slots.render('default'); +const { html, panels } = processPanels(panelHtml); +--- + +<starlight-tabs> + { + panels && ( + <div class="tablist-wrapper"> + <ul role="tablist"> + {panels.map(({ label, panelId, tabId }, idx) => ( + <li role="presentation" class="tab"> + <a + role="tab" + href={'#' + panelId} + id={tabId} + aria-selected={idx === 0 && 'true'} + tabindex={idx !== 0 ? -1 : 0} + > + {label} + </a> + </li> + ))} + </ul> + </div> + ) + } + <Fragment set:html={html} /> +</starlight-tabs> + +<style> + starlight-tabs { + display: block; + } + + .tablist-wrapper { + overflow-x: auto; + } + + [role='tablist'] { + display: flex; + list-style: none; + border-bottom: 2px solid var(--sl-color-gray-5); + padding: 0; + } + + [role='tablist'] .tab + .tab { + margin-top: 0; + } + .tab { + margin-bottom: -2px; + } + .tab > [role='tab'] { + display: block; + padding: 0 1.25rem; + text-decoration: none; + border-bottom: 2px solid var(--sl-color-gray-5); + color: var(--sl-color-gray-3); + } + .tab [role='tab'][aria-selected] { + color: var(--sl-color-white); + border-color: var(--sl-color-text-accent); + font-weight: 600; + } + + .tablist-wrapper ~ :global([role='tabpanel']) { + margin-top: 1rem; + } +</style> + +<script> + class StarlightTabs extends HTMLElement { + tabs: HTMLAnchorElement[]; + panels: HTMLElement[]; + + constructor() { + super(); + const tablist = this.querySelector<HTMLUListElement>('[role="tablist"]')!; + this.tabs = [ + ...tablist.querySelectorAll<HTMLAnchorElement>('[role="tab"]'), + ]; + this.panels = [ + ...this.querySelectorAll<HTMLElement>('[role="tabpanel"]'), + ]; + + this.tabs.forEach((tab, i) => { + // Handle clicks for mouse users + tab.addEventListener('click', (e) => { + e.preventDefault(); + const currentTab = tablist.querySelector('[aria-selected]'); + if (e.currentTarget !== currentTab) { + this.switchTab(e.currentTarget as HTMLAnchorElement, i); + } + }); + + // Handle keyboard input + tab.addEventListener('keydown', (e) => { + const index = this.tabs.indexOf(e.currentTarget as any); + // Work out which key the user is pressing and + // Calculate the new tab's index where appropriate + const dir = + e.key === 'ArrowLeft' + ? index - 1 + : e.key === 'ArrowRight' + ? index + 1 + : e.key === 'ArrowDown' + ? 'down' + : null; + if (dir === null) return; + // If the down key is pressed, move focus to the open panel, + // otherwise switch to the adjacent tab + if (dir === 'down') { + e.preventDefault(); + this.panels[i]?.focus(); + } else if (this.tabs[dir]) { + e.preventDefault(); + this.switchTab(this.tabs[dir], dir); + } + }); + }); + } + + switchTab(newTab: HTMLAnchorElement | null | undefined, index: number) { + if (!newTab) return; + + // Mark all tabs as unselected and hide all tab panels. + this.tabs.forEach((tab) => { + tab.removeAttribute('aria-selected'); + tab.setAttribute('tabindex', '-1'); + }); + this.panels.forEach((oldPanel) => { + oldPanel.hidden = true; + }); + + // Show new panel and mark new tab as selected. + const newPanel = this.panels[index]; + if (newPanel) newPanel.hidden = false; + // Restore active tab to the default tab order. + newTab.removeAttribute('tabindex'); + newTab.setAttribute('aria-selected', 'true'); + newTab.focus(); + } + } + + customElements.define('starlight-tabs', StarlightTabs); +</script> diff --git a/packages/starlight/user-components/rehype-tabs.ts b/packages/starlight/user-components/rehype-tabs.ts new file mode 100644 index 00000000..e3421856 --- /dev/null +++ b/packages/starlight/user-components/rehype-tabs.ts @@ -0,0 +1,82 @@ +import { rehype } from 'rehype'; +import { CONTINUE, SKIP, visit } from 'unist-util-visit'; + +interface Panel { + panelId: string; + tabId: string; + label: string; +} + +declare module 'vfile' { + interface DataMap { + panels: Panel[]; + } +} + +export const TabItemTagname = 'starlight-tab-item'; + +let count = 0; +const getIDs = () => { + const id = count++; + return { panelId: 'tab-panel-' + id, tabId: 'tab-' + id }; +}; + +/** + * Rehype processor to extract tab panel data and turn each + * `<starlight-tab-item>` into a `<section>` with the necessary + * attributes. + */ +const tabsProcessor = rehype() + .data('settings', { fragment: true }) + .use(function tabs() { + return (tree, file) => { + file.data.panels = []; + let isFirst = true; + visit(tree, 'element', (node) => { + if (node.tagName !== TabItemTagname || !node.properties) { + return CONTINUE; + } + + const { dataLabel } = node.properties; + const ids = getIDs(); + file.data.panels?.push({ + ...ids, + label: String(dataLabel), + }); + + // Remove `<TabItem>` props + delete node.properties.dataLabel; + // Turn into `<section>` with required attributes + node.tagName = 'section'; + node.properties.id = ids.panelId; + node.properties['aria-labelledby'] = ids.tabId; + node.properties.role = 'tabpanel'; + node.properties.tabindex = -1; + // Hide all panels except the first + // TODO: make initially visible tab configurable + if (isFirst) { + isFirst = false; + } else { + node.properties.hidden = true; + } + + // Skip over the tab panel’s children. + return SKIP; + }); + }; + }); + +/** + * Process tab panel items to extract data for the tab links and format + * each tab panel correctly. + * @param html Inner HTML passed to the `<Tabs>` component. + */ +export const processPanels = (html: string) => { + const file = tabsProcessor.processSync({ value: html }); + return { + /** Data for each tab panel. */ + panels: file.data.panels, + /** Processed HTML for the tab panels. */ + html: file.toString(), + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e074675..d397e16d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: pagefind: specifier: ^0.12.0 version: 0.12.0 + rehype: + specifier: ^12.0.1 + version: 12.0.1 remark-directive: specifier: ^2.0.1 version: 2.0.1 @@ -58,6 +61,9 @@ importers: unist-util-visit: specifier: ^4.1.2 version: 4.1.2 + vfile: + specifier: ^5.3.7 + version: 5.3.7 devDependencies: '@types/node': specifier: ^18.15.11 |