From d880065e29a632823a08adcb6158a59fd9557270 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Fri, 1 Mar 2024 18:55:56 +0100 Subject: Add `` component (#1564) * WIP: post T&D & evening work * Add steps rehype processor * Use rehype to validate and process steps content * Second pass at styling * Add docs to components guide * Add temporary test file * Add tests for rehype processor * Add changeset * Remove broken links in test page * Fix error message Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com> * Also fix snapshots * Brighter guide line in dark mode * Slightly smaller margin around bullets * Skip `color-contrast` tests in pa11y-ci * refactor: remove wrapper `
` Simplify styles by applying `sl-steps` class directly to `
    ` rather than relying on Astro scoping and a wrapper `
    ` * Move steps docs below file tree * Delete test page --------- Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>--- .changeset/sharp-cars-play.md | 5 ++ docs/.pa11yci | 3 + docs/src/content/docs/guides/components.mdx | 40 +++++++++++ .../__tests__/remark-rehype/rehype-steps.test.ts | 69 ++++++++++++++++++ packages/starlight/components.ts | 1 + packages/starlight/user-components/Steps.astro | 84 ++++++++++++++++++++++ packages/starlight/user-components/rehype-steps.ts | 58 +++++++++++++++ 7 files changed, 260 insertions(+) create mode 100644 .changeset/sharp-cars-play.md create mode 100644 packages/starlight/__tests__/remark-rehype/rehype-steps.test.ts create mode 100644 packages/starlight/user-components/Steps.astro create mode 100644 packages/starlight/user-components/rehype-steps.ts diff --git a/.changeset/sharp-cars-play.md b/.changeset/sharp-cars-play.md new file mode 100644 index 00000000..3bb1e812 --- /dev/null +++ b/.changeset/sharp-cars-play.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': minor +--- + +Adds a `` component for styling more complex guided tasks. diff --git a/docs/.pa11yci b/docs/.pa11yci index 76751c3e..db117e4e 100644 --- a/docs/.pa11yci +++ b/docs/.pa11yci @@ -2,6 +2,9 @@ "defaults": { "runners": [ "axe" + ], + "ignore": [ + "color-contrast" ] } } diff --git a/docs/src/content/docs/guides/components.mdx b/docs/src/content/docs/guides/components.mdx index dff8be68..a193175b 100644 --- a/docs/src/content/docs/guides/components.mdx +++ b/docs/src/content/docs/guides/components.mdx @@ -328,6 +328,46 @@ import { FileTree } from '@astrojs/starlight/components'; +### Steps + +Use the `` component to style numbered lists of tasks. +This is useful for more complex step-by-step guides where each step needs to be clearly highlighted. + +Wrap `` around a standard Markdown ordered list. +All the usual Markdown syntax is applicable inside ``. + +````mdx title="src/content/docs/example.mdx" +import { Steps } from '@astrojs/starlight/components'; + + + +1. Import the component into your MDX file: + + ```js + import { Steps } from '@astrojs/starlight/components'; + ``` + +2. Wrap `` around your ordered list items. + + +```` + +The code above generates the following on the page: + +import { Steps } from '@astrojs/starlight/components'; + + + +1. Import the component into your MDX file: + + ```js + import { Steps } from '@astrojs/starlight/components'; + ``` + +2. Wrap `` around your ordered list items. + + + ### Icon import { Icon } from '@astrojs/starlight/components'; diff --git a/packages/starlight/__tests__/remark-rehype/rehype-steps.test.ts b/packages/starlight/__tests__/remark-rehype/rehype-steps.test.ts new file mode 100644 index 00000000..d97f7db8 --- /dev/null +++ b/packages/starlight/__tests__/remark-rehype/rehype-steps.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from 'vitest'; +import { processSteps } from '../../user-components/rehype-steps'; + +test('empty component throws an error', () => { + expect(() => processSteps('')).toThrowErrorMatchingInlineSnapshot( + ` + "[AstroUserError]: + The \`\` component expects its content to be a single ordered list (\`
      \`) but found no child elements. + Hint: + To learn more about the \`\` component, see https://starlight.astro.build/guides/components/#steps" + ` + ); +}); + +test('component with non-element content throws an error', () => { + expect(() => processSteps('Text node')).toThrowErrorMatchingInlineSnapshot( + ` + "[AstroUserError]: + The \`\` component expects its content to be a single ordered list (\`
        \`) but found no child elements. + Hint: + To learn more about the \`\` component, see https://starlight.astro.build/guides/components/#steps" + ` + ); +}); + +test('component with non-`
          ` content throws an error', () => { + expect(() => processSteps('

          A paragraph is not an ordered list

          ')) + .toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The \`\` component expects its content to be a single ordered list (\`
            \`) but found the following element: \`

            \`. + Hint: + To learn more about the \`\` component, see https://starlight.astro.build/guides/components/#steps" + `); +}); + +test('component with multiple children throws an error', () => { + expect(() => processSteps('

                ')).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The \`\` component expects its content to be a single ordered list (\`
                  \`) but found multiple child elements: \`
                    \`, \`
                      \`. + Hint: + To learn more about the \`\` component, see https://starlight.astro.build/guides/components/#steps" + `); +}); + +test('applies `role="list"` to child list', () => { + const { html } = processSteps('
                      1. Step one
                      '); + expect(html).toMatchInlineSnapshot(`"
                      1. Step one
                      "`); +}); + +test('does not interfere with other attributes on the child list', () => { + const { html } = processSteps('
                      1. Step one
                      '); + expect(html).toMatchInlineSnapshot( + `"
                      1. Step one
                      "` + ); +}); + +test('applies `class="sl-list"` to child list', () => { + const { html } = processSteps('
                      1. Step one
                      '); + expect(html).toContain('class="sl-steps"'); +}); + +test('applies class name and preserves existing classes on a child list', () => { + const testClass = 'test class-concat'; + const { html } = processSteps(`
                      1. Step one
                      `); + expect(html).toContain(`class="${testClass} sl-steps"`); + expect(html).toMatchInlineSnapshot( + `"
                      1. Step one
                      "` + ); +}); diff --git a/packages/starlight/components.ts b/packages/starlight/components.ts index fdf417ee..e564b62b 100644 --- a/packages/starlight/components.ts +++ b/packages/starlight/components.ts @@ -5,5 +5,6 @@ export { default as Icon } from './user-components/Icon.astro'; export { default as Tabs } from './user-components/Tabs.astro'; export { default as TabItem } from './user-components/TabItem.astro'; export { default as LinkCard } from './user-components/LinkCard.astro'; +export { default as Steps } from './user-components/Steps.astro'; export { default as FileTree } from './user-components/FileTree.astro'; export { Code } from 'astro-expressive-code/components'; diff --git a/packages/starlight/user-components/Steps.astro b/packages/starlight/user-components/Steps.astro new file mode 100644 index 00000000..ffd65a35 --- /dev/null +++ b/packages/starlight/user-components/Steps.astro @@ -0,0 +1,84 @@ +--- +import { processSteps } from './rehype-steps'; + +const content = await Astro.slots.render('default'); +const { html } = processSteps(content); +--- + + + + diff --git a/packages/starlight/user-components/rehype-steps.ts b/packages/starlight/user-components/rehype-steps.ts new file mode 100644 index 00000000..dd912ced --- /dev/null +++ b/packages/starlight/user-components/rehype-steps.ts @@ -0,0 +1,58 @@ +import { AstroError } from 'astro/errors'; +import type { Element, Root } from 'hast'; +import { rehype } from 'rehype'; + +const stepsProcessor = rehype() + .data('settings', { fragment: true }) + .use(function steps() { + return (tree: Root) => { + const rootElements = tree.children.filter((item): item is Element => item.type === 'element'); + const [rootElement] = rootElements; + + if (!rootElement) { + throw new StepsError( + 'The `` component expects its content to be a single ordered list (`
                        `) but found no child elements.' + ); + } else if (rootElements.length > 1) { + throw new StepsError( + 'The `` component expects its content to be a single ordered list (`
                          `) but found multiple child elements: ' + + rootElements.map((element: Element) => `\`<${element.tagName}>\``).join(', ') + + '.' + ); + } else if (rootElement.tagName !== 'ol') { + throw new StepsError( + 'The `` component expects its content to be a single ordered list (`
                            `) but found the following element: ' + + `\`<${rootElement.tagName}>\`.` + ); + } + + // Ensure `role="list"` is set on the ordered list. + // We use `list-style: none` in the styles for this component and need to ensure the list + // retains its semantics in Safari, which will remove them otherwise. + rootElement.properties.role = 'list'; + // Add the required CSS class name, preserving existing classes if present. + if (!Array.isArray(rootElement.properties.className)) { + rootElement.properties.className = ['sl-steps']; + } else { + rootElement.properties.className.push('sl-steps'); + } + }; + }); + +/** + * Process steps children: validates the HTML and adds `role="list"` to the ordered list. + * @param html Inner HTML passed to the `` component. + */ +export const processSteps = (html: string | undefined) => { + const file = stepsProcessor.processSync({ value: html }); + return { html: file.toString() }; +}; + +class StepsError extends AstroError { + constructor(message: string) { + super( + message, + 'To learn more about the `` component, see https://starlight.astro.build/guides/components/#steps' + ); + } +} -- cgit