diff --git a/.cem.yaml b/.cem.yaml new file mode 100644 index 0000000000..f810522b67 --- /dev/null +++ b/.cem.yaml @@ -0,0 +1,8 @@ +sourceControlRootUrl: 'https://github.com/sl-design-system/components/tree/main' + +generate: + files: + - 'packages/components/*/src/**/*.ts' + exclude: + - 'packages/components/*/src/**/*.spec.ts' + - 'packages/components/*/src/**/*.stories.ts' diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index ba31c06a22..387975cb73 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - docs/website-v2 paths-ignore: - 'packages/tokens/src/**' types: [opened, synchronize, reopened, closed] @@ -11,6 +12,7 @@ on: push: branches: - main + - docs/website-v2 # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -50,7 +52,7 @@ jobs: - uses: actions/upload-pages-artifact@v5 with: - path: 'website/dist' + path: 'docs/website/dist' - id: deployment uses: actions/deploy-pages@v5 @@ -109,7 +111,7 @@ jobs: JOB_ID=$(echo "$DEPLOYMENT" | jq -r '.jobId') ZIP_URL=$(echo "$DEPLOYMENT" | jq -r '.zipUploadUrl') - cd website/dist && zip -qr ../../deployment.zip . && cd ../.. + cd docs/website/dist && zip -qr ../../../deployment.zip . && cd ../../.. curl -sSf -T deployment.zip "$ZIP_URL" aws amplify start-deployment \ diff --git a/chromatic/package.json b/chromatic/package.json index c2ae4d7deb..a4bc7885bd 100644 --- a/chromatic/package.json +++ b/chromatic/package.json @@ -5,7 +5,7 @@ "private": true, "repository": { "type": "git", - "url": "https://github.com/sl-design-system/components.git", + "url": "https://github.com/sl-design-system/components", "directory": "chromatic" }, "type": "module", diff --git a/docs/components/.storybook/main.ts b/docs/components/.storybook/main.ts new file mode 100644 index 0000000000..4ff5d24a21 --- /dev/null +++ b/docs/components/.storybook/main.ts @@ -0,0 +1,39 @@ +import { importCSSSheet } from '@roenlie/vite-plugin-import-css-sheet' +import { type StorybookConfig } from '@storybook/web-components-vite'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string) { + return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))) +} + +const config: StorybookConfig = { + stories: [ + '../src/**/*.stories.ts' + ], + addons: [ + getAbsolutePath('@storybook/addon-vitest'), + getAbsolutePath('@storybook/addon-a11y'), + getAbsolutePath('@storybook/addon-docs') + ], + core: { + disableTelemetry: true + }, + framework: getAbsolutePath('@storybook/web-components-vite'), + staticDirs: [ + { from: '../../../packages/themes', to: '/themes' }, + ], + async viteFinal(config) { + const { mergeConfig } = await import('vite'); + + return mergeConfig(config, { + plugins: [importCSSSheet()] + }); + } +}; + +export default config; \ No newline at end of file diff --git a/docs/components/.storybook/preview-head.html b/docs/components/.storybook/preview-head.html new file mode 100644 index 0000000000..28c7d9d61e --- /dev/null +++ b/docs/components/.storybook/preview-head.html @@ -0,0 +1,10 @@ + + + + diff --git a/docs/components/.storybook/preview.ts b/docs/components/.storybook/preview.ts new file mode 100644 index 0000000000..f7fe7a9fd9 --- /dev/null +++ b/docs/components/.storybook/preview.ts @@ -0,0 +1,25 @@ +import '@webcomponents/scoped-custom-element-registry/scoped-custom-element-registry.min.js'; +import { setup } from '@sl-design-system/sanoma-learning'; +import { type Preview } from '@storybook/web-components-vite'; + +setup(); + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i + } + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo' + } + } +}; + +export default preview; diff --git a/docs/components/.storybook/vitest.setup.ts b/docs/components/.storybook/vitest.setup.ts new file mode 100644 index 0000000000..2965ba897f --- /dev/null +++ b/docs/components/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; +import { setProjectAnnotations } from '@storybook/web-components-vite'; +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); \ No newline at end of file diff --git a/docs/components/env.d.ts b/docs/components/env.d.ts new file mode 100644 index 0000000000..bca8d01d81 --- /dev/null +++ b/docs/components/env.d.ts @@ -0,0 +1,4 @@ +declare module '*.css' { + const css: CSSStyleSheet; + export default css; +} diff --git a/docs/components/package.json b/docs/components/package.json new file mode 100644 index 0000000000..e45c4740ea --- /dev/null +++ b/docs/components/package.json @@ -0,0 +1,90 @@ +{ + "name": "@sl-design-system/doc-components", + "private": true, + "type": "module", + "version": "0.0.0", + "description": "Web components used in the documentation of the SL Design System", + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/sl-design-system/components" + }, + "exports": { + "./code-block/code-block.js": "./dist/code-block/code-block.js", + "./code-example/code-example.js": "./dist/code-example/code-example.js", + "./code/code.js": "./dist/code/code.js", + "./command-palette/command-palette.js": "./dist/command-palette/command-palette.js", + "./copy-button/copy-button.js": "./dist/copy-button/copy-button.js", + "./heading/heading.js": "./dist/heading/heading.js", + "./install-info/install-info.js": "./dist/install-info/install-info.js", + "./open-issue-count/open-issue-count.js": "./dist/open-issue-count/open-issue-count.js", + "./page-toc/page-toc.js": "./dist/page-toc/page-toc.js", + "./search/search.js": "./dist/search/search.js", + "./sidebar/sidebar.js": "./dist/sidebar/sidebar.js", + "./site-nav/nav-group.js": "./dist/site-nav/nav-group.js", + "./site-nav/nav-item.js": "./dist/site-nav/nav-item.js", + "./site-nav/site-nav.js": "./dist/site-nav/site-nav.js", + "./theme-switch/theme-switch.js": "./dist/theme-switch/theme-switch.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "wireit", + "start": "wireit", + "watch": "tsdown --watch" + }, + "wireit": { + "build": { + "command": "tsdown", + "dependencies": [ + "../..:build" + ] + }, + "start": { + "command": "storybook dev -p 6009 --no-open", + "service": { + "readyWhen": { + "lineMatches": "Storybook ready!" + } + }, + "files": [ + ".storybook/main.ts" + ] + } + }, + "dependencies": { + "@open-wc/scoped-elements": "^3.0.6", + "@sl-design-system/icon": "workspace:^", + "@sl-design-system/search-field": "workspace:^", + "@sl-design-system/switch": "workspace:^", + "minisearch": "^7.2.0", + "prismjs": "^1.30.0" + }, + "devDependencies": { + "@roenlie/vite-plugin-import-css-sheet": "^0.0.7", + "@storybook/addon-a11y": "^10.3.5", + "@storybook/addon-docs": "^10.3.5", + "@storybook/addon-vitest": "^10.3.5", + "@storybook/web-components": "^10.3.5", + "@storybook/web-components-vite": "^10.3.5", + "@tsdown/css": "^0.21.7", + "@types/prismjs": "^1.26.6", + "@typescript/native-preview": "^7.0.0-dev.20260413.1", + "lit": "^3.3.2", + "sass-embedded": "^1.99.0", + "storybook": "^10.3.5", + "tsdown": "^0.21.7", + "typescript": "^5.9.3", + "wireit": "^0.14.12" + }, + "peerDependencies": { + "lit": "^3.3.2" + }, + "inlinedDependencies": { + "@floating-ui/core": "1.7.5", + "@floating-ui/dom": "1.7.6", + "@floating-ui/utils": "0.2.11", + "@fortawesome/free-brands-svg-icons": "7.2.0", + "@fortawesome/pro-regular-svg-icons": "7.2.0", + "@fortawesome/pro-solid-svg-icons": "7.2.0" + } +} diff --git a/docs/components/src/code-block/code-block.css b/docs/components/src/code-block/code-block.css new file mode 100644 index 0000000000..72e1239705 --- /dev/null +++ b/docs/components/src/code-block/code-block.css @@ -0,0 +1,27 @@ +:host { + background: var(--sl-color-background-accent-grey-subtlest); + border-radius: var(--sl-size-borderRadius-default); + display: block; + overflow-x: auto; + padding: var(--sl-size-200); + position: relative; +} + +:host(:hover) doc-copy-button, +doc-copy-button:focus-within { + opacity: 1; +} + +::slotted(pre) { + background: transparent !important; + margin: 0 !important; + padding: 0 !important; +} + +doc-copy-button { + inset-block-start: var(--sl-size-100); + inset-inline-end: var(--sl-size-100); + opacity: 0; + position: absolute; + transition: opacity 0.15s; +} diff --git a/docs/components/src/code-block/code-block.spec.ts b/docs/components/src/code-block/code-block.spec.ts new file mode 100644 index 0000000000..ce326cd00a --- /dev/null +++ b/docs/components/src/code-block/code-block.spec.ts @@ -0,0 +1,70 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Code } from './code-block.js'; + +try { + customElements.define('doc-code-block', Code); +} catch { + /* empty */ +} + +describe('doc-code-block', () => { + let el: Code; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` + +
const foo = 'bar';
+
+ `); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(Code); + }); + + it('should render a default slot', () => { + const slot = el.renderRoot.querySelector('slot:not([name])'); + + expect(slot).to.exist; + expect(slot?.assignedElements()).to.have.length.greaterThan(0); + }); + + it('should render a copy button', () => { + expect(el.renderRoot.querySelector('doc-copy-button')).to.exist; + }); + + it('should set the copy button content from the slotted pre element', () => { + const copyButton = el.renderRoot.querySelector('doc-copy-button'); + + expect(copyButton?.content).to.equal("const foo = 'bar';"); + }); + + it('should copy the source to the clipboard on click', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + const copyButtonHost = el.renderRoot.querySelector('doc-copy-button')! as Element & { renderRoot: ShadowRoot }; + copyButtonHost.renderRoot.querySelector('sl-button')!.click(); + await el.updateComplete; + + expect(writeText).toHaveBeenCalledWith("const foo = 'bar';"); + + writeText.mockRestore(); + }); + }); + + describe('without pre element', () => { + beforeEach(async () => { + el = await fixture(html`some text`); + }); + + it('should render a copy button with no content', () => { + const copyButton = el.renderRoot.querySelector('doc-copy-button'); + + expect(copyButton?.content).to.equal(''); + }); + }); +}); diff --git a/docs/components/src/code-block/code-block.stories.ts b/docs/components/src/code-block/code-block.stories.ts new file mode 100644 index 0000000000..d4126646e7 --- /dev/null +++ b/docs/components/src/code-block/code-block.stories.ts @@ -0,0 +1,48 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { Code } from './code-block.js'; + +type Story = StoryObj; + +try { + customElements.define('doc-code-block', Code); +} catch { + /* empty */ +} + +export default { + title: 'Code', + render: () => html` + +
import { LitElement, html } from 'lit';
+
+export class MyElement extends LitElement {
+  override render() {
+    return html\`<p>Hello world!</p>\`;
+  }
+}
+
+ ` +} satisfies Meta; + +export const Basic: Story = {}; + +export const MultiLine: Story = { + render: () => html` + +
<sl-button variant="primary">Click me</sl-button>
+<sl-button variant="default">Cancel</sl-button>
+
+ ` +}; + +export const CSS: Story = { + render: () => html` + +
:host {
+  display: block;
+  color: red;
+}
+
+ ` +}; diff --git a/docs/components/src/code-block/code-block.ts b/docs/components/src/code-block/code-block.ts new file mode 100644 index 0000000000..509e470ba1 --- /dev/null +++ b/docs/components/src/code-block/code-block.ts @@ -0,0 +1,35 @@ +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { state } from 'lit/decorators.js'; +import { CopyButton } from '../copy-button/copy-button.js'; +import styles from './code-block.css' with { type: 'css' }; + +export class Code extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'doc-copy-button': CopyButton + }; + } + + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** @internal The source code to be copied to the clipboard. */ + @state() source?: string; + + override render(): TemplateResult { + return html` + + + `; + } + + #onSlotChange(event: Event & { target: HTMLSlotElement }): void { + const pre = event.target + .assignedElements({ flatten: true }) + .find((el): el is HTMLPreElement => el.tagName === 'PRE'); + + this.source = pre?.textContent?.trim() ?? ''; + } +} diff --git a/docs/components/src/code-example/code-example.css b/docs/components/src/code-example/code-example.css new file mode 100644 index 0000000000..f7eca56fbe --- /dev/null +++ b/docs/components/src/code-example/code-example.css @@ -0,0 +1,105 @@ +:host { + border: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + border-radius: var(--sl-size-050); + display: block; +} + +:host([inverted]) .demo { + background: var(--sl-color-foreground-accent-grey-bold); + color: var(--sl-color-foreground-inverted-bold); +} + +:host([justify='center']) { + --_justify: center; +} + +:host([justify='end']) { + --_justify: end; +} + +:host([justify='stretch']) { + --_justify: stretch; +} + +:host([show-source]) doc-code-block { + border-block-start: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); +} + +:host([orientation='horizontal']) .demo { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--sl-size-100); + justify-content: var(--_justify, start); +} + +:host([orientation='vertical']) .demo { + gap: var(--sl-size-200); +} + +::slotted(sl-menu) { + display: flex !important; + margin: 0 !important; + opacity: 1 !important; + position: static !important; +} + +.demo { + display: grid; + justify-items: var(--_justify, start); + padding: var(--sl-size-200); + position: relative; +} + +doc-code-block { + border-radius: 0; +} + +details { + border-block-start: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + display: flex; + flex-direction: column-reverse; +} + +summary { + align-items: center; + border-end-end-radius: var(--sl-size-borderRadius-default); + border-end-start-radius: var(--sl-size-borderRadius-default); + cursor: pointer; + display: flex; + font: inherit; + gap: var(--sl-size-050); + justify-content: center; + outline: transparent solid var(--sl-size-borderWidth-focusRing); + outline-offset: var(--sl-size-outlineOffset-default); + padding: var(--sl-size-100) var(--sl-size-200); + user-select: none; + + @media (prefers-reduced-motion: no-preference) { + transition: background 0.2s ease-in-out; + } + + &:focus-visible { + outline-color: var(--sl-color-border-focused); + } + + &:hover { + background: var(--sl-color-background-accent-grey-subtlest); + } + + &:active { + background: var(--sl-color-background-accent-grey-subtle); + } +} + +details[open] summary { + border-block-start: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + + sl-icon { + rotate: 180deg; + + @media (prefers-reduced-motion: no-preference) { + transition: rotate 0.2s ease-in-out; + } + } +} diff --git a/docs/components/src/code-example/code-example.spec.ts b/docs/components/src/code-example/code-example.spec.ts new file mode 100644 index 0000000000..bca9c3b7e3 --- /dev/null +++ b/docs/components/src/code-example/code-example.spec.ts @@ -0,0 +1,134 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CodeExample } from './code-example.js'; + +try { + customElements.define('doc-code-example', CodeExample); +} catch { + /* empty */ +} + +describe('doc-code-example', () => { + let el: CodeExample; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` + + +
<button>Click me</button>
+
+ `); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(CodeExample); + }); + + it('should render a demo area', () => { + expect(el.renderRoot.querySelector('.demo')).to.exist; + }); + + it('should render slotted content in the demo area', () => { + const slot = el.renderRoot.querySelector('.demo slot'); + + expect(slot).to.exist; + expect(slot?.assignedElements()).to.have.length.greaterThan(0); + }); + + it('should render a source area', () => { + expect(el.renderRoot.querySelector('.source')).to.exist; + }); + + it('should render the source slot', () => { + const slot = el.renderRoot.querySelector('slot[name="source"]'); + + expect(slot).to.exist; + expect(slot?.assignedElements()).to.have.length.greaterThan(0); + }); + + it('should render a copy button', () => { + const docCode = el.renderRoot.querySelector('doc-code-block') as Element & { renderRoot: ShadowRoot }; + + expect(docCode?.renderRoot.querySelector('doc-copy-button')).to.exist; + }); + + it('should set the copy button content from the source slot', async () => { + const docCode = el.renderRoot.querySelector('doc-code-block') as Element & { renderRoot: ShadowRoot }; + const copyButton = docCode?.renderRoot.querySelector('doc-copy-button'); + + expect(copyButton?.content).to.equal(''); + }); + + it('should copy the source to the clipboard on click', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + const docCode = el.renderRoot.querySelector('doc-code-block') as Element & { renderRoot: ShadowRoot }; + const copyButtonHost = docCode?.renderRoot.querySelector('doc-copy-button') as Element & { renderRoot: ShadowRoot }; + copyButtonHost?.renderRoot.querySelector('sl-button')!.click(); + await el.updateComplete; + + expect(writeText).toHaveBeenCalledWith(''); + + writeText.mockRestore(); + }); + }); + + describe('orientation=horizontal', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should reflect the orientation attribute on the element', () => { + expect(el).to.have.attribute('orientation', 'horizontal'); + }); + + it('should lay out the demo area horizontally', () => { + const demo = el.renderRoot.querySelector('.demo'); + + expect(demo).to.have.style('display', 'flex'); + }); + + it('should add a gap between slotted elements', () => { + const demo = el.renderRoot.querySelector('.demo'); + + expect(demo).to.have.style('gap', '16px'); + }); + }); + + describe('orientation=vertical', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should reflect the orientation attribute on the element', () => { + expect(el).to.have.attribute('orientation', 'vertical'); + }); + + it('should lay out the demo area vertically', () => { + const demo = el.renderRoot.querySelector('.demo'); + + expect(demo).to.have.style('display', 'grid'); + }); + + it('should add a gap between slotted elements', () => { + const demo = el.renderRoot.querySelector('.demo'); + + expect(demo).to.have.style('gap', '16px'); + }); + }); + + describe('justify', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should reflect the justify state on the element', async () => { + const demo = el.renderRoot.querySelector('.demo'); + + expect(demo).to.have.style('justify-items', 'center'); + }); + }); +}); diff --git a/docs/components/src/code-example/code-example.stories.ts b/docs/components/src/code-example/code-example.stories.ts new file mode 100644 index 0000000000..991d1c1c61 --- /dev/null +++ b/docs/components/src/code-example/code-example.stories.ts @@ -0,0 +1,78 @@ +import '@sl-design-system/button/register.js'; +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { CodeExample } from './code-example.js'; + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-code-example', CodeExample); +} catch { + /* empty */ +} + +export default { + title: 'Code Example', + argTypes: { + inverted: { + control: { type: 'boolean' } + }, + justify: { + control: { type: 'inline-radio' }, + options: ['start', 'center', 'end', 'stretch'] + }, + orientation: { + control: { type: 'inline-radio' }, + options: ['horizontal', 'vertical'] + }, + showSource: { + control: { type: 'boolean' } + } + }, + render: ({ inverted, justify, orientation, showSource }) => html` + + Click me + Another button +
<sl-button>Click me</sl-button>
+
+ ` +} satisfies Meta; + +export const Basic: Story = {}; + +export const OrientationHorizontal: Story = { + args: { + orientation: 'horizontal' + } +}; + +export const OrientationVertical: Story = { + args: { + orientation: 'vertical' + } +}; + +export const Inverted: Story = { + args: { + inverted: true + } +}; +export const JustifyCenter: Story = { + args: { + justify: 'center' + } +}; + +export const JustifyStretch: Story = { + args: { + justify: 'stretch' + } +}; + +export const ShowSource: Story = { + args: { + showSource: true + } +}; diff --git a/docs/components/src/code-example/code-example.ts b/docs/components/src/code-example/code-example.ts new file mode 100644 index 0000000000..3ee680dcf7 --- /dev/null +++ b/docs/components/src/code-example/code-example.ts @@ -0,0 +1,56 @@ +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { Code } from '../code-block/code-block.js'; +import styles from './code-example.css' with { type: 'css' }; + +export class CodeExample extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'doc-code-block': Code, + 'sl-icon': Icon + }; + } + + /** @internal */ + static styles: CSSResultGroup = styles; + + /** The orientation of the content within the demo area. */ + @property({ reflect: true }) orientation?: 'horizontal' | 'vertical'; + + /** Whether the demo background should use the inverted background color. */ + @property({ type: Boolean, reflect: true }) inverted?: boolean; + + /** The alignment of the content within the demo area. */ + @property({ reflect: true }) justify?: 'start' | 'center' | 'end' | 'stretch'; + + /** Whether to show the source code by default (without requiring the user to expand it). */ + @property({ type: Boolean, reflect: true, attribute: 'show-source' }) showSource?: boolean; + + override render(): TemplateResult { + const source = html` + + + + `; + + return html` +
+ +
+ ${this.showSource + ? source + : html` +
+ + Code + + + ${source} +
+ `} + `; + } +} diff --git a/docs/components/src/code/code.css b/docs/components/src/code/code.css new file mode 100644 index 0000000000..ec3455fa82 --- /dev/null +++ b/docs/components/src/code/code.css @@ -0,0 +1,24 @@ +:host { + display: inline; +} + +:host(:hover) doc-copy-button, +doc-copy-button:focus-within { + opacity: 1; +} + +code { + anchor-name: --code; + background: var(--sl-color-background-neutral-subtle); + border-radius: var(--sl-size-borderRadius-default); + padding-block: var(--sl-size-025); + padding-inline: var(--sl-size-075); +} + +doc-copy-button { + opacity: 0; + position: absolute; + position-anchor: --code; + position-area: center end; + transition: opacity 0.15s; +} diff --git a/docs/components/src/code/code.spec.ts b/docs/components/src/code/code.spec.ts new file mode 100644 index 0000000000..0c31b66bac --- /dev/null +++ b/docs/components/src/code/code.spec.ts @@ -0,0 +1,57 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Code } from './code.js'; + +try { + customElements.define('doc-code', Code); +} catch { + /* empty */ +} + +describe('doc-code', () => { + let el: Code; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html`:state(foo)`); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(Code); + }); + + it('should render a code element', () => { + expect(el.renderRoot.querySelector('code')).to.exist; + }); + + it('should render a default slot', () => { + const slot = el.renderRoot.querySelector('slot:not([name])'); + + expect(slot).to.exist; + }); + + it('should render a copy button', () => { + expect(el.renderRoot.querySelector('doc-copy-button')).to.exist; + }); + + it('should set the copy button content from the slotted text', () => { + const copyButton = el.renderRoot.querySelector('doc-copy-button'); + + expect(copyButton?.content).to.equal(':state(foo)'); + }); + + it('should copy the source to the clipboard on click', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + const copyButtonHost = el.renderRoot.querySelector('doc-copy-button')! as Element & { renderRoot: ShadowRoot }; + copyButtonHost.renderRoot.querySelector('sl-button')!.click(); + await el.updateComplete; + + expect(writeText).toHaveBeenCalledWith(':state(foo)'); + + writeText.mockRestore(); + }); + }); +}); diff --git a/docs/components/src/code/code.stories.ts b/docs/components/src/code/code.stories.ts new file mode 100644 index 0000000000..e1eebaa0d4 --- /dev/null +++ b/docs/components/src/code/code.stories.ts @@ -0,0 +1,22 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { Code } from './code.js'; + +type Story = StoryObj; + +try { + customElements.define('doc-code', Code); +} catch { + /* empty */ +} + +export default { + title: 'Inline Code', + render: () => html`

