diff options
author | Chris Swithinbank | 2024-03-01 18:55:56 +0100 |
---|---|---|
committer | GitHub | 2024-03-01 18:55:56 +0100 |
commit | d880065e29a632823a08adcb6158a59fd9557270 (patch) | |
tree | 3bab5b435c32042657014533ab16a96f4ae7279f | |
parent | 9a918a5b4902f43729f4d023257772710af3a12b (diff) | |
download | IT.starlight-d880065e29a632823a08adcb6158a59fd9557270.tar.gz IT.starlight-d880065e29a632823a08adcb6158a59fd9557270.tar.bz2 IT.starlight-d880065e29a632823a08adcb6158a59fd9557270.zip |
Add `<Steps>` component (#1564)
* WIP: <Steps> 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 `<div>`
Simplify styles by applying `sl-steps` class directly to `<ol>` rather than relying on Astro scoping and a wrapper `<div>`
* Move steps docs below file tree
* Delete test page
---------
Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>
-rw-r--r-- | .changeset/sharp-cars-play.md | 5 | ||||
-rw-r--r-- | docs/.pa11yci | 3 | ||||
-rw-r--r-- | docs/src/content/docs/guides/components.mdx | 40 | ||||
-rw-r--r-- | packages/starlight/__tests__/remark-rehype/rehype-steps.test.ts | 69 | ||||
-rw-r--r-- | packages/starlight/components.ts | 1 | ||||
-rw-r--r-- | packages/starlight/user-components/Steps.astro | 84 | ||||
-rw-r--r-- | packages/starlight/user-components/rehype-steps.ts | 58 |
7 files changed, 260 insertions, 0 deletions
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 `<Steps>` 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'; </FileTree> +### Steps + +Use the `<Steps>` 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 `<Steps>` around a standard Markdown ordered list. +All the usual Markdown syntax is applicable inside `<Steps>`. + +````mdx title="src/content/docs/example.mdx" +import { Steps } from '@astrojs/starlight/components'; + +<Steps> + +1. Import the component into your MDX file: + + ```js + import { Steps } from '@astrojs/starlight/components'; + ``` + +2. Wrap `<Steps>` around your ordered list items. + +</Steps> +```` + +The code above generates the following on the page: + +import { Steps } from '@astrojs/starlight/components'; + +<Steps> + +1. Import the component into your MDX file: + + ```js + import { Steps } from '@astrojs/starlight/components'; + ``` + +2. Wrap `<Steps>` around your ordered list items. + +</Steps> + ### 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 \`<Steps>\` component expects its content to be a single ordered list (\`<ol>\`) but found no child elements. + Hint: + To learn more about the \`<Steps>\` component, see https://starlight.astro.build/guides/components/#steps" + ` + ); +}); + +test('component with non-element content throws an error', () => { + expect(() => processSteps('<!-- comment -->Text node')).toThrowErrorMatchingInlineSnapshot( + ` + "[AstroUserError]: + The \`<Steps>\` component expects its content to be a single ordered list (\`<ol>\`) but found no child elements. + Hint: + To learn more about the \`<Steps>\` component, see https://starlight.astro.build/guides/components/#steps" + ` + ); +}); + +test('component with non-`<ol>` content throws an error', () => { + expect(() => processSteps('<p>A paragraph is not an ordered list</p>')) + .toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The \`<Steps>\` component expects its content to be a single ordered list (\`<ol>\`) but found the following element: \`<p>\`. + Hint: + To learn more about the \`<Steps>\` component, see https://starlight.astro.build/guides/components/#steps" + `); +}); + +test('component with multiple children throws an error', () => { + expect(() => processSteps('<ol></ol><ol></ol>')).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The \`<Steps>\` component expects its content to be a single ordered list (\`<ol>\`) but found multiple child elements: \`<ol>\`, \`<ol>\`. + Hint: + To learn more about the \`<Steps>\` component, see https://starlight.astro.build/guides/components/#steps" + `); +}); + +test('applies `role="list"` to child list', () => { + const { html } = processSteps('<ol><li>Step one</li></ol>'); + expect(html).toMatchInlineSnapshot(`"<ol role="list" class="sl-steps"><li>Step one</li></ol>"`); +}); + +test('does not interfere with other attributes on the child list', () => { + const { html } = processSteps('<ol start="5"><li>Step one</li></ol>'); + expect(html).toMatchInlineSnapshot( + `"<ol start="5" role="list" class="sl-steps"><li>Step one</li></ol>"` + ); +}); + +test('applies `class="sl-list"` to child list', () => { + const { html } = processSteps('<ol><li>Step one</li></ol>'); + 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(`<ol class="${testClass}"><li>Step one</li></ol>`); + expect(html).toContain(`class="${testClass} sl-steps"`); + expect(html).toMatchInlineSnapshot( + `"<ol class="test class-concat sl-steps" role="list"><li>Step one</li></ol>"` + ); +}); 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); +--- + +<Fragment set:html={html} /> + +<style is:global> + .sl-steps { + --bullet-size: calc(var(--sl-line-height) * 1rem); + --bullet-margin: 0.375rem; + + list-style: none; + padding-inline-start: 0; + } + + .sl-steps > li { + position: relative; + padding-inline-start: calc(var(--bullet-size) + 1rem); + /* HACK: Keeps any `margin-bottom` inside the `<li>`’s padding box to avoid gaps in the hairline border. */ + padding-bottom: 1px; + /* Prevent bullets from touching in short list items. */ + min-height: calc(var(--bullet-size) + var(--bullet-margin)); + } + .sl-steps > li + li { + /* Remove margin between steps. */ + margin-top: 0; + } + + /* Custom list marker element. */ + .sl-steps > li::before { + content: counter(list-item); + position: absolute; + top: 0; + inset-inline-start: 0; + width: var(--bullet-size); + height: var(--bullet-size); + line-height: var(--bullet-size); + + font-size: var(--sl-text-xs); + font-weight: 600; + text-align: center; + color: var(--sl-color-white); + background-color: var(--sl-color-gray-6); + border-radius: 99rem; + box-shadow: inset 0 0 0 1px var(--sl-color-gray-5); + } + + /* Vertical guideline linking list numbers. */ + .sl-steps > li:not(:last-of-type)::after { + --guide-width: 1px; + content: ''; + position: absolute; + top: calc(var(--bullet-size) + var(--bullet-margin)); + bottom: var(--bullet-margin); + inset-inline-start: calc((var(--bullet-size) - var(--guide-width)) / 2); + width: var(--guide-width); + background-color: var(--sl-color-hairline-light); + } + + /* Adjust first item inside a step so that it aligns vertically with the number + even if using a larger font size (e.g. a heading) */ + .sl-steps > li > :first-child { + /* + The `lh` unit is not yet supported by all browsers in our support matrix + — see https://caniuse.com/mdn-css_types_length_lh + In unsupported browsers we approximate this using our known line-heights. + */ + --lh: calc(1em * var(--sl-line-height)); + --shift-y: calc(0.5 * (var(--bullet-size) - var(--lh))); + transform: translateY(var(--shift-y)); + margin-bottom: var(--shift-y); + } + .sl-steps > li > :first-child:where(h1, h2, h3, h4, h5, h6) { + --lh: calc(1em * var(--sl-line-height-headings)); + } + @supports (--prop: 1lh) { + .sl-steps > li > :first-child { + --lh: 1lh; + } + } +</style> 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 `<Steps>` component expects its content to be a single ordered list (`<ol>`) but found no child elements.' + ); + } else if (rootElements.length > 1) { + throw new StepsError( + 'The `<Steps>` component expects its content to be a single ordered list (`<ol>`) but found multiple child elements: ' + + rootElements.map((element: Element) => `\`<${element.tagName}>\``).join(', ') + + '.' + ); + } else if (rootElement.tagName !== 'ol') { + throw new StepsError( + 'The `<Steps>` component expects its content to be a single ordered list (`<ol>`) 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 `<Steps>` 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 `<Steps>` component, see https://starlight.astro.build/guides/components/#steps' + ); + } +} |