summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Swithinbank2023-05-12 23:07:42 +0200
committerGitHub2023-05-12 23:07:42 +0200
commit623b577319b1dea2d6c42f1b680139fb858d85d6 (patch)
treef9fff83ac9d32e48edd47663afb3d237b1fcb4bf
parent97418a5c69e1bdff2f75b931cff35aa6e643b6cf (diff)
downloadIT.starlight-623b577319b1dea2d6c42f1b680139fb858d85d6.tar.gz
IT.starlight-623b577319b1dea2d6c42f1b680139fb858d85d6.tar.bz2
IT.starlight-623b577319b1dea2d6c42f1b680139fb858d85d6.zip
Add tab components (#38)
-rw-r--r--.changeset/slimy-icons-itch.md5
-rw-r--r--docs/astro.config.mjs6
-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.mdx60
-rw-r--r--packages/starlight/components.ts2
-rw-r--r--packages/starlight/package.json5
-rw-r--r--packages/starlight/user-components/TabItem.astro17
-rw-r--r--packages/starlight/user-components/Tabs.astro148
-rw-r--r--packages/starlight/user-components/rehype-tabs.ts82
-rw-r--r--pnpm-lock.yaml6
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