summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Swithinbank2024-03-01 18:55:56 +0100
committerGitHub2024-03-01 18:55:56 +0100
commitd880065e29a632823a08adcb6158a59fd9557270 (patch)
tree3bab5b435c32042657014533ab16a96f4ae7279f
parent9a918a5b4902f43729f4d023257772710af3a12b (diff)
downloadIT.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.md5
-rw-r--r--docs/.pa11yci3
-rw-r--r--docs/src/content/docs/guides/components.mdx40
-rw-r--r--packages/starlight/__tests__/remark-rehype/rehype-steps.test.ts69
-rw-r--r--packages/starlight/components.ts1
-rw-r--r--packages/starlight/user-components/Steps.astro84
-rw-r--r--packages/starlight/user-components/rehype-steps.ts58
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'
+ );
+ }
+}