Use the :state(active) selector to target this state.

` +} satisfies Meta; + +export const Basic: Story = {}; + +export const Standalone: Story = { + render: () => html`const foo = 'bar';` +}; diff --git a/docs/components/src/code/code.ts b/docs/components/src/code/code.ts new file mode 100644 index 0000000000..10fa4d3ac8 --- /dev/null +++ b/docs/components/src/code/code.ts @@ -0,0 +1,31 @@ +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { state } from 'lit/decorators.js'; +import { CopyButton } from '../copy-button/copy-button.js'; +import styles from './code.css' with { type: 'css' }; + +export class Code extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'doc-copy-button': CopyButton + }; + } + + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** @internal The source code to be copied to the clipboard. */ + @state() source?: string; + + override render(): TemplateResult { + return html` + + + `; + } + + #onSlotChange(): void { + this.source = this.textContent?.trim() ?? ''; + } +} diff --git a/docs/components/src/command-palette/command-palette.css b/docs/components/src/command-palette/command-palette.css new file mode 100644 index 0000000000..45aaa2383f --- /dev/null +++ b/docs/components/src/command-palette/command-palette.css @@ -0,0 +1,154 @@ +/* stylelint-disable custom-property-pattern */ + +:host { + display: contents; +} + +dialog { + background: var(--sl-elevation-surface-raised-default); + border: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + border-radius: var(--sl-size-borderRadius-lg); + box-shadow: var(--sl-elevation-shadow-300, 0 16px 40px rgb(0 0 0 / 18%)); + flex-direction: column; + inline-size: min(580px, calc(100dvw - var(--sl-size-600))); + margin: 15vh auto auto; + max-block-size: 70dvh; + overflow: visible; + padding: 0; + + &[open] { + display: flex; + + &::backdrop { + background: var(--sl-color-blanket-plain); + opacity: 1; + + @starting-style { + opacity: 0; + } + } + } + + &::backdrop { + opacity: 0; + transition: opacity 0.2s ease-in-out; + transition-behavior: allow-discrete; + } +} + +.search { + align-items: center; + border-block-end: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + display: flex; + flex: none; + gap: var(--sl-size-150); + padding: var(--sl-size-200) var(--sl-size-250); + + sl-icon { + color: var(--sl-color-foreground-subtlest); + flex: none; + font-size: var(--sl-size-225, 18px); + } + + input { + appearance: none; + background: transparent; + border: 0; + color: var(--sl-color-foreground-plain); + flex: 1; + font: var(--sl-text-body-lg, inherit); + margin: 0; + min-inline-size: 0; + outline: none; + padding: 0; + + &::placeholder { + color: var(--sl-color-foreground-subtlest); + } + } +} + +.message { + color: var(--sl-color-foreground-subtle); + font: var(--sl-text-body-md); + margin: 0; + padding: var(--sl-size-300) var(--sl-size-250); + text-align: center; +} + +.results { + display: flex; + flex-direction: column; + gap: var(--sl-size-025, 2px); + list-style: none; + margin: 0; + overflow-y: auto; + padding: var(--sl-size-150); + + li { + align-items: center; + border-radius: var(--sl-size-borderRadius-default); + color: var(--sl-color-foreground-plain); + cursor: pointer; + display: flex; + font: var(--sl-text-body-md); + gap: var(--sl-size-150); + padding: var(--sl-size-150) var(--sl-size-200); + + &.active { + background: var( + --sl-color-background-input-interactive, + var(--sl-color-background-accent-blue-subtlest) + ); + } + + sl-icon { + color: var(--sl-color-foreground-subtle); + flex: none; + font-size: var(--sl-size-200); + } + + .label { + flex: 1; + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +footer { + align-items: center; + border-block-start: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + color: var(--sl-color-foreground-subtlest); + display: flex; + flex: none; + font: var(--sl-text-body-sm); + gap: var(--sl-size-250); + padding: var(--sl-size-150) var(--sl-size-250); + + span { + align-items: center; + display: inline-flex; + gap: var(--sl-size-075, 6px); + } + + kbd { + align-items: center; + background: var(--sl-elevation-surface-raised-sunken); + border: var(--sl-size-borderWidth-default) solid var(--sl-color-border-default); + border-radius: var(--sl-size-borderRadius-sm); + color: var(--sl-color-foreground-subtle); + display: inline-flex; + font: var(--sl-text-body-sm); + justify-content: center; + line-height: 1; + min-inline-size: var(--sl-size-225, 18px); + padding: var(--sl-size-050) var(--sl-size-075, 6px); + + sl-icon { + font-size: var(--sl-size-150); + } + } +} diff --git a/docs/components/src/command-palette/command-palette.spec.ts b/docs/components/src/command-palette/command-palette.spec.ts new file mode 100644 index 0000000000..7774fd9986 --- /dev/null +++ b/docs/components/src/command-palette/command-palette.spec.ts @@ -0,0 +1,199 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Command } from './command-palette.js'; + +// Use dynamic import from dist to avoid CSS module resolution issues in browser tests + +const { CommandPalette: CommandPaletteClass } = + await import('@sl-design-system/doc-components/command-palette/command-palette.js'); + +try { + customElements.define('doc-command-palette', CommandPaletteClass); +} catch { + /* empty */ +} + +const commands = [ + { id: 'home', label: 'Go to home', keywords: ['start'] }, + { id: 'components', label: 'Browse components' }, + { id: 'tokens', label: 'Design tokens' } +]; + +describe('doc-command-palette', () => { + let el: InstanceType; + + beforeEach(async () => { + el = await fixture(html``); + }); + + describe('dialog', () => { + it('should render a dialog', () => { + expect(el.renderRoot.querySelector('dialog')).to.exist; + }); + + it('should not be open by default', () => { + expect(el.renderRoot.querySelector('dialog')!.open).to.be.false; + }); + + it('should use a plain input instead of sl-search-field', () => { + expect(el.renderRoot.querySelector('input')).to.exist; + expect(el.renderRoot.querySelector('sl-search-field')).not.to.exist; + }); + + it('should render a legend with keyboard hints', () => { + const legend = el.renderRoot.querySelector('.legend'); + + expect(legend).to.exist; + expect(legend!.querySelectorAll('kbd').length).to.be.greaterThan(0); + }); + + it('should open when show() is called and focus the input', async () => { + el.show(); + + expect(el.dialog!.open).to.be.true; + + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.renderRoot.activeElement).to.equal(el.input); + }); + + it('should close when close() is called', () => { + el.show(); + expect(el.dialog!.open).to.be.true; + + el.close(); + + expect(el.dialog!.open).to.be.false; + }); + }); + + describe('commands', () => { + it('should render all commands by default', () => { + expect(el.renderRoot.querySelectorAll('.results li').length).to.equal(commands.length); + }); + + it('should highlight the first command when opened', () => { + el.show(); + + expect(el.activeIndex).to.equal(0); + }); + + it('should filter commands by the query', async () => { + el.query = 'browse'; + await el.updateComplete; + + const items = el.renderRoot.querySelectorAll('.results li'); + + expect(items.length).to.equal(1); + expect(items[0].textContent?.trim()).to.equal('Browse components'); + }); + + it('should match against keywords', async () => { + el.query = 'start'; + await el.updateComplete; + + const items = el.renderRoot.querySelectorAll('.results li'); + + expect(items.length).to.equal(1); + expect(items[0].textContent?.trim()).to.equal('Go to home'); + }); + + it('should show a message when there are no results', async () => { + el.query = 'nonexistent'; + await el.updateComplete; + + expect(el.renderRoot.querySelector('.message')).to.exist; + expect(el.renderRoot.querySelectorAll('.results li').length).to.equal(0); + }); + + it('should emit doc-command-select when a command is clicked', () => { + const onSelect = vi.fn<(event: CustomEvent) => void>(); + el.addEventListener('doc-command-select', onSelect); + + el.show(); + el.renderRoot.querySelector('.results li')!.click(); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect.mock.calls[0][0].detail.id).to.equal('home'); + }); + }); + + describe('keyboard', () => { + it('should open on Cmd+K', () => { + const showModalSpy = vi.spyOn(el.dialog!, 'showModal'); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true }) + ); + + expect(showModalSpy).toHaveBeenCalled(); + }); + + it('should open on Ctrl+K', () => { + const showModalSpy = vi.spyOn(el.dialog!, 'showModal'); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }) + ); + + expect(showModalSpy).toHaveBeenCalled(); + }); + + it('should move the active index with the arrow keys', async () => { + el.show(); + expect(el.activeIndex).to.equal(0); + + el.dialog!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + expect(el.activeIndex).to.equal(1); + + el.dialog!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await el.updateComplete; + expect(el.activeIndex).to.equal(0); + }); + + it('should wrap around when navigating past the ends', async () => { + el.show(); + + el.dialog!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await el.updateComplete; + + expect(el.activeIndex).to.equal(commands.length - 1); + }); + + it('should select the active command on Enter', () => { + const onSelect = vi.fn<(event: CustomEvent) => void>(); + el.addEventListener('doc-command-select', onSelect); + + el.show(); + el.dialog!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect.mock.calls[0][0].detail.id).to.equal('home'); + }); + + it('should close on Escape', () => { + el.show(); + expect(el.dialog!.open).to.be.true; + + el.dialog!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(el.dialog!.open).to.be.false; + }); + }); + + describe('cleanup', () => { + it('should remove the keydown listener on disconnect', () => { + const showModalSpy = vi.spyOn(el.dialog!, 'showModal'); + + el.remove(); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true }) + ); + + expect(showModalSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/docs/components/src/command-palette/command-palette.stories.ts b/docs/components/src/command-palette/command-palette.stories.ts new file mode 100644 index 0000000000..698c886457 --- /dev/null +++ b/docs/components/src/command-palette/command-palette.stories.ts @@ -0,0 +1,55 @@ +import { + faClockRotateLeft, + faCodeBranch, + faHouse, + faMoon, + faPalette, + faShapes +} from '@fortawesome/pro-regular-svg-icons'; +import { Icon } from '@sl-design-system/icon'; +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { type Command, CommandPalette } from './command-palette.js'; + +type Story = StoryObj; + +Icon.register(faClockRotateLeft, faCodeBranch, faHouse, faMoon, faPalette, faShapes); + +try { + customElements.define('doc-command-palette', CommandPalette); +} catch { + /* empty */ +} + +const commands: Command[] = [ + { id: 'home', label: 'Go to home', icon: 'far-house', keywords: ['start', 'index'] }, + { id: 'components', label: 'Browse components', icon: 'far-shapes' }, + { id: 'tokens', label: 'Design tokens', icon: 'far-palette' }, + { id: 'theme', label: 'Toggle theme', icon: 'far-moon', keywords: ['dark', 'light'] }, + { + id: 'github', + label: 'Open on GitHub', + icon: 'far-code-branch', + keywords: ['repository', 'source'] + }, + { + id: 'changelog', + label: 'View changelog', + icon: 'far-clock-rotate-left', + keywords: ['releases'] + } +]; + +export default { + title: 'Command palette', + render: () => html` + + + ` +} satisfies Meta; + +export const Basic: Story = {}; diff --git a/docs/components/src/command-palette/command-palette.ts b/docs/components/src/command-palette/command-palette.ts new file mode 100644 index 0000000000..62ac03853b --- /dev/null +++ b/docs/components/src/command-palette/command-palette.ts @@ -0,0 +1,256 @@ +import { faArrowTurnDownLeft, faMagnifyingGlass } from '@fortawesome/pro-regular-svg-icons'; +import { + type ScopedElementsMap, + ScopedElementsMixin +} from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import styles from './command-palette.css' with { type: 'css' }; + +Icon.register(faArrowTurnDownLeft, faMagnifyingGlass); + +/** A single command shown in the palette. */ +export interface Command { + /** Unique identifier, also used as the DOM id of the option. */ + id: string; + + /** The text shown for the command. */ + label: string; + + /** Optional icon name (e.g. `far-file-lines`) shown before the label. */ + icon?: string; + + /** Optional extra terms used when filtering, in addition to the label. */ + keywords?: string[]; + + /** Optional URL navigated to when the command is selected. */ + url?: string; +} + +/** Fired when a command is selected. */ +export type DocCommandSelectEvent = CustomEvent; + +/** + * A visually lightweight command palette modal. Type to filter a list of commands, navigate with + * the arrow keys and select with Enter. A legend with keyboard hints is shown at the bottom. + * + * @fires doc-command-select - Fired with the selected command. + */ +export class CommandPalette extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-icon': Icon + }; + } + + /** @internal */ + static styles: CSSResultGroup = styles; + + @query('dialog') dialog?: HTMLDialogElement; + + @query('input') input?: HTMLInputElement; + + /** The commands available in the palette. */ + @property({ type: Array }) commands: Command[] = []; + + /** Index of the currently highlighted command, or -1 if none. */ + @state() activeIndex = -1; + + /** The current query string. */ + @state() query = ''; + + override connectedCallback(): void { + super.connectedCallback(); + + document.addEventListener('keydown', this.#onKeydown); + } + + override disconnectedCallback(): void { + document.removeEventListener('keydown', this.#onKeydown); + + super.disconnectedCallback(); + } + + render(): TemplateResult { + const results = this.#filter(); + + return html` + + + + ${this.#renderResults(results)} + +
+ to navigate + + to select + + esc to close +
+
+ `; + } + + #renderResults(results: Command[]): TemplateResult { + if (results.length === 0) { + return html` +

No results found${this.query ? html`for “${this.query}”` : nothing}.

+ `; + } + + return html` +
    + ${results.map( + (command, index) => html` +
  • this.#select(command)} + @pointermove=${() => (this.activeIndex = index)}> + ${command.icon ? html`` : nothing} + ${command.label} +
  • + ` + )} +
+ `; + } + + /** Opens the palette and focuses the input. */ + show(): void { + this.dialog?.showModal(); + this.query = ''; + this.activeIndex = this.commands.length > 0 ? 0 : -1; + + requestAnimationFrame(() => this.input?.focus()); + } + + /** Closes the palette. */ + close(): void { + this.dialog?.close(); + } + + #onBackdropClick(event: MouseEvent): void { + const dialog = this.dialog; + + if (!dialog || dialog !== event.composedPath()[0]) { + return; + } + + const rect = dialog.getBoundingClientRect(); + + if ( + event.clientY < rect.top || + event.clientY > rect.bottom || + event.clientX < rect.left || + event.clientX > rect.right + ) { + dialog.close(); + } + } + + #onDialogKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + this.close(); + return; + } + + const results = this.#filter(); + + if (results.length === 0) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.activeIndex = this.activeIndex < results.length - 1 ? this.activeIndex + 1 : 0; + this.#scrollActiveIntoView(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.activeIndex = this.activeIndex > 0 ? this.activeIndex - 1 : results.length - 1; + this.#scrollActiveIntoView(); + } else if (event.key === 'Enter' && this.activeIndex >= 0) { + event.preventDefault(); + + const command = results[this.activeIndex]; + if (command) { + this.#select(command); + } + } + } + + #onInput(event: Event): void { + this.query = (event.target as HTMLInputElement).value; + this.activeIndex = this.#filter().length > 0 ? 0 : -1; + } + + #onKeydown = (event: KeyboardEvent): void => { + if (event.key === 'k' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.show(); + } + }; + + /** Filters the commands by the current query. */ + #filter(): Command[] { + const query = this.query.trim().toLowerCase(); + + if (!query) { + return this.commands; + } + + return this.commands.filter(command => { + const haystack = [command.label, ...(command.keywords ?? [])].join(' ').toLowerCase(); + + return haystack.includes(query); + }); + } + + #select(command: Command): void { + this.close(); + + this.dispatchEvent( + new CustomEvent('doc-command-select', { + bubbles: true, + composed: true, + detail: command + }) + ); + + if (command.url) { + window.location.href = command.url; + } + } + + #scrollActiveIntoView(): void { + const items = this.renderRoot.querySelectorAll('.results li'); + + items[this.activeIndex]?.scrollIntoView({ block: 'nearest' }); + } +} diff --git a/docs/components/src/copy-button/copy-button.css b/docs/components/src/copy-button/copy-button.css new file mode 100644 index 0000000000..e7ddcab66a --- /dev/null +++ b/docs/components/src/copy-button/copy-button.css @@ -0,0 +1,3 @@ +:host { + cursor: copy; +} diff --git a/docs/components/src/copy-button/copy-button.spec.ts b/docs/components/src/copy-button/copy-button.spec.ts new file mode 100644 index 0000000000..d64f4592a9 --- /dev/null +++ b/docs/components/src/copy-button/copy-button.spec.ts @@ -0,0 +1,135 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CopyButton } from './copy-button.js'; + +try { + customElements.define('doc-copy-button', CopyButton); +} catch { + /* empty */ +} + +describe('doc-copy-button', () => { + let el: CopyButton; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(CopyButton); + }); + + it('should have a copy icon', () => { + const icon = el.renderRoot.querySelector('sl-icon'); + + expect(icon).to.exist; + expect(icon).to.have.attribute('name', 'far-copy'); + }); + + it('should have ghost fill by default', () => { + expect(el.fill).to.equal('ghost'); + }); + + it('should have the button role', () => { + expect(el).to.have.attribute('role', 'button'); + }); + + it('should have cursor copy style', () => { + expect(getComputedStyle(el).cursor).to.equal('copy'); + }); + + it('should not have a target by default', () => { + expect(el.target).to.be.undefined; + }); + }); + + describe('target', () => { + beforeEach(async () => { + // Create a target element in the document + const target = document.createElement('div'); + target.id = 'copy-target'; + target.textContent = 'Text to copy'; + document.body.appendChild(target); + + el = await fixture(html``); + }); + + afterEach(() => { + document.getElementById('copy-target')?.remove(); + }); + + it('should have the target property set', () => { + expect(el.target).to.equal('copy-target'); + }); + + it('should copy the target text to the clipboard on click', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + el.click(); + await el.updateComplete; + + expect(writeText).toHaveBeenCalledWith('Text to copy'); + + writeText.mockRestore(); + }); + + it('should trim the target text before copying', async () => { + const target = document.getElementById('copy-target')!; + target.textContent = ' padded text '; + + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + el.click(); + await el.updateComplete; + + expect(writeText).toHaveBeenCalledWith('padded text'); + + writeText.mockRestore(); + }); + }); + + describe('no target', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should not attempt to copy when no target is set', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + el.click(); + await el.updateComplete; + + expect(writeText).not.toHaveBeenCalled(); + + writeText.mockRestore(); + }); + }); + + describe('nonexistent target', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should not attempt to copy when the target element does not exist', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + el.click(); + await el.updateComplete; + + expect(writeText).not.toHaveBeenCalled(); + + writeText.mockRestore(); + }); + }); + + describe('fill override', () => { + it('should allow overriding the fill', async () => { + el = await fixture(html``); + + expect(el.fill).to.equal('outline'); + }); + }); +}); diff --git a/docs/components/src/copy-button/copy-button.stories.ts b/docs/components/src/copy-button/copy-button.stories.ts new file mode 100644 index 0000000000..10acfb0a5a --- /dev/null +++ b/docs/components/src/copy-button/copy-button.stories.ts @@ -0,0 +1,50 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { CopyButton } from './copy-button.js'; + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-copy-button', CopyButton); +} catch { + /* empty */ +} + +export default { + title: 'Copy Button', + args: { + target: 'copy-target' + }, + argTypes: { + target: { + control: 'text' + }, + fill: { + control: 'select', + options: ['solid', 'outline', 'link', 'ghost'] + } + }, + render: ({ target, fill }) => html` +

This text will be copied to the clipboard.

+ + ` +} satisfies Meta; + +export const Basic: Story = {}; + +export const Outline: Story = { + args: { + fill: 'outline' + } +}; + +export const WithDifferentTarget: Story = { + render: ({ target }) => html` +
const foo = 'bar';
+ + `, + args: { + target: 'code-snippet' + } +}; diff --git a/docs/components/src/copy-button/copy-button.ts b/docs/components/src/copy-button/copy-button.ts new file mode 100644 index 0000000000..fd1967e71e --- /dev/null +++ b/docs/components/src/copy-button/copy-button.ts @@ -0,0 +1,98 @@ +import { faCopy } from '@fortawesome/pro-regular-svg-icons'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Button } from '@sl-design-system/button'; +import { Icon } from '@sl-design-system/icon'; +import { Tooltip } from '@sl-design-system/tooltip'; +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import styles from './copy-button.css' with { type: 'css' }; + +Icon.register(faCopy); + +export class CopyButton extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-button': Button, + 'sl-icon': Icon, + 'sl-tooltip': Tooltip + }; + } + + /** @internal */ + static override styles: CSSResultGroup = [Button.styles, styles]; + + /** The ID of the timeout used to hide the tooltip. */ + #setTimeoutId?: number; + + /** @internal */ + @state() copyText = 'Copy'; + + /** The content to be copied to the clipboard. */ + @property() content?: string; + + /** The fill style of the button. */ + @property() fill = 'ghost'; + + /** The DOM id of the element whose text content should be copied. Only used when `content` is not set. */ + @property() target?: string; + + /** @internal The tooltip element. */ + @query('sl-tooltip') tooltip!: Tooltip; + + override connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'button'); + this.addEventListener('click', this.#onClick); + } + + override disconnectedCallback(): void { + this.removeEventListener('click', this.#onClick); + + if (this.#setTimeoutId) { + clearTimeout(this.#setTimeoutId); + this.#setTimeoutId = undefined; + } + + super.disconnectedCallback(); + } + + override render(): TemplateResult { + return html` + + + + ${this.copyText} + `; + } + + #onClick = async () => { + let text: string | undefined; + + if (this.content) { + text = this.content; + } else if (this.target) { + text = document.getElementById(this.target)?.textContent?.trim(); + } + + if (!text) { + return; + } + + await navigator.clipboard.writeText(text); + + this.copyText = 'Copied!'; + await this.updateComplete; + this.tooltip.showPopover(); + + if (this.#setTimeoutId) { + clearTimeout(this.#setTimeoutId); + } + + this.#setTimeoutId = window.setTimeout(() => { + this.tooltip.hidePopover(); + this.copyText = 'Copy'; + this.#setTimeoutId = undefined; + }, 2000); + }; +} diff --git a/docs/components/src/heading/heading.css b/docs/components/src/heading/heading.css new file mode 100644 index 0000000000..93068103b2 --- /dev/null +++ b/docs/components/src/heading/heading.css @@ -0,0 +1,55 @@ +:host { + align-items: center; + color: var(--sl-color-foreground-plain); + display: flex; + gap: 0.5rem; +} + +:host(:hover) doc-copy-button, +:host(:focus-within) doc-copy-button { + opacity: 1; +} + +:host([level='2']) { + margin-block: var(--sl-size-400) var(--sl-size-150); +} + +:host([level='3']) { + margin-block: var(--sl-size-300) var(--sl-size-100); +} + +::slotted(a) { + border-radius: var(--sl-size-borderRadius-default); + color: inherit !important; + outline: transparent solid var(--sl-size-borderWidth-focusRing); + outline-offset: var(--sl-size-outlineOffset-default); + text-decoration: none !important; +} + +::slotted(a:focus-visible) { + outline-color: var(--sl-color-border-focused); +} + +h2, +h3 { + font-family: the-message, sans-serif; + margin: 0; +} + +h2 { + font-size: 32px; + line-height: 36px; +} + +h3 { + font-size: 24px; + line-height: 28px; +} + +doc-copy-button { + opacity: 0; + + @media (prefers-reduced-motion: no-preference) { + transition: opacity 0.2s ease; + } +} diff --git a/docs/components/src/heading/heading.spec.ts b/docs/components/src/heading/heading.spec.ts new file mode 100644 index 0000000000..ad0955c87b --- /dev/null +++ b/docs/components/src/heading/heading.spec.ts @@ -0,0 +1,82 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Heading } from './heading.js'; + +try { + customElements.define('doc-heading', Heading); +} catch { + /* empty */ +} + +describe('doc-heading', () => { + let el: Heading; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html`My Heading`); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(Heading); + }); + + it('should have a heading role', () => { + expect(el).to.have.attribute('role', 'heading'); + }); + + it('should have aria-level 2 by default', () => { + expect(el).to.have.attribute('aria-level', '2'); + }); + + it('should render an h2 element by default', () => { + expect(el.renderRoot.querySelector('h2')).to.exist; + expect(el.renderRoot.querySelector('h3')).not.to.exist; + }); + + it('should have level 2 by default', () => { + expect(el.level).to.equal(2); + }); + + it('should not render a copy button when there is no id', () => { + expect(el.renderRoot.querySelector('doc-copy-button')).not.to.exist; + }); + }); + + describe('level 3', () => { + beforeEach(async () => { + el = await fixture(html`Sub Heading`); + }); + + it('should render an h3 element', () => { + expect(el.renderRoot.querySelector('h3')).to.exist; + expect(el.renderRoot.querySelector('h2')).not.to.exist; + }); + + it('should have aria-level 3', () => { + expect(el).to.have.attribute('aria-level', '3'); + }); + }); + + describe('copy button', () => { + beforeEach(async () => { + el = await fixture(html`My Section`); + }); + + it('should render a copy button when heading has an id', () => { + expect(el.renderRoot.querySelector('doc-copy-button')).to.exist; + }); + + it('should copy the URL with hash to the clipboard on click', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + el.renderRoot.querySelector('doc-copy-button')!.click(); + await el.updateComplete; + + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('#my-section')); + + writeText.mockRestore(); + }); + }); +}); diff --git a/docs/components/src/heading/heading.stories.ts b/docs/components/src/heading/heading.stories.ts new file mode 100644 index 0000000000..be05f4a62f --- /dev/null +++ b/docs/components/src/heading/heading.stories.ts @@ -0,0 +1,42 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { Heading } from './heading.js'; + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-heading', Heading); +} catch { + /* empty */ +} + +export default { + title: 'Heading', + args: { + level: 2 + }, + argTypes: { + level: { + control: 'inline-radio', + options: [2, 3] + } + }, + render: ({ level }) => html` + Section Title + ` +} satisfies Meta; + +export const Basic: Story = {}; + +export const Level3: Story = { + args: { + level: 3 + } +}; + +export const WithoutId: Story = { + render: ({ level }) => html` + No copy button without an id + ` +}; diff --git a/docs/components/src/heading/heading.ts b/docs/components/src/heading/heading.ts new file mode 100644 index 0000000000..b7a7c38991 --- /dev/null +++ b/docs/components/src/heading/heading.ts @@ -0,0 +1,42 @@ +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { CopyButton } from '../copy-button/copy-button.js'; +import styles from './heading.css' with { type: 'css' }; + +export class Heading extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'doc-copy-button': CopyButton + }; + } + + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** The heading level to render (2 or 3). */ + @property({ type: Number }) level: 2 | 3 = 2; + + /** @internal The URL to be copied to the clipboard. */ + @state() url?: string; + + override render(): TemplateResult { + return html` + ${this.level === 2 + ? html`

` + : html`

`} + + `; + } + + #onSlotChange(event: Event & { target: HTMLSlotElement }): void { + const anchor = event.target + .assignedElements({ flatten: true }) + .find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement && el.hasAttribute('href')); + + if (anchor?.href) { + this.url = anchor.href; + } + } +} diff --git a/docs/components/src/install-info/install-info.css b/docs/components/src/install-info/install-info.css new file mode 100644 index 0000000000..e83f0ad160 --- /dev/null +++ b/docs/components/src/install-info/install-info.css @@ -0,0 +1,47 @@ +:host { + display: block; +} + +.panel { + align-items: center; + background: var(--sl-elevation-surface-raised-sunken); + border: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + border-radius: var(--sl-size-borderRadius-lg); + display: flex; + gap: var(--sl-size-100); + padding: var(--sl-size-150) var(--sl-size-200); +} + +code { + flex: 1; + font-family: + ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', + 'Source Code Pro', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; + white-space: pre; +} + +.command { + color: var(--sl-color-foreground-accent-teal-plain); +} + +.scope { + color: var(--sl-color-foreground-accent-grey-subtlest); +} + +.package { + color: var(--sl-color-foreground-accent-blue-plain); +} + +.copy { + background: none; + border: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + border-radius: var(--sl-size-borderRadius-default); + color: var(--sl-color-foreground-plain); + cursor: pointer; + font: var(--sl-text-new-body-sm); + padding: var(--sl-size-025) var(--sl-size-100); + + &:hover { + background: var(--sl-elevation-surface-raised-alternative); + } +} diff --git a/docs/components/src/install-info/install-info.spec.ts b/docs/components/src/install-info/install-info.spec.ts new file mode 100644 index 0000000000..244f1aebc2 --- /dev/null +++ b/docs/components/src/install-info/install-info.spec.ts @@ -0,0 +1,106 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Use dynamic import from dist to avoid CSS module resolution issues in browser tests +const { InstallInfo: InstallInfoClass } = await import('@sl-design-system/doc-components/install-info/install-info'); + +try { + customElements.define('doc-install-info', InstallInfoClass); +} catch { + /* empty */ +} + +describe('doc-install-info', () => { + let el: InstanceType; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(InstallInfoClass); + }); + + it('should have a panel', () => { + const panel = el.renderRoot.querySelector('.panel'); + + expect(panel).to.exist; + }); + + it('should show the command', () => { + const command = el.renderRoot.querySelector('.command'); + + expect(command).to.have.trimmed.text('npm install'); + }); + + it('should show the scope', () => { + const scope = el.renderRoot.querySelector('.scope'); + + expect(scope).to.have.trimmed.text('@sl-design-system/'); + }); + + it('should show the package name', () => { + const pkg = el.renderRoot.querySelector('.package'); + + expect(pkg).to.have.trimmed.text('button'); + }); + }); + + describe('syntax highlighting', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have a command span', () => { + const command = el.renderRoot.querySelector('.command'); + + expect(command).to.exist; + }); + + it('should have a scope span', () => { + const scope = el.renderRoot.querySelector('.scope'); + + expect(scope).to.exist; + }); + + it('should have a package span', () => { + const pkg = el.renderRoot.querySelector('.package'); + + expect(pkg).to.have.trimmed.text('text-field'); + }); + }); + + describe('copy button', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have a copy button', () => { + const copyBtn = el.renderRoot.querySelector('.copy'); + + expect(copyBtn).to.exist; + }); + + it('should have an aria-label on the copy button', () => { + const copyBtn = el.renderRoot.querySelector('.copy'); + + expect(copyBtn).to.have.attribute('aria-label', 'Copy npm install @sl-design-system/button'); + }); + + it('should copy the command to clipboard when clicked', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + const copyBtn = el.renderRoot.querySelector('.copy')!; + copyBtn.click(); + + await el.updateComplete; + + expect(writeText).toHaveBeenCalledWith('npm install @sl-design-system/button'); + + writeText.mockRestore(); + }); + }); +}); diff --git a/docs/components/src/install-info/install-info.stories.ts b/docs/components/src/install-info/install-info.stories.ts new file mode 100644 index 0000000000..7054d7ad1a --- /dev/null +++ b/docs/components/src/install-info/install-info.stories.ts @@ -0,0 +1,33 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { InstallInfo } from './install-info.js'; + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-install-info', InstallInfo); +} catch { + /* empty */ +} + +export default { + title: 'Install Info', + args: { + package: 'button' + }, + argTypes: { + package: { + control: 'text' + } + }, + render: ({ package: pkg }) => html`` +} satisfies Meta; + +export const Basic: Story = {}; + +export const DifferentPackage: Story = { + args: { + package: 'text-field' + } +}; diff --git a/docs/components/src/install-info/install-info.ts b/docs/components/src/install-info/install-info.ts new file mode 100644 index 0000000000..5daa53ba51 --- /dev/null +++ b/docs/components/src/install-info/install-info.ts @@ -0,0 +1,31 @@ +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import styles from './install-info.css' with { type: 'css' }; + +export class InstallInfo extends LitElement { + /** @internal */ + static styles: CSSResultGroup = styles; + + /** The package name (without the @sl-design-system/ prefix). */ + @property() package?: string; + + override render(): TemplateResult { + const command = `npm install @sl-design-system/${this.package}`; + + return html` +
+ npm install @sl-design-system/${this.package} + +
+ `; + } + + async #copy(text: string): Promise { + await navigator.clipboard.writeText(text); + } +} diff --git a/docs/components/src/open-issue-count/open-issue-count.spec.ts b/docs/components/src/open-issue-count/open-issue-count.spec.ts new file mode 100644 index 0000000000..96b6a8fe1c --- /dev/null +++ b/docs/components/src/open-issue-count/open-issue-count.spec.ts @@ -0,0 +1,129 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { OpenIssueCount: OpenIssueCountClass } = await import( + '@sl-design-system/doc-components/open-issue-count/open-issue-count' +); + +try { + customElements.define('doc-open-issue-count', OpenIssueCountClass); +} catch { + /* empty */ +} + +const mockSubIssues = (issues: Array<{ state: string }>): void => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(issues), { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); +}; + +describe('doc-open-issue-count', () => { + let el: InstanceType; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(OpenIssueCountClass); + }); + + it('should render nothing when no issue is set', () => { + const count = el.renderRoot.querySelector('.count'); + expect(count).to.not.exist; + }); + }); + + describe('with issue number', () => { + beforeEach(async () => { + mockSubIssues([{ state: 'open' }, { state: 'open' }, { state: 'closed' }]); + el = await fixture(html``); + }); + + it('should render the count of open sub-issues', () => { + const count = el.renderRoot.querySelector('.count'); + expect(count).to.exist; + expect(count).to.have.trimmed.text('2'); + }); + + it('should fetch sub-issues from the GitHub API', () => { + expect(fetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/sl-design-system/components/issues/42/sub_issues', + expect.objectContaining({ headers: expect.objectContaining({ Accept: 'application/vnd.github+json' }) }) + ); + }); + }); + + describe('with all issues closed', () => { + beforeEach(async () => { + mockSubIssues([{ state: 'closed' }, { state: 'closed' }]); + el = await fixture(html``); + }); + + it('should render 0 open sub-issues', () => { + const count = el.renderRoot.querySelector('.count'); + expect(count).to.exist; + expect(count).to.have.trimmed.text('0'); + }); + }); + + describe('with no sub-issues', () => { + beforeEach(async () => { + mockSubIssues([]); + el = await fixture(html``); + }); + + it('should render 0', () => { + const count = el.renderRoot.querySelector('.count'); + expect(count).to.exist; + expect(count).to.have.trimmed.text('0'); + }); + }); + + describe('on API error', () => { + beforeEach(async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 404 })); + el = await fixture(html``); + }); + + it('should render nothing on error', () => { + const count = el.renderRoot.querySelector('.count'); + expect(count).to.not.exist; + }); + }); + + describe('on network failure', () => { + beforeEach(async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); + el = await fixture(html``); + }); + + it('should render nothing on network failure', () => { + const count = el.renderRoot.querySelector('.count'); + expect(count).to.not.exist; + }); + }); + + describe('issue change', () => { + beforeEach(async () => { + mockSubIssues([{ state: 'open' }]); + el = await fixture(html``); + }); + + it('should re-fetch when the issue number changes', async () => { + mockSubIssues([{ state: 'open' }, { state: 'open' }, { state: 'open' }]); + el.issue = 2; + await el.updateComplete; + + const count = el.renderRoot.querySelector('.count'); + expect(count).to.have.trimmed.text('3'); + }); + }); +}); diff --git a/docs/components/src/open-issue-count/open-issue-count.stories.ts b/docs/components/src/open-issue-count/open-issue-count.stories.ts new file mode 100644 index 0000000000..191ddd42c5 --- /dev/null +++ b/docs/components/src/open-issue-count/open-issue-count.stories.ts @@ -0,0 +1,33 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { OpenIssueCount } from './open-issue-count.js'; + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-open-issue-count', OpenIssueCount); +} catch { + /* empty */ +} + +export default { + title: 'Open Issue Count', + args: { + issue: 1 + }, + argTypes: { + issue: { + control: 'number' + } + }, + render: ({ issue }) => html`` +} satisfies Meta; + +export const Basic: Story = {}; + +export const NoIssue: Story = { + args: { + issue: undefined + } +}; diff --git a/docs/components/src/open-issue-count/open-issue-count.ts b/docs/components/src/open-issue-count/open-issue-count.ts new file mode 100644 index 0000000000..82e1cb9f53 --- /dev/null +++ b/docs/components/src/open-issue-count/open-issue-count.ts @@ -0,0 +1,44 @@ +import { LitElement, type TemplateResult, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +export class OpenIssueCount extends LitElement { + /** The GitHub issue number to look up sub-issues for. */ + @property({ type: Number }) issue?: number; + + /** @internal The number of open sub-issues, once fetched. */ + @state() count?: number; + + override updated(changes: Map): void { + super.updated(changes); + + if (changes.has('issue')) { + void this.#fetchCount(); + } + } + + override render(): TemplateResult | typeof nothing { + return html`${this.count}` ?? nothing; + } + + async #fetchCount(): Promise { + if (this.issue === undefined) { + this.count = undefined; + return; + } + + try { + const response = await fetch( + `https://api.github.com/repos/sl-design-system/components/issues/${this.issue}/sub_issues`, + { headers: { Accept: 'application/vnd.github+json' } } + ); + + if (response.ok) { + const subIssues = (await response.json()) as Array<{ state: string }>; + + this.count = subIssues.filter(i => i.state === 'open').length; + } + } catch { + // Render nothing on failure + } + } +} diff --git a/docs/components/src/page-toc/page-toc.css b/docs/components/src/page-toc/page-toc.css new file mode 100644 index 0000000000..5d7b0c200d --- /dev/null +++ b/docs/components/src/page-toc/page-toc.css @@ -0,0 +1,77 @@ +:host { + display: block; + padding-inline: var(--sl-size-200); +} + +nav { + display: flex; + flex-direction: column; + + /* Make sure the active indicator is positioned correctly on initial load. */ + &:has(:not([aria-current])) > ul:first-of-type > li:first-of-type > a { + anchor-name: --active-link; + } +} + +h2 { + align-items: center; + display: flex; + font: inherit; + gap: var(--sl-size-100); + margin-block-end: var(--sl-size-100); + + ~ ul { + border-inline-start: var(--sl-size-borderWidth-default) solid var(--sl-color-border-bold); + margin-inline-start: 6px; + } +} + +.active { + background: var(--sl-elevation-surface-base-default); + inline-size: calc(var(--sl-size-075) + var(--sl-size-010)); + inset-block: anchor(top) anchor(bottom); + inset-inline-start: 19px; + position: absolute; + position-anchor: --active-link; + transition: inset 0.2s ease-in-out; + z-index: 1; + + &::before { + background: var(--sl-color-background-selected-bold); + block-size: var(--sl-size-300); + border-radius: var(--sl-size-150); + content: ''; + inset: 50% var(--sl-size-010) auto; + margin-block-start: calc(var(--sl-size-150) / -1); + position: absolute; + z-index: 1; + } +} + +ul { + display: grid; + list-style: none; + margin: 0; + padding: 0; +} + +ul ul a { + padding-inline-start: var(--sl-size-400); +} + +a { + color: var(--sl-color-text-plain); + display: block; + padding-block: var(--sl-size-100); + padding-inline-start: var(--sl-size-200); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &[aria-current] { + anchor-name: --active-link; + color: var(--sl-color-foreground-primary-bold); + } +} diff --git a/docs/components/src/page-toc/page-toc.stories.ts b/docs/components/src/page-toc/page-toc.stories.ts new file mode 100644 index 0000000000..2cba885a91 --- /dev/null +++ b/docs/components/src/page-toc/page-toc.stories.ts @@ -0,0 +1,141 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { PageToc } from './page-toc.js'; + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-page-toc', PageToc); +} catch { + /* empty */ +} + +export default { + title: 'Page Table of Contents', + parameters: { + layout: 'fullscreen' + }, + args: { + target: '.page' + }, + render: ({ target }) => { + return html` + +
+
+

Button

+

Overview

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat + nulla pariatur. +

+

+ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium. +

+

Usage

+

+ Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni + dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor + sit amet. +

+

When to use

+

+ Consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam + aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit + laboriosam, nisi ut aliquid ex ea commodi consequatur. +

+

+ Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel + illum qui dolorem eum fugiat quo voluptas nulla pariatur. +

+

Variants

+

+ At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti + atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident. +

+

Primary

+

+ Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem + rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque + nihil impedit quo minus id quod maxime placeat facere possimus. +

+

+ Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates + repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut + reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. +

+

Properties

+

+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem + aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. + Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit. +

+

+ Sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui + dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora + incidunt ut labore et dolore magnam aliquam quaerat voluptatem. +

+

Size

+

+ Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex + ea commodi consequatur. Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil + molestiae consequatur. +

+

Accessibility

+

+ Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni + dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor + sit amet, consectetur, adipisci velit. +

+

Keyboard interaction and focus management

+

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum. +

+

WAI-ARIA

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + ea commodo consequat. +

+ +

+ At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti + atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique + sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. +

+

+ Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi + optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, + omnis dolor repellendus. +

+
+ +
+ `; + } +} satisfies Meta; + +export const Basic: Story = {}; diff --git a/docs/components/src/page-toc/page-toc.ts b/docs/components/src/page-toc/page-toc.ts new file mode 100644 index 0000000000..ef43600c15 --- /dev/null +++ b/docs/components/src/page-toc/page-toc.ts @@ -0,0 +1,156 @@ +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import styles from './page-toc.css' with { type: 'css' }; + +interface TocEntry { + id: string; + text: string; + children: TocEntry[]; +} + +export class PageToc extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-icon': Icon + }; + } + + /** @internal */ + static styles: CSSResultGroup = styles; + + /** All observed headings in document order. */ + #headings: Element[] = []; + + /** Update the active heading when scrolling. */ + #observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + this.#visibleIds.add(entry.target.id); + } else { + this.#visibleIds.delete(entry.target.id); + } + } + + // Always pick the first visible heading in document order + const firstVisible = this.#headings.find(h => this.#visibleIds.has(h.id)); + if (firstVisible) { + this.activeId = firstVisible.id; + } + }, + { rootMargin: '0px 0px -60% 0px', threshold: 0 } + ); + + /** Currently visible heading IDs. */ + #visibleIds = new Set(); + + /** @internal The id of the currently active heading. */ + @state() activeId?: string; + + /** @internal The grouped heading entries for the TOC. */ + @state() entries: TocEntry[] = []; + + /** @internal The title of the page, taken from the h1. */ + @state() pageTitle?: string; + + /** The selector for the main content area. */ + @property() target = 'main'; + + disconnectedCallback(): void { + this.#observer.disconnect(); + + super.disconnectedCallback(); + } + + willUpdate(changes: PropertyValues): void { + super.willUpdate(changes); + + if (changes.has('target')) { + this.refresh(); + } + } + + refresh(): void { + this.#observer.disconnect(); + this.#visibleIds.clear(); + this.#headings = []; + this.activeId = undefined; + + const target = document.querySelector(this.target ?? 'main'); + if (!target) { + console.warn(`Target element "${this.target}" not found.`); + return; + } + + this.pageTitle = target.querySelector('h1')?.textContent?.trim(); + + const headings = Array.from(target.querySelectorAll('doc-heading[id]')), + entries: TocEntry[] = []; + + for (const heading of headings) { + const entry: TocEntry = { + id: heading.id, + text: heading.textContent?.trim() ?? '', + children: [] + }; + + if (heading.getAttribute('level') === '3' && entries.length > 0) { + entries[entries.length - 1].children.push(entry); + } else { + entries.push(entry); + } + + this.#observer.observe(heading); + } + + this.#headings = headings; + this.entries = entries; + } + + render(): TemplateResult { + return html` + + `; + } +} diff --git a/docs/components/src/search/search.css b/docs/components/src/search/search.css new file mode 100644 index 0000000000..214e1e2163 --- /dev/null +++ b/docs/components/src/search/search.css @@ -0,0 +1,162 @@ +/* stylelint-disable custom-property-pattern */ + +:host { + display: block; +} + +button { + align-items: center; + background: var(--sl-color-background-input-plain); + border: var(--sl-size-borderWidth-default) solid var(--sl-color-border-input); + border-radius: var(--sl-size-borderRadius-default); + box-sizing: border-box; + color: var(--sl-color-foreground-subtlest); + cursor: pointer; + display: flex; + font: inherit; + gap: var(--sl-size-100); + inline-size: 100%; + outline: transparent solid var(--sl-size-borderWidth-focusRing); + outline-offset: var(--sl-size-outlineOffset-default); + padding-block: calc(var(--sl-size-100) - var(--sl-size-borderWidth-default)); + padding-inline: calc(var(--sl-size-150) - var(--sl-size-borderWidth-default)); + + &:hover { + background: color-mix( + in srgb, + var(--sl-color-background-input-plain), + var(--sl-color-background-input-interactive) + calc(100% * var(--sl-opacity-interactive-plain-hover)) + ); + } + + &:focus-visible { + outline-color: var(--sl-color-border-focused); + } + + sl-icon { + color: var(--sl-color-foreground-plain); + font-size: var(--sl-size-200); + } + + span { + flex: 1; + text-align: start; + } + + kbd { + background: var(--sl-elevation-surface-raised-sunken); + border: var(--sl-size-borderWidth-default) solid var(--sl-color-border-default); + border-radius: var(--sl-size-borderRadius-sm); + font: var(--sl-text-body-sm); + line-height: 1; + padding: var(--sl-size-050) var(--sl-size-100); + } +} + +dialog { + background: transparent; + border: 0; + inline-size: min(600px, calc(100dvw - var(--sl-size-600))); + margin: 15vh auto auto; + max-block-size: 70dvh; + overflow: visible; + padding: 0; + + &[open] { + display: flex; + + &::backdrop { + background: var(--sl-color-blanket-plain); + opacity: 1; + + @starting-style { + opacity: 0; + } + } + } + + &::backdrop { + opacity: 0; + transition: opacity 0.2s ease-in-out; + transition-behavior: allow-discrete; + } +} + +.container { + background: var(--sl-elevation-surface-raised-default); + border-radius: var(--sl-size-borderRadius-default); + display: flex; + flex-direction: column; + inline-size: 100%; + max-block-size: 70dvh; + overflow: hidden; +} + +sl-search-field { + flex: none; + margin: var(--sl-size-200); +} + +.message { + color: var(--sl-color-foreground-subtle); + font: var(--sl-text-body-md); + margin: 0; + padding: 0 var(--sl-size-200) var(--sl-size-300); +} + +.results { + display: flex; + flex-direction: column; + list-style: none; + margin: 0; + overflow-y: auto; + padding: 0 var(--sl-size-200) var(--sl-size-200); + + li { + align-items: flex-start; + border-radius: var(--sl-size-borderRadius-default); + cursor: pointer; + display: flex; + gap: var(--sl-size-150); + padding: var(--sl-size-150) var(--sl-size-200); + + &:hover, + &.active { + background: var(--sl-color-background-accent-blue-subtlest); + } + + sl-icon { + color: var(--sl-color-foreground-subtle); + flex: none; + font-size: var(--sl-size-200); + margin-block-start: var(--sl-size-050); + } + } +} + +.result-content { + display: flex; + flex-direction: column; + gap: var(--sl-size-050); + min-inline-size: 0; +} + +.result-title { + color: var(--sl-color-foreground-accent-blue-bold); + font: var(--sl-text-body-md); + font-weight: var(--sl-text-typeset-fontWeight-semibold); +} + +.result-description { + color: var(--sl-color-foreground-default); + font: var(--sl-text-body-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-path { + color: var(--sl-color-foreground-subtle); + font: var(--sl-text-body-sm); +} diff --git a/docs/components/src/search/search.spec.ts b/docs/components/src/search/search.spec.ts new file mode 100644 index 0000000000..7b607ca5ad --- /dev/null +++ b/docs/components/src/search/search.spec.ts @@ -0,0 +1,157 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { type LitElement, html } from 'lit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Use dynamic import from dist to avoid CSS module resolution issues in browser tests +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const { Search: SearchClass } = await import('@sl-design-system/doc-components/search/search'); + +try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + customElements.define('doc-search', SearchClass); +} catch { + /* empty */ +} + +describe('doc-search', () => { + let el: LitElement; + + beforeEach(async () => { + el = await fixture(html``); + }); + + describe('trigger button', () => { + it('should render a trigger button', () => { + const button = el.renderRoot.querySelector('button'); + + expect(button).to.exist; + }); + + it('should have a search icon', () => { + const icon = el.renderRoot.querySelector('button sl-icon'); + + expect(icon).to.exist; + expect(icon?.getAttribute('name')).to.equal('far-magnifying-glass'); + }); + + it('should have "Search" text', () => { + const span = el.renderRoot.querySelector('button span'); + + expect(span).to.exist; + expect(span?.textContent).to.equal('Search'); + }); + + it('should have a keyboard shortcut hint', () => { + const kbd = el.renderRoot.querySelector('button kbd'); + + expect(kbd).to.exist; + }); + + it('should open the dialog when clicked', () => { + const button = el.renderRoot.querySelector('button')!; + const dialog = el.renderRoot.querySelector('dialog')!; + + button.click(); + + expect(dialog.open).to.be.true; + }); + }); + + describe('dialog', () => { + it('should render a dialog', () => { + const dialog = el.renderRoot.querySelector('dialog'); + + expect(dialog).to.exist; + }); + + it('should not be open by default', () => { + const dialog = el.renderRoot.querySelector('dialog')!; + + expect(dialog.open).to.be.false; + }); + + it('should contain a search field', () => { + const searchField = el.renderRoot.querySelector('sl-search-field'); + + expect(searchField).to.exist; + }); + + it('should have an aria-label on the search field', () => { + const searchField = el.renderRoot.querySelector('sl-search-field'); + + expect(searchField?.getAttribute('aria-label')).to.equal('Search documentation'); + }); + + it('should not contain any results before a query is entered', () => { + const results = el.renderRoot.querySelectorAll('.results li'); + + expect(results.length).to.equal(0); + }); + + it('should show a prompt message before a query is entered', () => { + const message = el.renderRoot.querySelector('.message'); + + expect(message).to.exist; + }); + + it('should close when the Escape key is pressed', () => { + const button = el.renderRoot.querySelector('button')!; + const dialog = el.renderRoot.querySelector('dialog')!; + + button.click(); + expect(dialog.open).to.be.true; + + dialog.close(); + + expect(dialog.open).to.be.false; + }); + }); + + describe('keyboard shortcut', () => { + it('should open the dialog on Cmd+K', () => { + const dialog = el.renderRoot.querySelector('dialog')!; + const showModalSpy = vi.spyOn(dialog, 'showModal'); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true }) + ); + + expect(showModalSpy).toHaveBeenCalled(); + }); + + it('should open the dialog on Ctrl+K', () => { + const dialog = el.renderRoot.querySelector('dialog')!; + const showModalSpy = vi.spyOn(dialog, 'showModal'); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }) + ); + + expect(showModalSpy).toHaveBeenCalled(); + }); + + it('should not open the dialog on just K', () => { + const dialog = el.renderRoot.querySelector('dialog')!; + const showModalSpy = vi.spyOn(dialog, 'showModal'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', bubbles: true })); + + expect(showModalSpy).not.toHaveBeenCalled(); + }); + }); + + describe('cleanup', () => { + it('should remove the keydown listener on disconnect', () => { + const dialog = el.renderRoot.querySelector('dialog')!; + const showModalSpy = vi.spyOn(dialog, 'showModal'); + + el.remove(); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true }) + ); + + expect(showModalSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/docs/components/src/search/search.stories.ts b/docs/components/src/search/search.stories.ts new file mode 100644 index 0000000000..047cfcc22d --- /dev/null +++ b/docs/components/src/search/search.stories.ts @@ -0,0 +1,25 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { Search } from './search.js'; + +type Story = StoryObj; + +try { + customElements.define('doc-search', Search); +} catch { + /* empty */ +} + +export default { + title: 'Search', + render: () => html` + + + ` +} satisfies Meta; + +export const Basic: Story = {}; diff --git a/docs/components/src/search/search.ts b/docs/components/src/search/search.ts new file mode 100644 index 0000000000..dcfdc808fa --- /dev/null +++ b/docs/components/src/search/search.ts @@ -0,0 +1,306 @@ +import { faMagnifyingGlass } from '@fortawesome/pro-regular-svg-icons'; +import { + type ScopedElementsMap, + ScopedElementsMixin +} from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { SearchField, type SlSearchEvent } from '@sl-design-system/search-field'; +import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import MiniSearch, { type SearchResult } from 'minisearch'; +import styles from './search.css' with { type: 'css' }; + +Icon.register(faMagnifyingGlass); + +/** A single result as rendered in the listbox. */ +interface SearchResultItem { + title: string; + description: string; + url: string; + /** Icon name (e.g. `far-bolt`) inherited from the page's category. */ + icon: string; +} + +/** The shape of the `search.json` file generated by the Eleventy search plugin. */ +interface SearchData { + searchIndex: unknown; + map: SearchResultItem[]; +} + +/** The fields the index is built with; must match the Eleventy search plugin. */ +const SEARCH_FIELDS = ['t', 'h', 'c']; + +export class Search extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-icon': Icon, + 'sl-search-field': SearchField + }; + } + + /** @internal */ + static styles: CSSResultGroup = styles; + + /** The MiniSearch index, loaded lazily the first time the dialog is opened. */ + #index?: MiniSearch; + + /** Maps a result id to its display data (title, description, url). */ + #map: SearchResultItem[] = []; + + /** Tracks the in-flight or completed load of the search index. */ + #loadPromise?: Promise; + + #onKeydown = (event: KeyboardEvent): void => { + if (event.key === 'k' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.#open(); + } + }; + + @query('dialog') dialog?: HTMLDialogElement; + + /** Index of the currently highlighted result, or -1 if none. */ + @state() activeIndex = -1; + + /** The current query string. */ + @state() query = ''; + + /** The current result list. */ + @state() results: SearchResultItem[] = []; + + /** Whether the index is still being loaded. */ + @state() loading = false; + + override connectedCallback(): void { + super.connectedCallback(); + + document.addEventListener('keydown', this.#onKeydown); + } + + override disconnectedCallback(): void { + document.removeEventListener('keydown', this.#onKeydown); + + super.disconnectedCallback(); + } + + render(): TemplateResult { + const isMac = navigator.platform.includes('Mac'); + + return html` + + + +
+ + = 0 ? `result-${this.activeIndex}` : nothing} + aria-controls="search-results" + placeholder="Search documentation..." + role="combobox" + aria-expanded="true" + aria-autocomplete="list"> + + ${this.#renderResults()} +
+
+ `; + } + + #renderResults(): TemplateResult { + if (this.loading) { + return html`

Loading search index…

`; + } + + if (!this.query) { + return html`

Start typing to search the documentation.

`; + } + + if (this.results.length === 0) { + return html`

No results found for “${this.query}”.

`; + } + + return html` +
    + ${this.results.map( + (result, index) => html` +
  • this.#select(index)}> + +
    + ${result.title} + ${result.description + ? html`${result.description}` + : nothing} + ${result.url.replace(/^\//, '')} +
    +
  • + ` + )} +
+ `; + } + + #open(): void { + this.dialog?.showModal(); + this.activeIndex = -1; + + void this.#loadIndex(); + + requestAnimationFrame(() => { + this.renderRoot.querySelector('sl-search-field')?.focus(); + }); + } + + /** Lazily fetches and parses the search index, building the MiniSearch instance once. */ + async #loadIndex(): Promise { + if (this.#index || this.#loadPromise) { + return this.#loadPromise; + } + + this.loading = true; + + this.#loadPromise = (async () => { + try { + const response = await fetch('/search.json'), + data = (await response.json()) as SearchData; + + this.#index = MiniSearch.loadJSON(JSON.stringify(data.searchIndex), { + fields: SEARCH_FIELDS + }); + this.#map = data.map; + } catch { + // Leave the index undefined; searches will simply return no results. + } finally { + this.loading = false; + } + })(); + + return this.#loadPromise; + } + + #onSearch(event: SlSearchEvent): void { + this.#search(event.detail); + } + + #search(query: string): void { + this.query = query.trim(); + + if (!this.query || !this.#index) { + this.results = []; + this.activeIndex = -1; + return; + } + + const matches = this.#index.search(this.query, { + prefix: true, + fuzzy: 0.2, + boost: { t: 20, h: 10, c: 1 } + }); + + // Re-rank so that title matches win: MiniSearch's score doesn't account for + // where in the title a term matches, so boost exact/prefix/word-boundary title hits. + const queryLower = this.query.toLowerCase(), + rankTitle = (title: string): number => { + if (title === queryLower) { + return 3; + } else if (title.startsWith(queryLower)) { + return 2; + } else if ( + new RegExp(`\\b${queryLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(title) + ) { + return 1; + } else { + return 0; + } + }; + + matches.sort((a: SearchResult, b: SearchResult) => { + const rankDiff = + rankTitle((this.#map[b.id]?.title ?? '').toLowerCase()) - + rankTitle((this.#map[a.id]?.title ?? '').toLowerCase()); + + return rankDiff !== 0 ? rankDiff : b.score - a.score; + }); + + this.results = matches + .map(match => this.#map[match.id]) + .filter((page): page is SearchResultItem => !!page?.url); + + // Highlight the first result so Enter navigates to the top match. + this.activeIndex = this.results.length > 0 ? 0 : -1; + } + + #select(index: number): void { + const result = this.results[index]; + if (!result) { + return; + } + + this.dialog?.close(); + window.location.href = result.url; + } + + #onBackdropClick(event: MouseEvent): void { + const dialog = this.dialog; + + if (!dialog || dialog !== event.composedPath()[0]) { + return; + } + + const rect = dialog.getBoundingClientRect(); + + if ( + event.clientY < rect.top || + event.clientY > rect.bottom || + event.clientX < rect.left || + event.clientX > rect.right + ) { + dialog.close(); + } + } + + #onDialogKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + this.dialog?.close(); + return; + } + + if (this.results.length === 0) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.activeIndex = this.activeIndex < this.results.length - 1 ? this.activeIndex + 1 : 0; + this.#scrollActiveIntoView(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.activeIndex = this.activeIndex > 0 ? this.activeIndex - 1 : this.results.length - 1; + this.#scrollActiveIntoView(); + } else if (event.key === 'Enter' && this.activeIndex >= 0) { + event.preventDefault(); + this.#select(this.activeIndex); + } + } + + #scrollActiveIntoView(): void { + this.renderRoot + .querySelectorAll('.results li') + [this.activeIndex]?.scrollIntoView({ block: 'nearest' }); + } +} diff --git a/docs/components/src/sidebar/sidebar.css b/docs/components/src/sidebar/sidebar.css new file mode 100644 index 0000000000..597a15ee37 --- /dev/null +++ b/docs/components/src/sidebar/sidebar.css @@ -0,0 +1,72 @@ +:host { + background: var(--sl-elevation-surface-raised-sunken); + block-size: 100dvh; + border-inline-end: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + display: flex; + flex-direction: column; + inset-block-start: 0; + overflow: hidden; + position: sticky; +} + +:host-context([data-color-scheme='dark']) .logo-light { + display: none; +} + +:host-context([data-color-scheme='dark']) .logo-dark { + display: block; +} + +header { + padding: var(--sl-size-300); + padding-block-end: var(--sl-size-200); + + a { + display: block; + text-decoration: none; + } + + img { + block-size: 2rem; + display: block; + inline-size: auto; + } +} + +.logo-dark { + display: none; +} + +doc-search { + padding-inline: var(--sl-size-300); +} + +doc-site-nav { + flex: 1; + overflow-y: auto; + padding: var(--sl-size-200) var(--sl-size-300); +} + +footer { + align-items: center; + /* stylelint-disable-next-line custom-property-pattern */ + border-block-start: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + display: flex; + justify-content: space-between; + padding: var(--sl-size-200) var(--sl-size-300); + + a { + align-items: center; + color: var(--sl-color-foreground-plain); + display: inline-flex; + text-decoration: none; + + &:hover { + color: var(--sl-color-foreground-accent-blue-bold); + } + } + + sl-icon { + inline-size: var(--sl-size-300); + } +} diff --git a/docs/components/src/sidebar/sidebar.spec.ts b/docs/components/src/sidebar/sidebar.spec.ts new file mode 100644 index 0000000000..f3553c2412 --- /dev/null +++ b/docs/components/src/sidebar/sidebar.spec.ts @@ -0,0 +1,99 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it } from 'vitest'; + +// Use dynamic import from dist to avoid CSS module resolution issues in browser tests +const { Sidebar } = await import('@sl-design-system/doc-components/sidebar/sidebar'); + +try { + customElements.define('doc-sidebar', Sidebar); +} catch { + /* empty */ +} + +describe('doc-sidebar', () => { + let el: InstanceType; + + beforeEach(async () => { + el = await fixture(html``); + }); + + describe('structure', () => { + it('should render a header with a link', () => { + const link = el.renderRoot.querySelector('header a'); + + expect(link).to.exist; + expect(link).to.have.attribute('href', '/'); + expect(link).to.have.attribute('aria-label', 'SL Design System'); + }); + + it('should render logo images in the header', () => { + const lightLogo = el.renderRoot.querySelector('header img.logo-light'); + const darkLogo = el.renderRoot.querySelector('header img.logo-dark'); + + expect(lightLogo).to.exist; + expect(lightLogo).to.have.attribute('src', '/assets/logo-black.svg'); + expect(darkLogo).to.exist; + expect(darkLogo).to.have.attribute('src', '/assets/logo.svg'); + }); + + it('should render a body section with a site-nav', () => { + const body = el.renderRoot.querySelector('.body'); + const siteNav = el.renderRoot.querySelector('doc-site-nav'); + + expect(body).to.exist; + expect(siteNav).to.exist; + }); + + it('should render a slot inside the site-nav', () => { + const slot = el.renderRoot.querySelector('doc-site-nav slot'); + + expect(slot).to.exist; + }); + + it('should render a footer', () => { + const footer = el.renderRoot.querySelector('footer'); + + expect(footer).to.exist; + }); + + it('should render a GitHub link in the footer', () => { + const link = el.renderRoot.querySelector('footer a'); + + expect(link).to.exist; + expect(link).to.have.attribute('href', 'https://github.com/sl-design-system/components'); + expect(link).to.have.attribute('target', '_blank'); + expect(link).to.have.attribute('rel', 'noopener noreferrer'); + }); + + it('should render a GitHub icon in the footer link', () => { + const icon = el.renderRoot.querySelector('footer a sl-icon'); + + expect(icon).to.exist; + expect(icon).to.have.attribute('name', 'fab-github'); + }); + + it('should render a theme switch in the footer', () => { + const themeSwitch = el.renderRoot.querySelector('footer doc-theme-switch'); + + expect(themeSwitch).to.exist; + }); + }); + + describe('slotted content', () => { + beforeEach(async () => { + el = await fixture(html` + + Navigation content + + `); + }); + + it('should slot content into the site-nav', () => { + const slot = el.renderRoot.querySelector('doc-site-nav slot') as HTMLSlotElement; + const assignedNodes = slot?.assignedNodes({ flatten: true }); + + expect(assignedNodes?.length).to.be.greaterThan(0); + }); + }); +}); diff --git a/docs/components/src/sidebar/sidebar.stories.ts b/docs/components/src/sidebar/sidebar.stories.ts new file mode 100644 index 0000000000..d4460289d9 --- /dev/null +++ b/docs/components/src/sidebar/sidebar.stories.ts @@ -0,0 +1,51 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { Sidebar } from './sidebar.js'; + +type Story = StoryObj; + +try { + customElements.define('doc-sidebar', Sidebar); +} catch { + /* empty */ +} + +export default { + title: 'Sidebar', + parameters: { + layout: 'fullscreen' + }, + render: () => html` + +
+ + + + + + + + + + + + + +
+

Page Title

+

Main content area. The sidebar should remain fixed while this content scrolls.

+
+
+ ` +} satisfies Meta; + +export const Basic: Story = {}; diff --git a/docs/components/src/sidebar/sidebar.ts b/docs/components/src/sidebar/sidebar.ts new file mode 100644 index 0000000000..4cf517089e --- /dev/null +++ b/docs/components/src/sidebar/sidebar.ts @@ -0,0 +1,49 @@ +import { faGithub } from '@fortawesome/free-brands-svg-icons'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { Search } from '../search/search.js'; +import { SiteNav } from '../site-nav/site-nav.js'; +import { ThemeSwitch } from '../theme-switch/theme-switch.js'; +import styles from './sidebar.css' with { type: 'css' }; + +Icon.register(faGithub); + +export class Sidebar extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'doc-search': Search, + 'doc-site-nav': SiteNav, + 'doc-theme-switch': ThemeSwitch, + 'sl-icon': Icon + }; + } + + /** @internal */ + static styles: CSSResultGroup = styles; + + render(): TemplateResult { + return html` +
+ + SL Design System + SL Design System + +
+ + + + + + + + + `; + } +} diff --git a/docs/components/src/site-nav/nav-group.css b/docs/components/src/site-nav/nav-group.css new file mode 100644 index 0000000000..861566d110 --- /dev/null +++ b/docs/components/src/site-nav/nav-group.css @@ -0,0 +1,52 @@ +:host { + display: block; +} + +:host(:not(:first-of-type)) { + margin-block-start: var(--sl-size-300); +} + +:host([collapsible]) h2 { + align-items: center; + cursor: pointer; + display: flex; + user-select: none; +} + +:host([collapsed]) { + sl-icon { + transform: rotate(-90deg); + } + + slot { + display: none; + } +} + +h2 { + color: var(--sl-color-foreground-plain); + font-size: inherit; + font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); + margin: 0; + padding: var(--sl-size-075) var(--sl-size-100); +} + +a { + color: inherit; + text-decoration: dotted underline; + + &:hover { + text-decoration-style: solid; + } +} + +sl-icon { + margin-inline-start: auto; + transition: transform 0.2s ease; +} + +slot { + display: flex; + flex-direction: column; + gap: var(--sl-size-025); +} diff --git a/docs/components/src/site-nav/nav-group.spec.ts b/docs/components/src/site-nav/nav-group.spec.ts new file mode 100644 index 0000000000..4519a5b712 --- /dev/null +++ b/docs/components/src/site-nav/nav-group.spec.ts @@ -0,0 +1,193 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it } from 'vitest'; + +const { NavGroup } = await import('@sl-design-system/doc-components/site-nav/site-nav'); + +try { + customElements.define('doc-nav-group', NavGroup); +} catch { + /* empty */ +} + +describe('doc-nav-group', () => { + let el: InstanceType; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(NavGroup); + }); + + it('should not have a heading property', () => { + expect(el.heading).to.be.undefined; + }); + + it('should not be collapsible by default', () => { + expect(el.collapsible).to.not.be.ok; + }); + + it('should not be collapsed by default', () => { + expect(el.collapsed).to.not.be.ok; + }); + + it('should not render a heading element when none is set', () => { + const h2 = el.renderRoot.querySelector('h2'); + + expect(h2).to.not.exist; + }); + + it('should render a slot for child items', () => { + const slot = el.renderRoot.querySelector('slot'); + + expect(slot).to.exist; + }); + }); + + describe('with heading', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have the heading property set', () => { + expect(el.heading).to.equal('Getting Started'); + }); + + it('should render an h2 element', () => { + const h2 = el.renderRoot.querySelector('h2'); + + expect(h2).to.exist; + }); + + it('should display the heading text', () => { + const h2 = el.renderRoot.querySelector('h2'); + + expect(h2?.textContent?.trim()).to.equal('Getting Started'); + }); + + it('should still render a slot', () => { + const slot = el.renderRoot.querySelector('slot'); + + expect(slot).to.exist; + }); + }); + + describe('updating heading', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should update the heading when the property changes', async () => { + el.heading = 'Updated'; + await el.updateComplete; + + const h2 = el.renderRoot.querySelector('h2'); + + expect(h2?.textContent?.trim()).to.equal('Updated'); + }); + + it('should remove the heading when set to undefined', async () => { + el.heading = undefined; + await el.updateComplete; + + const h2 = el.renderRoot.querySelector('h2'); + + expect(h2).to.not.exist; + }); + }); + + describe('with slotted content', () => { + beforeEach(async () => { + el = await fixture(html` + + Child content + + `); + }); + + it('should slot the child content', () => { + const slot = el.renderRoot.querySelector('slot') as HTMLSlotElement, + assigned = slot.assignedElements({ flatten: true }); + + expect(assigned).to.have.length(1); + expect(assigned[0]).to.have.class('test-child'); + }); + }); + + describe('collapsible', () => { + beforeEach(async () => { + el = await fixture(html` + + Child content + + `); + }); + + it('should have the collapsible attribute', () => { + expect(el).to.have.attribute('collapsible'); + }); + + it('should render a chevron icon', () => { + const chevron = el.renderRoot.querySelector('.chevron'); + + expect(chevron).to.exist; + }); + + it('should not be collapsed by default', () => { + expect(el.collapsed).to.not.be.ok; + }); + + it('should toggle collapsed when heading is clicked', async () => { + const h2 = el.renderRoot.querySelector('h2')!; + + h2.click(); + await el.updateComplete; + + expect(el.collapsed).to.be.true; + expect(el).to.have.attribute('collapsed'); + + h2.click(); + await el.updateComplete; + + expect(el.collapsed).to.be.false; + expect(el).to.not.have.attribute('collapsed'); + }); + + it('should not render a chevron when not collapsible', async () => { + el.collapsible = false; + await el.updateComplete; + + const chevron = el.renderRoot.querySelector('.chevron'); + + expect(chevron).to.not.exist; + }); + }); + + describe('collapsed', () => { + beforeEach(async () => { + el = await fixture(html` + + Child content + + `); + }); + + it('should have the collapsed attribute', () => { + expect(el).to.have.attribute('collapsed'); + }); + + it('should expand when heading is clicked', async () => { + const h2 = el.renderRoot.querySelector('h2')!; + + h2.click(); + await el.updateComplete; + + expect(el.collapsed).to.be.false; + expect(el).to.not.have.attribute('collapsed'); + }); + }); +}); diff --git a/docs/components/src/site-nav/nav-group.stories.ts b/docs/components/src/site-nav/nav-group.stories.ts new file mode 100644 index 0000000000..484615ab73 --- /dev/null +++ b/docs/components/src/site-nav/nav-group.stories.ts @@ -0,0 +1,93 @@ +import { + faBook, + faCircleQuestion, + faCodeBranch, + faLayerGroup, + faShieldCheck +} from '@fortawesome/pro-regular-svg-icons'; +import { Icon } from '@sl-design-system/icon'; +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { NavGroup } from './nav-group.js'; +import { NavItem } from './nav-item.js'; + +Icon.register(faBook, faCircleQuestion, faCodeBranch, faLayerGroup, faShieldCheck); + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-nav-group', NavGroup); + customElements.define('doc-nav-item', NavItem); +} catch { + /* empty */ +} + +export default { + title: 'Site Navigation/Nav Group', + args: { + heading: 'Introduction' + }, + render: ({ collapsed, collapsible, heading, href }) => { + return html` + + + + + + + `; + } +} satisfies Meta; + +export const Basic: Story = {}; + +export const WithLink: Story = { + args: { + href: '#' + } +}; + +export const Collapsible: Story = { + args: { + collapsible: true + } +}; + +export const Collapsed: Story = { + args: { + collapsible: true, + collapsed: true + } +}; + +export const MultipleGroups: Story = { + render: () => { + return html` + +
+ + + + + + + + + + + + + +
+ `; + } +}; diff --git a/docs/components/src/site-nav/nav-group.ts b/docs/components/src/site-nav/nav-group.ts new file mode 100644 index 0000000000..2207d8ba40 --- /dev/null +++ b/docs/components/src/site-nav/nav-group.ts @@ -0,0 +1,68 @@ +import { faChevronDown } from '@fortawesome/pro-regular-svg-icons'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import styles from './nav-group.css' with { type: 'css' }; + +Icon.register(faChevronDown); + +export class NavGroup extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-icon': Icon + }; + } + + /** @internal */ + static styles: CSSResultGroup = styles; + + /** Whether this group can be collapsed. */ + @property({ type: Boolean, reflect: true }) collapsible?: boolean; + + /** Whether this group is collapsed. Only applies when collapsible is true. */ + @property({ type: Boolean, reflect: true }) collapsed?: boolean; + + /** The section heading text. */ + @property() heading?: string; + + /** Optional URL for the heading. */ + @property() href?: string; + + override connectedCallback(): void { + super.connectedCallback(); + + this.setAttribute('role', 'group'); + } + + override render(): TemplateResult { + return html` + ${this.heading + ? html` +

+ ${this.href ? html`${this.heading}` : this.heading} + ${this.collapsible + ? html`` + : nothing} +

+ ` + : nothing} + + `; + } + + override updated(): void { + if (this.heading) { + this.setAttribute('aria-label', this.heading); + } else { + this.removeAttribute('aria-label'); + } + } + + #onHeadingClick(): void { + if (this.collapsible) { + this.collapsed = !this.collapsed; + } + } +} diff --git a/docs/components/src/site-nav/nav-item.css b/docs/components/src/site-nav/nav-item.css new file mode 100644 index 0000000000..b7d851334c --- /dev/null +++ b/docs/components/src/site-nav/nav-item.css @@ -0,0 +1,153 @@ +/* stylelint-disable custom-property-pattern */ + +:host { + --_bg-color: transparent; + --_bg-mix-color: var(--sl-color-background-neutral-interactive-plain); + --_bg-opacity: var(--sl-opacity-interactive-plain-idle); + --nav-indent: var(--sl-size-200); + + color: var(--sl-color-foreground-plain); + display: block; + outline: none; + scroll-margin-block: 8px; +} + +:host([active]) { + --_bg-color: var(--sl-color-background-selected-subtlest); + --_bg-mix-color: var(--sl-color-background-selected-interactive-plain); + + color: var(--sl-color-foreground-accent-blue-bold); +} + +:host([data-level='0']) .active { + display: none; +} + +:host(:focus-visible) summary, +:host(:focus-visible) a { + outline: var(--sl-size-borderWidth-focusRing) solid var(--sl-color-border-focused); + outline-offset: calc(var(--sl-size-borderWidth-focusRing) * -1); +} + +:host(:not([icon])) summary { + padding-inline-start: calc( + var(--sl-size-100) + var(--nav-level, 0) * var(--nav-indent) + var(--sl-size-200) + var(--sl-size-100) + ); +} + +:host(:not([icon])[data-level='0']) [part='leaf'] { + padding-inline-start: calc(var(--sl-size-100) + var(--sl-size-200) + var(--sl-size-100)); +} + +details { + border: none; + + &[open] { + summary { + margin-block-end: var(--sl-size-025); + } + + .chevron { + transform: rotate(90deg); + } + + .subtree { + display: flex; + } + } +} + +summary { + align-items: center; + background: color-mix(in srgb, var(--_bg-color), var(--_bg-mix-color) calc(100% * var(--_bg-opacity))); + border-radius: var(--sl-size-borderRadius-default); + cursor: pointer; + display: flex; + gap: var(--sl-size-100); + padding-block: var(--sl-size-075); + padding-inline: calc(var(--sl-size-100) + var(--nav-level, 0) * var(--nav-indent)) var(--sl-size-100); + + @media (prefers-reduced-motion: no-preference) { + transition: background 200ms ease-in-out; + } + + &::-webkit-details-marker { + display: none; + } + + &:hover { + --_bg-opacity: var(--sl-opacity-interactive-plain-hover); + } + + &:active { + --_bg-opacity: var(--sl-opacity-interactive-plain-active); + } + + a { + color: inherit; + flex: 1; + text-decoration: none; + } +} + +.chevron { + margin-inline-start: auto; + transition: transform 0.15s ease; +} + +.label { + flex: 1; +} + +.subtree { + border-inline-start: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain); + display: none; + flex-direction: column; + gap: var(--sl-size-025); + margin-inline-start: calc(var(--sl-size-100) + var(--nav-level, 0) * var(--nav-indent) + 0.5em); + padding-inline-start: var(--sl-size-100); + position: relative; +} + +[part='active'] { + background: var(--sl-elevation-surface-base-default); + border-radius: var(--sl-size-150); + inline-size: calc(var(--sl-size-075) - var(--sl-size-010)); + inset-block: anchor(top) anchor(bottom); + inset-inline-start: calc(-1 * (var(--sl-size-050) - var(--sl-size-010))); + position: absolute; + transition: inset 200ms ease-in-out; + + &::before { + background: var(--sl-color-background-selected-bold); + border-radius: var(--sl-size-150); + content: ''; + inline-size: 100%; + inset-block: var(--sl-size-050); + position: absolute; + } +} + +[part='leaf'] { + align-items: center; + background: color-mix(in srgb, var(--_bg-color), var(--_bg-mix-color) calc(100% * var(--_bg-opacity))); + border-radius: var(--sl-size-borderRadius-default); + color: inherit; + display: flex; + gap: var(--sl-size-100); + padding: var(--sl-size-075) var(--sl-size-100); + position: relative; + text-decoration: none; + + @media (prefers-reduced-motion: no-preference) { + transition: background 200ms ease-in-out; + } + + &:hover { + --_bg-opacity: var(--sl-opacity-interactive-plain-hover); + } + + &:active { + --_bg-opacity: var(--sl-opacity-interactive-plain-active); + } +} diff --git a/docs/components/src/site-nav/nav-item.spec.ts b/docs/components/src/site-nav/nav-item.spec.ts new file mode 100644 index 0000000000..d7577820fb --- /dev/null +++ b/docs/components/src/site-nav/nav-item.spec.ts @@ -0,0 +1,424 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it } from 'vitest'; + +const { NavItem } = await import('@sl-design-system/doc-components/site-nav/site-nav'); + +try { + customElements.define('doc-nav-item', NavItem); +} catch { + /* empty */ +} + +describe('doc-nav-item', () => { + let el: InstanceType; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(NavItem); + }); + + it('should not have a heading', () => { + expect(el.heading).to.be.undefined; + }); + + it('should not have an href', () => { + expect(el.href).to.be.undefined; + }); + + it('should not have an icon', () => { + expect(el.icon).to.be.undefined; + }); + + it('should not be active', () => { + expect(el.active).to.be.false; + }); + + it('should not be open', () => { + expect(el.open).to.be.false; + }); + + it('should not be expandable', () => { + expect(el.expandable).to.be.false; + }); + + it('should have level 0', () => { + expect(el.level).to.equal(0); + }); + }); + + describe('leaf item', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render a link', () => { + const link = el.renderRoot.querySelector('a.leaf'); + + expect(link).to.exist; + }); + + it('should have the correct href', () => { + const link = el.renderRoot.querySelector('a.leaf'); + + expect(link).to.have.attribute('href', '/'); + }); + + it('should display the heading text', () => { + const link = el.renderRoot.querySelector('a.leaf'); + + expect(link).to.have.trimmed.text('Home'); + }); + + it('should not render a details element', () => { + const details = el.renderRoot.querySelector('details'); + + expect(details).to.not.exist; + }); + + it('should not render a chevron icon', () => { + const chevron = el.renderRoot.querySelector('.chevron'); + + expect(chevron).to.not.exist; + }); + + it('should not have aria-current when not active', () => { + const link = el.renderRoot.querySelector('a.leaf'); + + expect(link).to.not.have.attribute('aria-current'); + }); + }); + + describe('active leaf item', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have the active attribute reflected', () => { + expect(el).to.have.attribute('active'); + }); + + it('should set aria-current="page" on the link', () => { + const link = el.renderRoot.querySelector('a.leaf'); + + expect(link).to.have.attribute('aria-current', 'page'); + }); + + it('should remove aria-current when active is set to false', async () => { + el.active = false; + await el.updateComplete; + + const link = el.renderRoot.querySelector('a.leaf'); + + expect(link).to.not.have.attribute('aria-current'); + }); + }); + + describe('item with icon', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render an sl-icon element', () => { + const icon = el.renderRoot.querySelector('sl-icon'); + + expect(icon).to.exist; + }); + + it('should set the icon name', () => { + const icon = el.renderRoot.querySelector('sl-icon'); + + expect(icon).to.have.attribute('name', 'far-book'); + }); + + it('should not render an icon when icon is not set', async () => { + el.icon = undefined; + await el.updateComplete; + + const icon = el.renderRoot.querySelector('a.leaf sl-icon'); + + expect(icon).to.not.exist; + }); + }); + + describe('expandable item', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + `); + }); + + it('should be expandable', () => { + expect(el.expandable).to.be.true; + }); + + it('should render a details element', () => { + const details = el.renderRoot.querySelector('details'); + + expect(details).to.exist; + }); + + it('should render a summary', () => { + const summary = el.renderRoot.querySelector('summary'); + + expect(summary).to.exist; + }); + + it('should display the heading in the summary', () => { + const summary = el.renderRoot.querySelector('summary'); + + expect(summary).to.have.trimmed.text('Guides'); + }); + + it('should render a chevron icon', () => { + const chevron = el.renderRoot.querySelector('.chevron'); + + expect(chevron).to.exist; + }); + + it('should have the chevron with name far-chevron-right', () => { + const chevron = el.renderRoot.querySelector('.chevron'); + + expect(chevron).to.have.attribute('name', 'far-chevron-right'); + }); + + it('should be collapsed by default', () => { + const details = el.renderRoot.querySelector('details'); + + expect(details).to.not.have.attribute('open'); + expect(el.open).to.be.false; + }); + + it('should expand when open is set to true', async () => { + el.open = true; + await el.updateComplete; + + const details = el.renderRoot.querySelector('details'); + + expect(details).to.have.attribute('open'); + }); + + it('should collapse when open is set to false', async () => { + el.open = true; + await el.updateComplete; + + el.open = false; + await el.updateComplete; + + const details = el.renderRoot.querySelector('details'); + + expect(details).to.not.have.attribute('open'); + }); + + it('should update the open property when details is toggled', async () => { + const details = el.renderRoot.querySelector('details')!; + + details.open = true; + details.dispatchEvent(new Event('toggle')); + await el.updateComplete; + + expect(el.open).to.be.true; + }); + + it('should emit an sl-toggle event when opened', async () => { + const details = el.renderRoot.querySelector('details')!; + + let event: Event | undefined; + el.addEventListener('sl-toggle', (e: Event) => (event = e)); + + details.open = true; + details.dispatchEvent(new Event('toggle')); + await el.updateComplete; + + expect(event).to.exist; + expect((event as CustomEvent).detail).to.be.true; + }); + + it('should emit an sl-toggle event when closed', async () => { + el.open = true; + await el.updateComplete; + + const details = el.renderRoot.querySelector('details')!; + + let event: Event | undefined; + el.addEventListener('sl-toggle', (e: Event) => (event = e)); + + details.open = false; + details.dispatchEvent(new Event('toggle')); + await el.updateComplete; + + expect(event).to.exist; + expect((event as CustomEvent).detail).to.be.false; + }); + + it('should not emit an sl-toggle event for non-expandable items', async () => { + const leaf = await fixture(html``); + + let event: Event | undefined; + leaf.addEventListener('sl-toggle', (e: Event) => (event = e)); + + leaf.click(); + await leaf.updateComplete; + + expect(event).to.be.undefined; + }); + + it('should reflect the open attribute', async () => { + el.open = true; + await el.updateComplete; + + expect(el).to.have.attribute('open'); + }); + + it('should render a slot for child items', () => { + const slot = el.renderRoot.querySelector('slot'); + + expect(slot).to.exist; + }); + + it('should render a span label when no href is set', () => { + const span = el.renderRoot.querySelector('summary .label'); + + expect(span).to.exist; + expect(span).to.have.text('Guides'); + }); + + it('should not render a link in the summary when no href is set', () => { + const link = el.renderRoot.querySelector('summary a'); + + expect(link).to.not.exist; + }); + }); + + describe('expandable item with href', () => { + beforeEach(async () => { + el = await fixture(html` + + + + `); + }); + + it('should render a link inside the summary', () => { + const link = el.renderRoot.querySelector('summary a'); + + expect(link).to.exist; + }); + + it('should have the correct href on the summary link', () => { + const link = el.renderRoot.querySelector('summary a'); + + expect(link).to.have.attribute('href', '/section/'); + }); + + it('should display the heading in the summary link', () => { + const link = el.renderRoot.querySelector('summary a'); + + expect(link).to.have.text('Section'); + }); + + it('should not render a span label', () => { + const span = el.renderRoot.querySelector('summary .label'); + + expect(span).to.not.exist; + }); + }); + + describe('expandable item with icon', () => { + beforeEach(async () => { + el = await fixture(html` + + + + `); + }); + + it('should render the icon in the summary', () => { + const icon = el.renderRoot.querySelector('summary sl-icon:not(.chevron)'); + + expect(icon).to.exist; + }); + + it('should set the correct icon name', () => { + const icon = el.renderRoot.querySelector('summary sl-icon:not(.chevron)'); + + expect(icon).to.have.attribute('name', 'far-code-branch'); + }); + + it('should render both the item icon and the chevron', () => { + const icons = el.renderRoot.querySelectorAll('summary sl-icon'); + + expect(icons).to.have.length(2); + }); + }); + + describe('nesting levels', () => { + let root: InstanceType, + child: InstanceType, + grandchild: InstanceType; + + beforeEach(async () => { + root = await fixture(html` + + + + + + `); + child = root.querySelector('doc-nav-item')!; + grandchild = child.querySelector('doc-nav-item')!; + }); + + it('should have level 0 for the root item', () => { + expect(root.level).to.equal(0); + }); + + it('should have level 1 for the child item', () => { + expect(child.level).to.equal(1); + }); + + it('should have level 2 for the grandchild item', () => { + expect(grandchild.level).to.equal(2); + }); + + it('should set --nav-level to 0 on the root', () => { + expect(root.style.getPropertyValue('--nav-level')).to.equal('0'); + }); + + it('should set --nav-level to 1 on the child', () => { + expect(child.style.getPropertyValue('--nav-level')).to.equal('1'); + }); + + it('should set --nav-level to 2 on the grandchild', () => { + expect(grandchild.style.getPropertyValue('--nav-level')).to.equal('2'); + }); + }); + + describe('initially open with children', () => { + beforeEach(async () => { + el = await fixture(html` + + + + `); + }); + + it('should render with details open', () => { + const details = el.renderRoot.querySelector('details'); + + expect(details).to.have.attribute('open'); + }); + + it('should have the open property set', () => { + expect(el.open).to.be.true; + }); + }); +}); diff --git a/docs/components/src/site-nav/nav-item.stories.ts b/docs/components/src/site-nav/nav-item.stories.ts new file mode 100644 index 0000000000..2bda668f33 --- /dev/null +++ b/docs/components/src/site-nav/nav-item.stories.ts @@ -0,0 +1,115 @@ +import { faBook, faBookmark, faCodeBranch, faFileLines, faHome } from '@fortawesome/pro-regular-svg-icons'; +import { Icon } from '@sl-design-system/icon'; +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { NavGroup } from './nav-group.js'; +import { NavItem } from './nav-item.js'; + +Icon.register(faBook, faBookmark, faCodeBranch, faFileLines, faHome); + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-nav-group', NavGroup); + customElements.define('doc-nav-item', NavItem); +} catch { + /* empty */ +} + +export default { + title: 'Site Navigation/Nav Item', + args: { + heading: 'Documentation', + href: '#', + icon: 'far-book' + }, + argTypes: { + icon: { + control: 'text' + } + }, + render: ({ active, heading, href, icon }) => { + return html` + + + + + `; + } +} satisfies Meta; + +export const Basic: Story = {}; + +export const Active: Story = { + args: { + active: true + } +}; + +export const WithoutIcon: Story = { + args: { + icon: undefined + } +}; + +export const Expandable: Story = { + args: { + heading: 'Guides', + href: undefined, + icon: 'far-bookmark' + }, + render: ({ active, heading, icon, open }) => { + return html` + + + + + + + + + `; + } +}; + +export const ExpandableOpen: Story = { + args: { + heading: 'Guides', + href: undefined, + icon: 'far-bookmark', + open: true + }, + render: Expandable.render +}; + +export const Nested: Story = { + render: () => { + return html` + + + + + + + + + + + + + `; + } +}; diff --git a/docs/components/src/site-nav/nav-item.ts b/docs/components/src/site-nav/nav-item.ts new file mode 100644 index 0000000000..a64f79767c --- /dev/null +++ b/docs/components/src/site-nav/nav-item.ts @@ -0,0 +1,127 @@ +import { faChevronRight } from '@fortawesome/pro-regular-svg-icons'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import styles from './nav-item.css' with { type: 'css' }; + +Icon.register(faChevronRight); + +export class NavItem extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-icon': Icon + }; + } + + /** @internal */ + static styles: CSSResultGroup = styles; + + /** Whether this is the active/current page. */ + @property({ type: Boolean, reflect: true }) active?: boolean; + + /** @internal Whether this item has child nav items. */ + @state() expandable = false; + + /** The display text for this nav item. */ + @property() heading?: string; + + /** The URL this item links to. */ + @property() href?: string; + + /** The icon name (for top-level items). */ + @property({ reflect: true }) icon?: string; + + /** @internal The nesting level (0-based), computed from DOM. */ + @state() level = 0; + + /** Whether the children are expanded. */ + @property({ type: Boolean, reflect: true }) open?: boolean; + + override connectedCallback(): void { + super.connectedCallback(); + + this.setAttribute('role', 'treeitem'); + + const style = document.createElement('style'); + style.innerText = ` + doc-nav-item:has(> doc-nav-item[active])::part(active) { + position-anchor: --active; + } + doc-nav-item > doc-nav-item[active]::part(leaf) { + anchor-name: --active; + } + `; + this.append(style); + + // Compute nesting level by counting ancestor nav-item elements + let level = 0, + parent = this.parentElement; + + while (parent) { + if (parent.localName === this.localName) { + level++; + } + parent = parent.parentElement; + } + + this.level = level; + this.dataset.level = String(level); + this.style.setProperty('--nav-level', String(level)); + + // Pre-detect expandability from light DOM children + this.expandable = !!this.querySelector(this.localName); + } + + override updated(): void { + if (this.expandable) { + this.setAttribute('aria-expanded', Boolean(this.open).toString()); + } else { + this.removeAttribute('aria-expanded'); + } + } + + override render(): TemplateResult { + if (this.expandable) { + return html` +
+ + ${this.icon ? html`` : nothing} + ${this.href + ? html`${this.heading}` + : html`${this.heading}`} + + +
+ + +
+
+ `; + } + + return html` + + ${this.icon ? html`` : nothing} ${this.heading} + + + `; + } + + #onSlotChange(event: Event & { target: HTMLSlotElement }): void { + const children = event.target.assignedElements({ flatten: true }); + + this.expandable = children.some(child => child.localName === this.localName); + } + + #onToggle(event: Event & { target: HTMLDetailsElement }): void { + this.open = event.target.open; + } +} diff --git a/docs/components/src/site-nav/site-nav.css b/docs/components/src/site-nav/site-nav.css new file mode 100644 index 0000000000..9891b76e5c --- /dev/null +++ b/docs/components/src/site-nav/site-nav.css @@ -0,0 +1,8 @@ +:host { + display: block; +} + +nav { + display: flex; + flex-direction: column; +} diff --git a/docs/components/src/site-nav/site-nav.spec.ts b/docs/components/src/site-nav/site-nav.spec.ts new file mode 100644 index 0000000000..574198ba62 --- /dev/null +++ b/docs/components/src/site-nav/site-nav.spec.ts @@ -0,0 +1,60 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it } from 'vitest'; + +const { SiteNav, NavGroup, NavItem } = await import('@sl-design-system/doc-components/site-nav/site-nav'); + +try { + customElements.define('doc-site-nav', SiteNav); + customElements.define('doc-nav-group', NavGroup); + customElements.define('doc-nav-item', NavItem); +} catch { + /* empty */ +} + +describe('doc-site-nav', () => { + let el: InstanceType; + + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render', () => { + expect(el).to.exist; + expect(el).to.be.instanceOf(SiteNav); + }); + + it('should render a nav element', () => { + const nav = el.renderRoot.querySelector('nav'); + + expect(nav).to.exist; + }); + + it('should have an aria-label on the nav', () => { + const nav = el.renderRoot.querySelector('nav'); + + expect(nav).to.have.attribute('aria-label', 'Site navigation'); + }); + + it('should render a slot for children', () => { + const slot = el.renderRoot.querySelector('slot'); + + expect(slot).to.exist; + }); + + it('should slot nav-group children', async () => { + el = await fixture(html` + + + + + + `); + + const slot = el.renderRoot.querySelector('slot') as HTMLSlotElement, + assigned = slot.assignedElements({ flatten: true }); + + expect(assigned).to.have.length(1); + expect(assigned[0].localName).to.equal('doc-nav-group'); + }); +}); diff --git a/docs/components/src/site-nav/site-nav.stories.ts b/docs/components/src/site-nav/site-nav.stories.ts new file mode 100644 index 0000000000..2e2c517e03 --- /dev/null +++ b/docs/components/src/site-nav/site-nav.stories.ts @@ -0,0 +1,171 @@ +import { + faBook, + faBookmark, + faCircleQuestion, + faCodeBranch, + faEnvelope, + faFileLines, + faHome, + faInfoCircle, + faLayerGroup, + faShieldCheck +} from '@fortawesome/pro-regular-svg-icons'; +import { Icon } from '@sl-design-system/icon'; +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { NavGroup } from './nav-group.js'; +import { NavItem } from './nav-item.js'; +import { SiteNav } from './site-nav.js'; + +Icon.register( + faBook, + faBookmark, + faCircleQuestion, + faCodeBranch, + faEnvelope, + faFileLines, + faHome, + faInfoCircle, + faLayerGroup, + faShieldCheck +); + +type Props = Record; +type Story = StoryObj; + +try { + customElements.define('doc-site-nav', SiteNav); + customElements.define('doc-nav-group', NavGroup); + customElements.define('doc-nav-item', NavItem); +} catch { + /* empty */ +} + +function onNavClick(event: Event): void { + const nav = event.currentTarget as HTMLElement, + target = (event.target as HTMLElement).closest('doc-nav-item'); + + if (!target || target.querySelector('doc-nav-item')) { + return; + } + + event.preventDefault(); + nav.querySelectorAll('doc-nav-item[active]').forEach(item => item.removeAttribute('active')); + target.setAttribute('active', ''); +} + +export default { + title: 'Site Navigation', + render: () => { + return html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + } +} satisfies Meta; + +export const Basic: Story = {}; + +export const Collapsed: Story = { + render: () => { + return html` + + + + + + + + + + + + + + `; + } +}; + +export const FlatItems: Story = { + render: () => { + return html` + + + + + + + + + `; + } +}; + +export const ActiveItem: Story = { + render: () => { + return html` + + + + + + + + + + + + + + + + + + `; + } +}; diff --git a/docs/components/src/site-nav/site-nav.ts b/docs/components/src/site-nav/site-nav.ts new file mode 100644 index 0000000000..81c2dc72bd --- /dev/null +++ b/docs/components/src/site-nav/site-nav.ts @@ -0,0 +1,237 @@ +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { type PageToc } from '../page-toc/page-toc.js'; +import { NavGroup } from './nav-group.js'; +import { NavItem } from './nav-item.js'; +import styles from './site-nav.css' with { type: 'css' }; + +const insideStorybook = location.pathname.startsWith('/iframe.html'); + +export class SiteNav extends LitElement { + /** @internal */ + static styles: CSSResultGroup = styles; + + override connectedCallback(): void { + super.connectedCallback(); + + this.addEventListener('keydown', this.#onKeydown); + + navigation.addEventListener('navigate', this.#onNavigate); + + // Set up roving tabindex after children are parsed + requestAnimationFrame(() => this.#initTabIndex()); + } + + override disconnectedCallback(): void { + navigation.removeEventListener('navigate', this.#onNavigate); + this.removeEventListener('keydown', this.#onKeydown); + + super.disconnectedCallback(); + } + + override render(): TemplateResult { + return html` + + `; + } + + #onKeydown = (event: KeyboardEvent): void => { + const items = this.#getVisibleItems(), + current = items.indexOf(document.activeElement as NavItem); + + if (current === -1 && !['Home', 'End'].includes(event.key)) { + return; + } + + let handled = true; + + switch (event.key) { + case 'ArrowDown': + this.#focusItem(items, Math.min(current + 1, items.length - 1)); + break; + case 'ArrowUp': + this.#focusItem(items, Math.max(current - 1, 0)); + break; + case 'ArrowRight': + if (items[current]?.expandable && !items[current].open) { + items[current].open = true; + } else if (items[current]?.expandable && items[current].open) { + // Move to first child + const next = current + 1; + if (next < items.length) { + this.#focusItem(items, next); + } + } + break; + case 'ArrowLeft': + if (items[current]?.expandable && items[current].open) { + items[current].open = false; + } else { + // Move to parent nav-item + let parent = items[current]?.parentElement; + while (parent && parent !== this) { + if (parent instanceof NavItem) { + const parentIdx = items.indexOf(parent); + if (parentIdx >= 0) { + this.#focusItem(items, parentIdx); + } + break; + } + parent = parent.parentElement; + } + } + break; + case 'Home': + this.#focusItem(items, 0); + break; + case 'End': + this.#focusItem(items, items.length - 1); + break; + case 'Enter': + case ' ': + if (items[current]?.href) { + window.location.href = items[current].href!; + } else if (items[current]?.expandable) { + items[current].open = !items[current].open; + } + break; + default: + handled = false; + } + + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + #onNavigate = (event: NavigateEvent): void => { + // Return early if the event can't be intercepted or is a download request + if (!event.canIntercept || event.downloadRequest !== null) { + return; + } + + // Return early if the navigation is just a hash change on the same page + if (!insideStorybook && event.hashChange) { + return; + } + + // Capture old active item before removing + const oldActiveItem = this.querySelector('doc-nav-item[active]'); + + // Remove the active state from the currently active item, if any + oldActiveItem?.removeAttribute('active'); + + // Determine the active nav-item: either the clicked one or the one matching the destination URL + const rootNode = event.sourceElement?.getRootNode(), + clickedItem = rootNode instanceof ShadowRoot ? rootNode.host : null, + destinationPath = new URL(event.destination.url).pathname.replace(/\/$/, ''), + activeItem = + clickedItem ?? + Array.from(this.querySelectorAll('doc-nav-item')).find( + item => + item.href && + new URL(item.href, location.href).pathname.replace(/\/$/, '') === destinationPath + ) ?? + null; + + activeItem?.setAttribute('active', ''); + + // In Storybook, just update the URL and let Storybook handle the rest + if (insideStorybook) { + return; + } + + event.intercept({ + handler: async () => { + await this.#updateNavTree(oldActiveItem, activeItem); + + const response = await fetch(new URL(event.destination.url)), + text = await response.text(), + doc = new DOMParser().parseFromString(text, 'text/html'), + newContent = doc.querySelector('main.content'), + currentContent = document.querySelector('main.content'); + + if (newContent && currentContent) { + document.title = doc.title; + currentContent.innerHTML = newContent.innerHTML; + + document.querySelector('doc-page-toc')?.refresh(); + } + } + }); + }; + + async #updateNavTree(oldActiveItem: NavItem | null, activeItem: Element | null): Promise { + const newAncestors = new Set(); + let p: Element | null = activeItem?.parentElement ?? null; + while (p) { + newAncestors.add(p); + p = p.parentElement; + } + + let oldParent: Element | null = oldActiveItem?.parentElement ?? null; + while (oldParent) { + if (!newAncestors.has(oldParent)) { + if (oldParent instanceof NavItem && oldParent.expandable) { + oldParent.open = false; + } else if (oldParent instanceof NavGroup && oldParent.collapsible) { + oldParent.collapsed = true; + } + } + oldParent = oldParent.parentElement; + } + + // If the active item is itself an expandable category, expand it as well so + // navigating to a category page also reveals its children. + if (activeItem instanceof NavItem && activeItem.expandable) { + activeItem.open = true; + } + + let parent: Element | null = activeItem?.parentElement ?? null; + while (parent) { + if (parent instanceof NavItem && parent.expandable) { + parent.open = true; + } else if (parent instanceof NavGroup) { + parent.collapsed = false; + } + parent = parent.parentElement; + } + + await Promise.resolve(); + activeItem?.scrollIntoView({ block: 'nearest' }); + } + + /** Returns all visible (not inside a collapsed parent) nav-items in DOM order. */ + #getVisibleItems(): NavItem[] { + return Array.from(this.querySelectorAll('*')) + .filter((el): el is NavItem => el instanceof NavItem) + .filter(item => { + let parent = item.parentElement; + + while (parent && parent !== this) { + if (parent instanceof NavItem && parent.expandable && !parent.open) { + return false; + } + parent = parent.parentElement; + } + + return true; + }); + } + + #focusItem(items: NavItem[], index: number): void { + items.forEach((item, i) => { + item.tabIndex = i === index ? 0 : -1; + }); + items[index]?.focus(); + } + + #initTabIndex(): void { + const items = this.#getVisibleItems(); + items.forEach((item, i) => { + item.tabIndex = i === 0 ? 0 : -1; + }); + } +} diff --git a/docs/components/src/theme-switch/theme-switch.spec.ts b/docs/components/src/theme-switch/theme-switch.spec.ts new file mode 100644 index 0000000000..5644f8b56a --- /dev/null +++ b/docs/components/src/theme-switch/theme-switch.spec.ts @@ -0,0 +1,138 @@ +import { type Switch } from '@sl-design-system/switch'; +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { type ThemeSwitch } from './theme-switch.js'; + +// Register the component for testing +const { ThemeSwitch: ThemeSwitchClass } = await import('./theme-switch.js'); + +try { + customElements.define('doc-theme-switch', ThemeSwitchClass); +} catch { + /* empty */ +} + +describe('doc-theme-switch', () => { + let el: ThemeSwitch; + + describe('defaults', () => { + beforeEach(async () => { + document.documentElement.removeAttribute('data-color-scheme'); + + el = await fixture(html``); + }); + + it('should render a switch', () => { + const switchEl = el.renderRoot.querySelector('sl-switch'); + + expect(switchEl).to.exist; + }); + + it('should have a sun icon for the off state', () => { + const switchEl = el.renderRoot.querySelector('sl-switch'); + + expect(switchEl).to.have.attribute('icon-off', 'fas-sun-bright'); + }); + + it('should have a moon icon for the on state', () => { + const switchEl = el.renderRoot.querySelector('sl-switch'); + + expect(switchEl).to.have.attribute('icon-on', 'fas-moon-stars'); + }); + + it('should have an aria-label on the switch', () => { + const switchEl = el.renderRoot.querySelector('sl-switch'); + + expect(switchEl).to.have.attribute('aria-label', 'Switch between light and dark mode'); + }); + + it('should set the data-color-scheme attribute on the document', () => { + expect(document.documentElement).to.have.attribute('data-color-scheme'); + }); + + it('should have a medium switch by default', () => { + const switchEl = el.renderRoot.querySelector('sl-switch'); + + expect(switchEl).to.not.have.attribute('size'); + }); + }); + + describe('light mode', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have the color-scheme attribute set to light', () => { + expect(el).to.have.attribute('color-scheme', 'light'); + }); + + it('should not have the switch checked', () => { + const switchEl = el.renderRoot.querySelector('sl-switch'); + + expect(switchEl?.checked).to.not.be.ok; + }); + + it('should set data-color-scheme to light on the document', () => { + expect(document.documentElement).to.have.attribute('data-color-scheme', 'light'); + }); + }); + + describe('dark mode', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have the color-scheme attribute set to dark', () => { + expect(el).to.have.attribute('color-scheme', 'dark'); + }); + + it('should have the switch checked', () => { + const switchEl = el.renderRoot.querySelector('sl-switch'); + + expect(switchEl?.checked).to.be.true; + }); + + it('should set data-color-scheme to dark on the document', () => { + expect(document.documentElement).to.have.attribute('data-color-scheme', 'dark'); + }); + }); + + describe('toggling', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should switch to dark mode when the switch is toggled', async () => { + const switchEl = el.renderRoot.querySelector('sl-switch')!; + + switchEl.click(); + await el.updateComplete; + + expect(el.colorScheme).to.equal('dark'); + expect(document.documentElement).to.have.attribute('data-color-scheme', 'dark'); + }); + + it('should switch back to light mode when toggled again', async () => { + const switchEl = el.renderRoot.querySelector('sl-switch')!; + + switchEl.click(); + await el.updateComplete; + + switchEl.click(); + await el.updateComplete; + + expect(el.colorScheme).to.equal('light'); + expect(document.documentElement).to.have.attribute('data-color-scheme', 'light'); + }); + + it('should reflect the color-scheme attribute when toggled', async () => { + const switchEl = el.renderRoot.querySelector('sl-switch')!; + + switchEl.click(); + await el.updateComplete; + + expect(el).to.have.attribute('color-scheme', 'dark'); + }); + }); +}); diff --git a/docs/components/src/theme-switch/theme-switch.stories.ts b/docs/components/src/theme-switch/theme-switch.stories.ts new file mode 100644 index 0000000000..8159faa685 --- /dev/null +++ b/docs/components/src/theme-switch/theme-switch.stories.ts @@ -0,0 +1,34 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { ThemeSwitch } from './theme-switch.js'; + +type Props = Pick; +type Story = StoryObj; + +try { + customElements.define('doc-theme-switch', ThemeSwitch); +} catch { + /* empty */ +} + +export default { + title: 'Theme Switch', + args: { + colorScheme: 'light' + }, + argTypes: { + colorScheme: { + control: 'inline-radio', + options: ['light', 'dark'] + } + }, + render: ({ colorScheme }) => html`` +} satisfies Meta; + +export const Light: Story = {}; + +export const Dark: Story = { + args: { + colorScheme: 'dark' + } +}; diff --git a/docs/components/src/theme-switch/theme-switch.ts b/docs/components/src/theme-switch/theme-switch.ts new file mode 100644 index 0000000000..3a5c7d8bfc --- /dev/null +++ b/docs/components/src/theme-switch/theme-switch.ts @@ -0,0 +1,62 @@ +import { faMoonStars, faSunBright } from '@fortawesome/pro-solid-svg-icons'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { Switch } from '@sl-design-system/switch'; +import { LitElement, type TemplateResult, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +export type ColorScheme = 'light' | 'dark'; + +Icon.register(faMoonStars, faSunBright); + +export class ThemeSwitch extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-switch': Switch + }; + } + + /** The current color scheme. */ + @property({ reflect: true, attribute: 'color-scheme' }) colorScheme: ColorScheme = 'light'; + + connectedCallback(): void { + super.connectedCallback(); + + if (!this.hasAttribute('color-scheme')) { + this.colorScheme = this.#getPreferredColorScheme(); + } + + this.#applyColorScheme(); + } + + render(): TemplateResult { + return html` + + `; + } + + #onChange(): void { + this.colorScheme = this.colorScheme === 'light' ? 'dark' : 'light'; + this.#applyColorScheme(); + } + + #applyColorScheme(): void { + document.documentElement.setAttribute('data-color-scheme', this.colorScheme); + + const themeLink = document.getElementById('theme-css') as HTMLLinkElement | null; + if (themeLink) { + themeLink.href = `/theme/${this.colorScheme}.css`; + } + } + + #getPreferredColorScheme(): ColorScheme { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } +} diff --git a/docs/components/tsconfig.json b/docs/components/tsconfig.json new file mode 100644 index 0000000000..f8e301ba70 --- /dev/null +++ b/docs/components/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023", "DOM", "DOM.Iterable"], + "moduleDetection": "force", + "module": "preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "emitDeclarationOnly": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["env.d.ts", "src"] +} diff --git a/docs/components/tsdown.config.ts b/docs/components/tsdown.config.ts new file mode 100644 index 0000000000..54d50d246f --- /dev/null +++ b/docs/components/tsdown.config.ts @@ -0,0 +1,46 @@ +import { importCssSheet } from '@sl-design-system/rolldown-plugin-css-sheet'; +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + clean: !process.argv.includes('--watch'), + deps: { + onlyBundle: false + }, + dts: { + tsgo: true + }, + entry: [ + 'src/code/code.ts', + 'src/code-block/code-block.ts', + 'src/code-example/code-example.ts', + 'src/command-palette/command-palette.ts', + 'src/copy-button/copy-button.ts', + 'src/heading/heading.ts', + 'src/install-info/install-info.ts', + 'src/open-issue-count/open-issue-count.ts', + 'src/page-toc/page-toc.ts', + 'src/search/search.ts', + 'src/sidebar/sidebar.ts', + 'src/site-nav/nav-group.ts', + 'src/site-nav/nav-item.ts', + 'src/site-nav/site-nav.ts', + 'src/theme-switch/theme-switch.ts' + ], + exports: { + customExports(exports) { + return Object.fromEntries( + Object.entries(exports).map(([key, value]) => { + // Skip the root export and keys that already have a file extension (e.g. './foo.js') + if (key === '.' || /\.[^/]+$/.test(key)) { + return [key, value]; + } + + return [`${key}.js`, value]; + }) + ); + } + }, + hash: false, + platform: 'browser', + plugins: [importCssSheet()] +}); diff --git a/docs/website/eleventy.config.js b/docs/website/eleventy.config.js new file mode 100644 index 0000000000..8ba8e95a76 --- /dev/null +++ b/docs/website/eleventy.config.js @@ -0,0 +1,185 @@ +import eleventyNavigationPlugin from '@11ty/eleventy-navigation'; +import * as esbuild from 'esbuild'; +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { basename, dirname, join, resolve } from 'node:path'; +import { parse as HTMLParse } from 'node-html-parser'; +import { anchorHeadingsTransformer } from './src/transformers/anchor-headings.js'; +import { codeExamplesTransformer } from './src/transformers/code-examples.js'; +import { highlightCodeTransformer } from './src/transformers/highlight-code.js'; +import { searchPlugin } from './src/plugins/search.js'; +import { buildDesignTokens, designTokenIcons } from './src/utils/design-tokens.js'; +import { getComponents, getCustomElements } from './src/utils/manifest.js'; +import { markdown } from './src/utils/markdown.js'; + +const require = createRequire(import.meta.url); +const themePath = dirname(require.resolve('@sl-design-system/sanoma-learning/package.json')); + +/** @param {import('@11ty/eleventy').UserConfig} eleventyConfig */ +export default async function (eleventyConfig) { + let allComponents = await getComponents(), + customElements = await getCustomElements(); + + eleventyConfig.addGlobalData('customElements', customElements); + + // Design token documentation, grouped by category and generated from the + // token JSON in packages/tokens. See src/utils/design-tokens.js. + eleventyConfig.addGlobalData('designTokens', buildDesignTokens()); + + eleventyConfig.addPassthroughCopy({ 'src/assets': 'assets' }); + eleventyConfig.addPassthroughCopy({ 'src/css': 'css' }); + + eleventyConfig.addPassthroughCopy({ + [join(themePath, 'light.css')]: 'theme/light.css', + [join(themePath, 'dark.css')]: 'theme/dark.css', + [join(themePath, 'global.css')]: 'theme/global.css', + [join(themePath, 'fonts.css')]: 'theme/fonts.css', + [join(themePath, 'fonts')]: 'theme/fonts' + }); + + eleventyConfig.addWatchTarget('../components/dist/**/*.js'); + eleventyConfig.addWatchTarget('./custom-elements.json'); + eleventyConfig.addWatchTarget('./src/js/{main,theme}.js'); + eleventyConfig.setWatchThrottleWaitTime(1000); + + eleventyConfig.setServerOptions({ + domDiff: false + }); + + eleventyConfig.on('eleventy.beforeWatch', async changes => { + let updateComponents = false; + + for (const change of changes) { + if (change.includes('custom-elements.json') || change.includes('package.json')) { + updateComponents = true; + break; + } + } + + if (updateComponents) { + allComponents = await getComponents(); + } + }); + + eleventyConfig.addPlugin(eleventyNavigationPlugin); + + eleventyConfig.setLibrary('md', markdown); + + eleventyConfig.addNunjucksGlobal('getComponent', tagName => { + const component = allComponents.find(c => c.tagName === tagName); + if (!component) { + throw new Error( + `Unable to find "<${tagName}>". Make sure the file name is the same as the tag name (without prefix).` + ); + } + return component; + }); + + const componentPageUrlMap = new Map(); + + eleventyConfig.addNunjucksGlobal('getComponentPageUrl', packageName => componentPageUrlMap.get(packageName) ?? null); + + eleventyConfig.addCollection('componentPages', function (collectionApi) { + const componentPages = collectionApi.getFilteredByGlob( + join(eleventyConfig.directories.input, 'components/**/*.md') + ); + + return componentPages.map(page => { + const componentName = basename(page.inputPath, '.md'), + tagName = `sl-${componentName}`, + component = allComponents.find(c => c.tagName === tagName); + + // Add component to the page's data + if (component) { + page.data.component = component; + componentPageUrlMap.set(component.packageName, page.url); + } + + return page; + }); + }); + + eleventyConfig.addTransform('component', function (content) { + let doc = HTMLParse(content, { blockTextElements: { code: true } }); + + const transformers = [anchorHeadingsTransformer(), codeExamplesTransformer(), highlightCodeTransformer()]; + + for (const transformer of transformers) { + transformer.call(this, doc); + } + + return doc.toString(); + }); + + // Build the client-side search index. Registered after the `component` transform + // so headings and other content transforms are already applied. + eleventyConfig.addPlugin( + searchPlugin({ + selectorsToIgnore: ['doc-code-example', 'doc-page-toc', 'pre'], + getTitle: doc => (doc.querySelector('title')?.textContent ?? '').replace(/\s*\|\s*SL Design System\s*$/, ''), + getDescription: doc => doc.querySelector('main.content p')?.textContent ?? '', + getContent: doc => doc.querySelector('main.content')?.textContent ?? '' + }) + ); + + eleventyConfig.on('eleventy.before', async () => { + // Scan pages for icon names in eleventyNavigation frontmatter + const icons = new Set(), + files = readdirSync('src', { recursive: true }); + + for (const file of files) { + if (typeof file !== 'string' || !file.endsWith('.md')) continue; + + const content = readFileSync(join('src', file), 'utf-8'), + frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/), + iconMatch = frontmatterMatch?.[1].match(/^\s+icon:\s*(\S+)/m); + + if (iconMatch) { + icons.add(iconMatch[1]); + } + } + + // The design token pages are generated from a template, so their nav icons + // aren't picked up by the frontmatter scan above; register them explicitly. + designTokenIcons.forEach(icon => icons.add(icon)); + + // Generate icon registration module + let iconsJs = '// Auto-generated from page frontmatter icons\n'; + + if (icons.size > 0) { + const importNames = [...icons].map( + name => + 'fa' + + name + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + ); + + iconsJs += `import { ${importNames.join(', ')} } from '@fortawesome/pro-regular-svg-icons';\n`; + iconsJs += `import { Icon } from '@sl-design-system/icon';\n\n`; + iconsJs += `Icon.register(${importNames.join(', ')});\n`; + } + + writeFileSync('src/js/icons.js', iconsJs); + + await esbuild.build({ + entryPoints: ['src/js/main.js'], + bundle: true, + format: 'esm', + outdir: 'dist/js', + supported: { decorators: false } + }); + }); + + return { + dir: { + includes: '../includes', + input: 'src/content', + layouts: '../layouts', + output: 'dist' + }, + templateFormats: ['njk', 'md'], + markdownTemplateEngine: 'njk' + }; +} diff --git a/docs/website/package.json b/docs/website/package.json new file mode 100644 index 0000000000..64bde131f2 --- /dev/null +++ b/docs/website/package.json @@ -0,0 +1,49 @@ +{ + "name": "@sl-design-system/website-v2", + "private": true, + "type": "module", + "scripts": { + "build": "wireit", + "start": "wireit" + }, + "wireit": { + "build": { + "command": "eleventy", + "output": [ + "dist" + ], + "dependencies": [ + "../components:build", + "metadata" + ] + }, + "metadata": { + "command": "cem generate --config ../../.cem.yaml --output ./custom-elements.json" + }, + "start": { + "command": "eleventy --serve", + "dependencies": [ + "../components:build", + "metadata" + ] + } + }, + "dependencies": { + "@11ty/eleventy": "^4.0.0-alpha.7", + "@11ty/eleventy-navigation": "^1.0.5", + "@fortawesome/pro-regular-svg-icons": "^7.2.0", + "@pwrs/cem": "0.10.3", + "@webcomponents/scoped-custom-element-registry": "^0.0.10", + "esbuild": "^0.25.0", + "lit": "^3.3.2", + "markdown-it": "^14.1.1", + "markdown-it-attrs": "^4.3.1", + "markdown-it-container": "^4.0.0", + "markdown-it-deflist": "^3.0.0", + "minisearch": "^7.2.0", + "node-html-parser": "^7.1.0", + "prismjs": "^1.30.0", + "slugify": "^1.6.9", + "wireit": "^0.14.12" + } +} diff --git a/docs/website/src/assets/logo-black.svg b/docs/website/src/assets/logo-black.svg new file mode 100644 index 0000000000..c697850c88 --- /dev/null +++ b/docs/website/src/assets/logo-black.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/website/src/assets/logo.svg b/docs/website/src/assets/logo.svg new file mode 100644 index 0000000000..e9ca6e2005 --- /dev/null +++ b/docs/website/src/assets/logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/website/src/content/api-reference/api-reference.md b/docs/website/src/content/api-reference/api-reference.md new file mode 100644 index 0000000000..baec8baa15 --- /dev/null +++ b/docs/website/src/content/api-reference/api-reference.md @@ -0,0 +1,9 @@ +--- +title: API Reference +layout: docs +eleventyNavigation: + key: API Reference + order: 3 + collapsible: true + collapsed: true +--- diff --git a/docs/website/src/content/api-reference/api-reference.njk b/docs/website/src/content/api-reference/api-reference.njk new file mode 100644 index 0000000000..ed2003ebf1 --- /dev/null +++ b/docs/website/src/content/api-reference/api-reference.njk @@ -0,0 +1,241 @@ +---js +{ + layout: 'docs', + pagination: { + data: 'customElements', + size: 1, + alias: 'element', + addAllPagesToCollections: true + }, + permalink: data => `api-reference/${data.element.tagName}/index.html`, + eleventyComputed: { + title: data => data.element.title, + eleventyNavigation: { + key: data => data.element.title, + parent: 'API Reference' + } + } +} +--- + +{% set component = getComponent(element.tagName) %} +{% set componentPageUrl = getComponentPageUrl(element.packageName) %} + +{% if componentPageUrl %} +

Part of the {{ component.name }} component.

+{% endif %} + + + +{% if component.summary %} +

{{ component.summary | safe }}

+{% endif %} + +

Slots

+{% if element.slots and element.slots.length %} + + + + + + + + + {% for slot in element.slots %} + + + + + {% endfor %} + +
NameDescription
{% if slot.name %}{{ slot.name }}{% else %}(default){% endif %}{{ slot.description | safe }}
+{% else %} +

No slots defined.

+{% endif %} + +

Attributes & Properties

+{% if element.attributesAndProperties and element.attributesAndProperties.length %} + + + + + + + + + + {% for item in element.attributesAndProperties %} + + + + + + {% endfor %} + +
NameDescriptionReflects
+ {% if item.static %}static {% endif %}{{ item.name }} + {% if item.attribute and item.attribute != item.name %}
{{ item.attribute }}{% endif %} +
+ {{ item.description | safe }} + {% if item.type or item.default %} +
+ {% if item.type %}
Type
{{ item.type.text }}
{% endif %} + {% if item.default %}
Default
{{ item.default }}
{% endif %} +
+ {% endif %} +
{% if item.reflects %}{% endif %}
+{% else %} +

No attributes or properties defined.

+{% endif %} + +

Methods

+{% if element.methods and element.methods.length %} + + + + + + + + + + {% for method in element.methods %} + + + + + + {% endfor %} + +
NameParametersDescription
{% if method.static %}static {% endif %}{{ method.name }}() + {% if method.parameters and method.parameters.length %} + {% for param in method.parameters %}{{ param.name }}{% if param.type %}: {{ param.type.text }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %} + {% endif %} + + {{ method.description | safe }} + {% if method.return and method.return.type and method.return.type.text != "void" %} +
+
Returns
{{ method.return.type.text }}
+
+ {% endif %} +
+{% else %} +

No methods defined.

+{% endif %} + +

Events

+{% if element.events and element.events.length %} + + + + + + + + + {% for event in element.events %} + + + + + {% endfor %} + +
NameDescription
{{ event.name }}{{ event.description | safe }}
+{% else %} +

No events defined.

+{% endif %} + +

CSS custom properties

+{% if element.cssProperties and element.cssProperties.length %} + + + + + + + + + {% for prop in element.cssProperties %} + + + + + {% endfor %} + +
NameDescription
{{ prop.name }}{{ prop.description | safe }}
+{% else %} +

No CSS custom properties defined.

+{% endif %} + +

CSS custom states

+{% if element.cssStates and element.cssStates.length %} + + + + + + + + + + {% for state in element.cssStates %} + + + + + + {% endfor %} + +
NameDescriptionCSS selector
{{ state.name }}{{ state.description | safe }}:state({{ state.name }})
+{% else %} +

No CSS custom states defined.

+{% endif %} + +

CSS parts

+{% if element.cssParts and element.cssParts.length %} + + + + + + + + + + {% for part in element.cssParts %} + + + + + + {% endfor %} + +
NameDescriptionCSS selector
{{ part.name }}{{ part.description | safe }}::part({{ part.name }})
+{% else %} +

No CSS parts defined.

+{% endif %} diff --git a/docs/website/src/content/components/actions/actions.md b/docs/website/src/content/components/actions/actions.md new file mode 100644 index 0000000000..db79cfde0c --- /dev/null +++ b/docs/website/src/content/components/actions/actions.md @@ -0,0 +1,32 @@ +--- +title: Actions +layout: docs +eleventyNavigation: + key: Actions + parent: Components + order: 1 + icon: bolt +--- + +Action components let users trigger operations, make choices or navigate. They are the primary way +users interact with an application. + +## Overview + +[Button](/components/actions/button) +: Triggers an action or navigates. + +[Button bar](/components/actions/button-bar) +: Groups related buttons together in a bar. + +[Menu button](/components/actions/menu-button) +: A button that opens a menu of actions. + +[Toggle button](/components/actions/toggle-button) +: A button that switches between a pressed and unpressed state. + +[Toggle group](/components/actions/toggle-group) +: A set of toggle buttons where one or more can be active. + +[Switch](/components/actions/switch) +: A single on/off toggle. diff --git a/docs/website/src/content/components/actions/button-bar.md b/docs/website/src/content/components/actions/button-bar.md new file mode 100644 index 0000000000..eb8ce1681a --- /dev/null +++ b/docs/website/src/content/components/actions/button-bar.md @@ -0,0 +1,186 @@ +--- +title: Button bar +layout: component +issueNumber: 2315 +storybook: actions-button-bar--basic +eleventyNavigation: + key: Button bar + parent: Actions +--- + +```html {.example .show-source} + + Foo + Bar + Baz + +``` + +## Usage + +Use a button bar to group related buttons side by side with consistent spacing. The button bar automatically wraps buttons to the next line when there is not enough horizontal space. + +Use a button bar instead of manually laying out buttons at every place where multiple actions are needed. This achieves consistency across your application, including different layouts on different viewports. + +Typical use cases include: + +- **Form actions**: Group actions like "Submit", "Cancel", or "Reset" together at the bottom of a form. +- **Navigation**: Guide users through multi-step flows with "Next", "Previous", or "Finish" buttons. +- **Content actions**: Group actions like "Edit", "Delete", or "Save" for a specific piece of content in a logically cohesive way. + +::: info +Do not use a button bar when you need a toolbar — a UI region that contains controls for operating on the content of an application (such as text formatting controls or drawing tools). A toolbar has distinct keyboard navigation semantics (`role="toolbar"` with arrow-key navigation between items) and is intended for persistent, application-level controls rather than contextual actions. Use the `` component for that purpose instead. +::: + +## Examples + +### Alignment + +Use the `align` attribute to control how buttons are distributed along the horizontal axis. + +Start +: Buttons are aligned to the left (default). + +Center +: Buttons are centered in the bar. + +End +: Buttons are aligned to the right. + +Space between +: Buttons are spread across the full width with space between them. + +```html {.example .justify-stretch .vertical} + + Foo + Bar + Baz + + + Foo + Bar + Baz + + + Foo + Bar + Baz + + + Foo + Bar + Baz + +``` + +### Reverse + +Use the `reverse` attribute to reverse the visual order of the buttons. This is useful when you want a primary action to appear on the right but still be first in the DOM order for accessibility. + +```html {.example .justify-stretch} + + Save + Cancel + +``` + +### Size + +Use the `size` attribute to set the size of all buttons in the bar at once, instead of setting it individually on each button. + +```html {.example .justify-stretch .vertical} + + Foo + Bar + Baz + + + Foo + Bar + Baz + + + Foo + Bar + Baz + +``` + +### Fill + +Use the `fill` attribute to set the fill of all buttons in the bar at once. + +```html {.example .vertical} + + Foo + Bar + Baz + + + Foo + Bar + Baz + + + Foo + Bar + Baz + +``` + +### Variant + +Use the `variant` attribute to set the variant of all buttons in the bar at once. + +```html {.example .justify-stretch} + + Save + Cancel + +``` + +### Wrapping + +When there is not enough horizontal space, buttons automatically wrap to the next line. + +```html {.example} + + Lorem + dolor + sit + amet + officia + esse + sunt + nulla + et + sint + nostrud + nisi + +``` + +### Icon only + +When all slotted buttons are icon-only ghost buttons, the button bar automatically reduces the gap between them. + +```html {.example} + + + + + Home + + + + Pinata + + + + Smile + +``` + +## Accessibility + +The button bar is a layout component and does not add any ARIA roles or attributes. Each slotted button retains its own accessible semantics. Make sure each button has an accessible name — especially icon-only buttons, which require either an `aria-label` or an associated tooltip. diff --git a/docs/website/src/content/components/actions/button.md b/docs/website/src/content/components/actions/button.md new file mode 100644 index 0000000000..6fc1ab3e84 --- /dev/null +++ b/docs/website/src/content/components/actions/button.md @@ -0,0 +1,204 @@ +--- +title: Button +layout: component +issueNumber: 2315 +storybook: actions-button--basic +eleventyNavigation: + key: Button + parent: Actions +--- + +```html {.example .show-source} +Button +``` + +## Usage + +Buttons should be used in user interfaces when you want to provide users with a clear and actionable way to interact with a webpage, application, or device. + +Buttons are used to trigger specific actions or functions. For example, you can use a "Submit" button in a form to send user input to a server, or a "Save" button to save changes in an application. + +::: info +Do not disable a button by default and leave it up to the user to figure out why the action is unavailable. Instead, provide an explanation using a tooltip or by including helper text next to the button. +::: + +## Examples + +### Variants + +Use the `variant` attribute to indicate the urgency or sentiment of the action. + +Primary +: The most important action on the page; the next step in the main user flow. + +Secondary +: Secondary flows, or when there is no clear hierarchy (e.g. dashboards). + +Success +: Confirming a successful or completed operation. + +Info +: Neutral actions that provide additional context or information. + +Warning +: Actions requiring caution or extra user confirmation. + +Danger +: Irreversible or potentially destructive actions. + +```html {.example .horizontal} +Primary +Secondary +Success +Info +Warning +Danger +``` + +There is an additional `inverted` variant that is designed for use on dark backgrounds. + +```html {.example .inverted} +Inverted +``` + +### Fill + +Use the `fill` attribute to indicate how essential the action is. + +Solid +: Essential actions that move the user forward in the flow. + +Outline +: Important but optional; draws attention without blocking progress. + +Ghost +: Suggestive or secondary actions that shouldn't distract from the main flow. + +Link +: Informational actions, such as "Read more" or "View details". + +```html {.example .horizontal} +Solid +Outline +Ghost +Link +``` + +### Shape + +Use the `shape` attribute to change the button's shape. + +```html {.example .horizontal} +Square +Pill + + + +``` + +### Size + +Buttons are available in three sizes: + +- `sm` — Small, for compact UIs +- `md` — Medium, the default +- `lg` — Large, for prominent actions + +```html {.example .horizontal} +Small +Medium +Large +``` + +### Disabled + +You can either use the `disabled` attribute to disable a button or set the `aria-disabled="true"` attribute for accessibility. Both will not trigger any events. The former will prevent the button from being focusable, while the latter will keep the button focusable but will indicate to assistive technologies that the action is unavailable. + +```html {.example .horizontal} +Disabled Aria Disabled +``` + +The `aria-disabled="true"` attribute should not be used as a one-for-one replacement for the `disabled` attribute because they have different functionalities. + +Both: + +- visually dim the button +- prevent actions (click, enter/space) on the button +- announce the button as 'dimmed' or 'disabled' in a screenreader + +However, there are some differences: + +- `disabled` takes the button out of the tab-focus sequence, `aria-disabled` does not +- `disabled` disables all pointer events, `aria-disabled` does not + +The difference can be useful when you want to combine a disabled button with a tooltip. In that case you want the button to be focusable (so you can hover or tab to it) but you also want it to be dimmed and not clickable. In that case you would use `aria-disabled` instead of `disabled`. + +When `disabled` is added to a button there is no need to also add `aria-disabled`; everything `aria-disabled` does, `disabled` does as well. + +You can read more on the difference and in which scenarios which option might be preferable on the [MDN page about aria-disabled](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-disabled). + +### Icon + +Icon buttons are used for actions that can be represented by an icon, such as "close" or "edit". Always provide a text label for accessibility, either through an `` or using `aria-label`. + +```html {.example} + + + + + + + + + + + Smile! + +``` + +### Command + +The button component supports the [Invoker Commands API](https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API). This allows you to declaratively connect a button to another element and invoke a command on it, without needing any JavaScript. + +Use the `command` attribute to specify the command to invoke, and the `commandfor` attribute to reference the `id` of the target element. The `id` must be in the same DOM scope as the button (i.e. the same document or shadow root). If you already have a JavaScript reference to the target element, you can set the `commandForElement` property directly instead. + +::: info +Note that not all browsers support the Invoker Commands API yet. When in doubt, use the [invokers-polyfill](https://github.com/keithamus/invokers-polyfill) to ensure compatibility. +::: + +Custom elements cannot use the same commands as native elements, but they can define their own custom commands. For example, the `` component defines a `--show-modal` command to open the dialog and a `--close` command to close it. + +```html {.example} + Open dialog + +

Commands Example

+

This dialog was opened without any JavaScript.

+ Close +
+``` + +## Accessibility + +The button component renders a native `