@astrojs/starlight 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @astrojs/starlight
2
2
 
3
+ ## 0.0.4
4
+
5
+ ### Patch Changes
6
+
7
+ - [#40](https://github.com/withastro/starlight/pull/40) [`e22dd76`](https://github.com/withastro/starlight/commit/e22dd76136f9749bb5d43f96241385faccfc90a1) Thanks [@delucis](https://github.com/delucis)! - Generate sitemaps for Starlight sites
8
+
9
+ - [#38](https://github.com/withastro/starlight/pull/38) [`623b577`](https://github.com/withastro/starlight/commit/623b577319b1dea2d6c42f1b680139fb858d85d6) Thanks [@delucis](https://github.com/delucis)! - Add tab components for use in MDX.
10
+
11
+ ## 0.0.3
12
+
13
+ ### Patch Changes
14
+
15
+ - [`1f62c75`](https://github.com/withastro/starlight/commit/1f62c75297ed44da721350840744823876a64bc5) Thanks [@delucis](https://github.com/delucis)! - Add repo, issues & homepage links to package.json
16
+
17
+ - [#36](https://github.com/withastro/starlight/pull/36) [`62265e4`](https://github.com/withastro/starlight/commit/62265e4a653d161483e3844b568ab150334e9238) Thanks [@delucis](https://github.com/delucis)! - Collapse table of contents in dropdown on narrower viewports
18
+
3
19
  ## 0.0.2
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Starlight
1
+ # <img src="https://github.com/withastro/starlight/assets/357379/494fcd83-42aa-4891-87e0-87402fa0b6f3" alt="" align="left" width="40" height="40"> Starlight
2
2
 
3
3
  > **Warning**
4
4
  > This tool is an early release and very much still work in progress!
@@ -40,6 +40,8 @@ const description = data.description || config.description;
40
40
  href={import.meta.env.BASE_URL + 'favicon.svg'}
41
41
  type="image/svg+xml"
42
42
  />
43
+ {/* Link to sitemap, but only when `site` is set. */}
44
+ {Astro.site && <link rel="sitemap" href="/sitemap-index.xml" />}
43
45
 
44
46
  <!-- OpenGraph Tags -->
45
47
  <meta property="og:title" content={title} />
@@ -30,7 +30,7 @@ interface Props {
30
30
  }
31
31
 
32
32
  .site-title {
33
- font-size: var(--sl-text-2xl);
33
+ font-size: var(--sl-text-h4);
34
34
  font-weight: 600;
35
35
  color: var(--sl-color-text-accent);
36
36
  text-decoration: none;
@@ -0,0 +1,29 @@
1
+ ---
2
+ import type { MarkdownHeading } from 'astro';
3
+ import type { CollectionEntry } from 'astro:content';
4
+ import config from 'virtual:starlight/user-config';
5
+ import EditLink from './EditLink.astro';
6
+ import RightSidebarPanel from './RightSidebarPanel.astro';
7
+ import TableOfContents from './TableOfContents.astro';
8
+
9
+ interface Props {
10
+ entry: CollectionEntry<'docs'>;
11
+ headings: MarkdownHeading[];
12
+ }
13
+
14
+ const { entry, headings } = Astro.props;
15
+ ---
16
+
17
+ <RightSidebarPanel>
18
+ <TableOfContents headings={headings} />
19
+ </RightSidebarPanel>
20
+ <RightSidebarPanel>
21
+ {
22
+ config.editLink.baseUrl && (
23
+ <>
24
+ <h2>Contribute</h2>
25
+ <EditLink data={entry.data} id={entry.id} />
26
+ </>
27
+ )
28
+ }
29
+ </RightSidebarPanel>
@@ -0,0 +1,125 @@
1
+ ---
2
+ import type { MarkdownHeading } from 'astro';
3
+ import config from 'virtual:starlight/user-config';
4
+ import { generateToC } from './generateToC';
5
+ import TableOfContentsList from './TableOfContentsList.astro';
6
+ import Icon from '../Icon.astro';
7
+
8
+ interface Props {
9
+ headings: MarkdownHeading[];
10
+ }
11
+
12
+ const toc = generateToC(Astro.props.headings, config.tableOfContents);
13
+ ---
14
+
15
+ <nav aria-labelledby="starlight__on-this-page--mobile" class="lg:hidden">
16
+ <details id="starlight__mobile-toc">
17
+ <summary id="starlight__on-this-page--mobile" class="flex">
18
+ <div class="toggle flex">
19
+ On this page
20
+ <Icon name={'right-caret'} class="caret" size="1rem" />
21
+ </div>
22
+ </summary>
23
+ <div class="dropdown">
24
+ <TableOfContentsList toc={toc} isMobile />
25
+ </div>
26
+ </details>
27
+ </nav>
28
+
29
+ <style>
30
+ nav {
31
+ position: fixed;
32
+ top: calc(var(--sl-nav-height) - 1px);
33
+ inset-inline: 0;
34
+ border-top: 1px solid var(--sl-color-gray-5);
35
+ background-color: var(--sl-color-bg-nav);
36
+ }
37
+ @media (min-width: 50rem) {
38
+ nav {
39
+ inset-inline-start: var(--sl-sidebar-width);
40
+ }
41
+ }
42
+
43
+ summary {
44
+ height: var(--sl-mobile-toc-height);
45
+ border-bottom: 1px solid var(--sl-color-hairline-shade);
46
+ padding: 0.5rem 1rem;
47
+ outline-offset: var(--sl-outline-offset-inside);
48
+ }
49
+ summary::marker,
50
+ summary::-webkit-details-marker {
51
+ display: none;
52
+ }
53
+
54
+ .toggle {
55
+ gap: 1rem;
56
+ align-items: center;
57
+ justify-content: space-between;
58
+ border: 1px solid var(--sl-color-gray-5);
59
+ border-radius: 0.5rem;
60
+ padding-block: 0.5rem;
61
+ padding-inline-start: 0.75rem;
62
+ padding-inline-end: 0.5rem;
63
+ font-size: var(--sl-text-xs);
64
+ line-height: 1;
65
+ background-color: var(--sl-color-black);
66
+ user-select: none;
67
+ cursor: pointer;
68
+ }
69
+ details[open] .toggle {
70
+ color: var(--sl-color-white);
71
+ border-color: var(--sl-color-accent);
72
+ }
73
+ details .toggle:hover {
74
+ color: var(--sl-color-white);
75
+ border-color: var(--sl-color-gray-2);
76
+ }
77
+
78
+ :global([dir='rtl']) .caret {
79
+ transform: rotateZ(180deg);
80
+ }
81
+ details[open] .caret {
82
+ transform: rotateZ(90deg);
83
+ }
84
+
85
+ .dropdown {
86
+ --border-top: 1px;
87
+ margin-top: calc(-1 * var(--border-top));
88
+ border: var(--border-top) solid var(--sl-color-gray-6);
89
+ border-top-color: var(--sl-color-hairline-shade);
90
+ max-height: calc(85vh - var(--sl-nav-height) - var(--sl-mobile-toc-height));
91
+ overflow-y: auto;
92
+ background-color: var(--sl-color-black);
93
+ box-shadow: var(--sl-shadow-md);
94
+ }
95
+ </style>
96
+
97
+ <script>
98
+ const details = document.querySelector<HTMLDetailsElement>(
99
+ '#starlight__mobile-toc'
100
+ );
101
+ if (details) {
102
+ const closeToC = () => {
103
+ details.open = false;
104
+ };
105
+ // Close the table of contents whenever a link is clicked.
106
+ details.querySelectorAll('a').forEach((a) => {
107
+ a.addEventListener('click', closeToC);
108
+ });
109
+ // Close the table of contents when a user clicks outside of it.
110
+ window.addEventListener('click', (e) => {
111
+ if (!details.contains(e.target as Node)) closeToC();
112
+ });
113
+ // Or when they press the escape key.
114
+ window.addEventListener('keydown', (e) => {
115
+ if (e.key === 'Escape' && details.open) {
116
+ const hasFocus = details.contains(document.activeElement);
117
+ closeToC();
118
+ if (hasFocus) {
119
+ const summary = details.querySelector('summary');
120
+ if (summary) summary.focus();
121
+ }
122
+ }
123
+ });
124
+ }
125
+ </script>
@@ -3,33 +3,54 @@ import type { generateToC } from './generateToC';
3
3
 
4
4
  interface Props {
5
5
  toc: ReturnType<typeof generateToC>;
6
- nested?: boolean;
6
+ depth?: number;
7
+ isMobile?: boolean;
7
8
  }
8
9
 
9
- const { toc, nested } = Astro.props;
10
+ const { toc, isMobile = false, depth = 0 } = Astro.props;
10
11
  ---
11
12
 
12
- <ul class:list={{ nested }}>
13
+ <ul class:list={{ isMobile }}>
13
14
  {
14
15
  toc.map((heading) => (
15
16
  <li>
16
17
  <a href={'#' + heading.slug}>{heading.text}</a>
17
18
  {heading.children.length > 0 && (
18
- <Astro.self toc={heading.children} nested />
19
+ <Astro.self
20
+ toc={heading.children}
21
+ depth={depth + 1}
22
+ isMobile={isMobile}
23
+ />
19
24
  )}
20
25
  </li>
21
26
  ))
22
27
  }
23
28
  </ul>
24
29
 
25
- <style>
30
+ <style define:vars={{ depth }}>
26
31
  ul {
27
32
  padding: 0;
28
33
  }
29
- .nested {
30
- padding-inline-start: 1rem;
31
- }
32
34
  ul :global(::marker) {
33
35
  color: transparent;
34
36
  }
37
+ a {
38
+ --pad-inline: 0rem;
39
+ display: block;
40
+ padding-inline: calc(1rem * var(--depth) + var(--pad-inline))
41
+ var(--pad-inline);
42
+ }
43
+ .isMobile a {
44
+ --pad-inline: 1rem;
45
+ border-top: 1px solid var(--sl-color-gray-6);
46
+ padding-block: 0.5rem;
47
+ color: var(--sl-color-text);
48
+ font-size: var(--sl-text-sm);
49
+ line-height: 1.25;
50
+ text-decoration: none;
51
+ outline-offset: var(--sl-outline-offset-inside);
52
+ }
53
+ .isMobile:first-child > li:first-child > a {
54
+ border-top: 0;
55
+ }
35
56
  </style>
package/components.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default as Tabs } from './user-components/Tabs.astro';
2
+ export { default as TabItem } from './user-components/TabItem.astro';
package/index.astro CHANGED
@@ -1,6 +1,5 @@
1
1
  ---
2
2
  import type { InferGetStaticPropsType } from 'astro';
3
- import config from 'virtual:starlight/user-config';
4
3
 
5
4
  import { getPrevNextLinks, getSidebar } from './utils/navigation';
6
5
  import { paths } from './utils/routing';
@@ -13,17 +12,16 @@ import './style/util.css';
13
12
 
14
13
  // Components — can override built-in CSS, but not user CSS.
15
14
  import ContentPanel from './components/ContentPanel.astro';
16
- import EditLink from './components/EditLink.astro';
17
15
  import FallbackContentNotice from './components/FallbackContentNotice.astro';
18
16
  import HeadSEO from './components/HeadSEO.astro';
19
17
  import Header from './components/Header.astro';
20
18
  import LastUpdated from './components/LastUpdated.astro';
21
19
  import MarkdownContent from './components/MarkdownContent.astro';
22
20
  import PrevNextLinks from './components/PrevNextLinks.astro';
23
- import RightSidebarPanel from './components/RightSidebarPanel.astro';
21
+ import RightSidebar from './components/RightSidebar.astro';
24
22
  import Sidebar from './components/Sidebar.astro';
25
23
  import SkipLink from './components/SkipLink.astro';
26
- import TableOfContents from './components/TableOfContents.astro';
24
+ import MobileTableOfContents from './components/TableOfContents/MobileTableOfContents.astro';
27
25
  import ThemeProvider from './components/ThemeProvider.astro';
28
26
  import PageFrame from './layout/PageFrame.astro';
29
27
  import TwoColumnContent from './layout/TwoColumnContent.astro';
@@ -58,22 +56,9 @@ const prevNextLinks = getPrevNextLinks(sidebar);
58
56
  <PageFrame>
59
57
  <Header slot="header" locale={locale} />
60
58
  <Sidebar slot="sidebar" sidebar={sidebar} locale={locale} />
59
+ <MobileTableOfContents headings={headings} />
61
60
  <TwoColumnContent>
62
- <Fragment slot="right-sidebar">
63
- <RightSidebarPanel>
64
- <TableOfContents headings={headings} />
65
- </RightSidebarPanel>
66
- <RightSidebarPanel>
67
- {
68
- config.editLink.baseUrl && (
69
- <>
70
- <h2>Contribute</h2>
71
- <EditLink data={entry.data} id={entry.id} />
72
- </>
73
- )
74
- }
75
- </RightSidebarPanel>
76
- </Fragment>
61
+ <RightSidebar slot="right-sidebar" entry={entry} headings={headings} />
77
62
  <main
78
63
  id="starlight__overview"
79
64
  data-pagefind-body
package/index.ts CHANGED
@@ -9,6 +9,7 @@ import { spawn } from 'node:child_process';
9
9
  import { dirname, relative } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { starlightAsides } from './integrations/asides';
12
+ import { starlightSitemap } from './integrations/sitemap';
12
13
  import {
13
14
  StarlightUserConfig,
14
15
  StarlightConfig,
@@ -63,7 +64,7 @@ export default function StarlightIntegration(
63
64
  },
64
65
  };
65
66
 
66
- return [Starlight, mdx()];
67
+ return [starlightSitemap(userConfig), Starlight, mdx()];
67
68
  }
68
69
 
69
70
  function resolveVirtualModuleId(id: string) {
@@ -0,0 +1,22 @@
1
+ import sitemap, { SitemapOptions } from '@astrojs/sitemap';
2
+ import type { StarlightConfig } from '../types';
3
+
4
+ /**
5
+ * A wrapped version of the `@astrojs/sitemap` integration configured based
6
+ * on Starlight i18n config.
7
+ */
8
+ export function starlightSitemap(opts: StarlightConfig) {
9
+ const sitemapConfig: SitemapOptions = {};
10
+ if (opts.isMultilingual) {
11
+ sitemapConfig.i18n = {
12
+ defaultLocale: opts.defaultLocale.locale! || 'root',
13
+ locales: Object.fromEntries(
14
+ Object.entries(opts.locales).map(([locale, config]) => [
15
+ locale,
16
+ config?.lang!,
17
+ ])
18
+ ),
19
+ };
20
+ }
21
+ return sitemap(sitemapConfig);
22
+ }
@@ -50,6 +50,7 @@ const hasSidebar = Astro.slots.has('sidebar');
50
50
  .sidebar-pane {
51
51
  visibility: var(--sl-sidebar-visibility, hidden);
52
52
  position: fixed;
53
+ z-index: var(--sl-z-index-menu);
53
54
  inset-block: 0;
54
55
  inset-inline-start: 0;
55
56
  padding-top: var(--sl-nav-height);
@@ -67,7 +68,7 @@ const hasSidebar = Astro.slots.has('sidebar');
67
68
  }
68
69
 
69
70
  .main-frame {
70
- padding-top: var(--sl-nav-height);
71
+ padding-top: calc(var(--sl-nav-height) + var(--sl-mobile-toc-height));
71
72
  }
72
73
 
73
74
  @media (min-width: 50rem) {
@@ -1,5 +1,5 @@
1
1
  <div class="container">
2
- <aside class="right-sidebar-container">
2
+ <aside class="right-sidebar-container hidden lg:block">
3
3
  <div class="right-sidebar"><slot name="right-sidebar" /></div>
4
4
  </aside>
5
5
  <div class="main-pane"><slot /></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrojs/starlight",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Build beautiful, high-performance documentation websites with Astro",
5
5
  "keywords": [
6
6
  "docs",
@@ -10,9 +10,17 @@
10
10
  ],
11
11
  "author": "Chris Swithinbank <swithinbank@gmail.com>",
12
12
  "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/withastro/starlight",
16
+ "directory": "packages/starlight"
17
+ },
18
+ "bugs": "https://github.com/withastro/starlight/issues",
19
+ "homepage": "https://starlight.astro.build",
13
20
  "type": "module",
14
21
  "exports": {
15
22
  ".": "./index.ts",
23
+ "./components": "./components.ts",
16
24
  "./schema": "./schema.ts",
17
25
  "./types": "./types.ts",
18
26
  "./index.astro": "./index.astro",
@@ -27,16 +35,19 @@
27
35
  },
28
36
  "dependencies": {
29
37
  "@astrojs/mdx": "^0.19.1",
38
+ "@astrojs/sitemap": "^1.3.1",
30
39
  "@pagefind/default-ui": "^0.12.0",
31
40
  "@types/mdast": "^3.0.11",
32
41
  "bcp-47": "^2.1.0",
33
42
  "execa": "^7.1.1",
34
43
  "hastscript": "^7.2.0",
35
44
  "pagefind": "^0.12.0",
45
+ "rehype": "^12.0.1",
36
46
  "remark-directive": "^2.0.1",
37
47
  "unified": "^10.1.2",
38
48
  "unist-util-remove": "^3.1.1",
39
- "unist-util-visit": "^4.1.2"
49
+ "unist-util-visit": "^4.1.2",
50
+ "vfile": "^5.3.7"
40
51
  },
41
52
  "scripts": {}
42
53
  }
package/style/props.css CHANGED
@@ -113,17 +113,21 @@
113
113
  --__sb-font-mono: var(--sl-font-mono, ''), var(--sl-font-system-mono);
114
114
 
115
115
  /** Key layout values */
116
- --sl-nav-height: 4rem;
116
+ --sl-nav-height: 3.5rem;
117
117
  --sl-nav-pad-x: 1.5rem;
118
118
  --sl-nav-pad-y: 0.75rem;
119
+ --sl-mobile-toc-height: 3rem;
119
120
  --sl-sidebar-width: 18.75rem;
120
121
  --sl-sidebar-pad-x: 1rem;
121
122
  --sl-content-width: 45rem;
122
123
  --sl-content-pad-x: 1rem;
123
124
  --sl-menu-button-size: 2rem;
124
125
  --sl-nav-gap: var(--sl-content-pad-x);
126
+ /* Offset required to show outline inside an element instead of round the outside */
127
+ --sl-outline-offset-inside: -0.1875rem;
125
128
 
126
129
  /* Global z-index values */
130
+ --sl-z-index-menu: 5;
127
131
  --sl-z-index-navbar: 10;
128
132
  --sl-z-index-skiplink: 20;
129
133
  }
@@ -183,6 +187,7 @@
183
187
 
184
188
  @media (min-width: 50em) {
185
189
  :root {
190
+ --sl-nav-height: 4rem;
186
191
  --sl-text-h1: var(--sl-text-5xl);
187
192
  --sl-text-h2: var(--sl-text-4xl);
188
193
  --sl-text-h3: var(--sl-text-3xl);
@@ -193,5 +198,6 @@
193
198
  @media (min-width: 72rem) {
194
199
  :root {
195
200
  --sl-content-pad-x: 1.5rem;
201
+ --sl-mobile-toc-height: 0rem;
196
202
  }
197
203
  }
package/style/util.css CHANGED
@@ -30,3 +30,14 @@
30
30
  display: block;
31
31
  }
32
32
  }
33
+ @media (min-width: 72rem) {
34
+ .lg\:hidden {
35
+ display: none;
36
+ }
37
+ .lg\:flex {
38
+ display: flex;
39
+ }
40
+ .lg\:block {
41
+ display: block;
42
+ }
43
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { TabItemTagname } from './rehype-tabs';
3
+
4
+ interface Props {
5
+ label: string;
6
+ }
7
+
8
+ const { label } = Astro.props;
9
+
10
+ if (!label) {
11
+ throw new Error('Missing prop `label` on `<TabItem>` component.');
12
+ }
13
+ ---
14
+
15
+ <TabItemTagname data-label={label}>
16
+ <slot />
17
+ </TabItemTagname>
@@ -0,0 +1,148 @@
1
+ ---
2
+ import { processPanels } from './rehype-tabs';
3
+
4
+ const panelHtml = await Astro.slots.render('default');
5
+ const { html, panels } = processPanels(panelHtml);
6
+ ---
7
+
8
+ <starlight-tabs>
9
+ {
10
+ panels && (
11
+ <div class="tablist-wrapper">
12
+ <ul role="tablist">
13
+ {panels.map(({ label, panelId, tabId }, idx) => (
14
+ <li role="presentation" class="tab">
15
+ <a
16
+ role="tab"
17
+ href={'#' + panelId}
18
+ id={tabId}
19
+ aria-selected={idx === 0 && 'true'}
20
+ tabindex={idx !== 0 ? -1 : 0}
21
+ >
22
+ {label}
23
+ </a>
24
+ </li>
25
+ ))}
26
+ </ul>
27
+ </div>
28
+ )
29
+ }
30
+ <Fragment set:html={html} />
31
+ </starlight-tabs>
32
+
33
+ <style>
34
+ starlight-tabs {
35
+ display: block;
36
+ }
37
+
38
+ .tablist-wrapper {
39
+ overflow-x: auto;
40
+ }
41
+
42
+ [role='tablist'] {
43
+ display: flex;
44
+ list-style: none;
45
+ border-bottom: 2px solid var(--sl-color-gray-5);
46
+ padding: 0;
47
+ }
48
+
49
+ [role='tablist'] .tab + .tab {
50
+ margin-top: 0;
51
+ }
52
+ .tab {
53
+ margin-bottom: -2px;
54
+ }
55
+ .tab > [role='tab'] {
56
+ display: block;
57
+ padding: 0 1.25rem;
58
+ text-decoration: none;
59
+ border-bottom: 2px solid var(--sl-color-gray-5);
60
+ color: var(--sl-color-gray-3);
61
+ }
62
+ .tab [role='tab'][aria-selected] {
63
+ color: var(--sl-color-white);
64
+ border-color: var(--sl-color-text-accent);
65
+ font-weight: 600;
66
+ }
67
+
68
+ .tablist-wrapper ~ :global([role='tabpanel']) {
69
+ margin-top: 1rem;
70
+ }
71
+ </style>
72
+
73
+ <script>
74
+ class StarlightTabs extends HTMLElement {
75
+ tabs: HTMLAnchorElement[];
76
+ panels: HTMLElement[];
77
+
78
+ constructor() {
79
+ super();
80
+ const tablist = this.querySelector<HTMLUListElement>('[role="tablist"]')!;
81
+ this.tabs = [
82
+ ...tablist.querySelectorAll<HTMLAnchorElement>('[role="tab"]'),
83
+ ];
84
+ this.panels = [
85
+ ...this.querySelectorAll<HTMLElement>('[role="tabpanel"]'),
86
+ ];
87
+
88
+ this.tabs.forEach((tab, i) => {
89
+ // Handle clicks for mouse users
90
+ tab.addEventListener('click', (e) => {
91
+ e.preventDefault();
92
+ const currentTab = tablist.querySelector('[aria-selected]');
93
+ if (e.currentTarget !== currentTab) {
94
+ this.switchTab(e.currentTarget as HTMLAnchorElement, i);
95
+ }
96
+ });
97
+
98
+ // Handle keyboard input
99
+ tab.addEventListener('keydown', (e) => {
100
+ const index = this.tabs.indexOf(e.currentTarget as any);
101
+ // Work out which key the user is pressing and
102
+ // Calculate the new tab's index where appropriate
103
+ const dir =
104
+ e.key === 'ArrowLeft'
105
+ ? index - 1
106
+ : e.key === 'ArrowRight'
107
+ ? index + 1
108
+ : e.key === 'ArrowDown'
109
+ ? 'down'
110
+ : null;
111
+ if (dir === null) return;
112
+ // If the down key is pressed, move focus to the open panel,
113
+ // otherwise switch to the adjacent tab
114
+ if (dir === 'down') {
115
+ e.preventDefault();
116
+ this.panels[i]?.focus();
117
+ } else if (this.tabs[dir]) {
118
+ e.preventDefault();
119
+ this.switchTab(this.tabs[dir], dir);
120
+ }
121
+ });
122
+ });
123
+ }
124
+
125
+ switchTab(newTab: HTMLAnchorElement | null | undefined, index: number) {
126
+ if (!newTab) return;
127
+
128
+ // Mark all tabs as unselected and hide all tab panels.
129
+ this.tabs.forEach((tab) => {
130
+ tab.removeAttribute('aria-selected');
131
+ tab.setAttribute('tabindex', '-1');
132
+ });
133
+ this.panels.forEach((oldPanel) => {
134
+ oldPanel.hidden = true;
135
+ });
136
+
137
+ // Show new panel and mark new tab as selected.
138
+ const newPanel = this.panels[index];
139
+ if (newPanel) newPanel.hidden = false;
140
+ // Restore active tab to the default tab order.
141
+ newTab.removeAttribute('tabindex');
142
+ newTab.setAttribute('aria-selected', 'true');
143
+ newTab.focus();
144
+ }
145
+ }
146
+
147
+ customElements.define('starlight-tabs', StarlightTabs);
148
+ </script>
@@ -0,0 +1,82 @@
1
+ import { rehype } from 'rehype';
2
+ import { CONTINUE, SKIP, visit } from 'unist-util-visit';
3
+
4
+ interface Panel {
5
+ panelId: string;
6
+ tabId: string;
7
+ label: string;
8
+ }
9
+
10
+ declare module 'vfile' {
11
+ interface DataMap {
12
+ panels: Panel[];
13
+ }
14
+ }
15
+
16
+ export const TabItemTagname = 'starlight-tab-item';
17
+
18
+ let count = 0;
19
+ const getIDs = () => {
20
+ const id = count++;
21
+ return { panelId: 'tab-panel-' + id, tabId: 'tab-' + id };
22
+ };
23
+
24
+ /**
25
+ * Rehype processor to extract tab panel data and turn each
26
+ * `<starlight-tab-item>` into a `<section>` with the necessary
27
+ * attributes.
28
+ */
29
+ const tabsProcessor = rehype()
30
+ .data('settings', { fragment: true })
31
+ .use(function tabs() {
32
+ return (tree, file) => {
33
+ file.data.panels = [];
34
+ let isFirst = true;
35
+ visit(tree, 'element', (node) => {
36
+ if (node.tagName !== TabItemTagname || !node.properties) {
37
+ return CONTINUE;
38
+ }
39
+
40
+ const { dataLabel } = node.properties;
41
+ const ids = getIDs();
42
+ file.data.panels?.push({
43
+ ...ids,
44
+ label: String(dataLabel),
45
+ });
46
+
47
+ // Remove `<TabItem>` props
48
+ delete node.properties.dataLabel;
49
+ // Turn into `<section>` with required attributes
50
+ node.tagName = 'section';
51
+ node.properties.id = ids.panelId;
52
+ node.properties['aria-labelledby'] = ids.tabId;
53
+ node.properties.role = 'tabpanel';
54
+ node.properties.tabindex = -1;
55
+ // Hide all panels except the first
56
+ // TODO: make initially visible tab configurable
57
+ if (isFirst) {
58
+ isFirst = false;
59
+ } else {
60
+ node.properties.hidden = true;
61
+ }
62
+
63
+ // Skip over the tab panel’s children.
64
+ return SKIP;
65
+ });
66
+ };
67
+ });
68
+
69
+ /**
70
+ * Process tab panel items to extract data for the tab links and format
71
+ * each tab panel correctly.
72
+ * @param html Inner HTML passed to the `<Tabs>` component.
73
+ */
74
+ export const processPanels = (html: string) => {
75
+ const file = tabsProcessor.processSync({ value: html });
76
+ return {
77
+ /** Data for each tab panel. */
78
+ panels: file.data.panels,
79
+ /** Processed HTML for the tab panels. */
80
+ html: file.toString(),
81
+ };
82
+ };