diff --git a/.cem.yaml b/.cem.yaml new file mode 100644 index 0000000000..293c0be883 --- /dev/null +++ b/.cem.yaml @@ -0,0 +1,9 @@ +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' + output: 'custom-elements.json' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a2cd10210..2065bf4706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ "main" ] + branches: [ "main", "docs/website-v2" ] pull_request: - branches: [ "main" ] + branches: [ "main", "docs/website-v2" ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index c0bced118a..49103129d6 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: @@ -47,7 +49,7 @@ jobs: - uses: actions/configure-pages@v5 - uses: actions/upload-pages-artifact@v3 with: - path: 'website/dist' + path: 'docs/website/dist' - id: deployment uses: actions/deploy-pages@v4 @@ -67,7 +69,7 @@ jobs: with: azure_static_web_apps_api_token: ${{ secrets.WEBSITE_AZURE_STATIC_WEB_APPS_API_TOKEN }} action: upload - app_location: website/dist + app_location: docs/website/dist production_branch: main skip_api_build: true skip_app_build: true diff --git a/.gitignore b/.gitignore index fe090f5b1f..431c125b7f 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,5 @@ __snapshots__ # Intellij .idea/ + +*storybook.log diff --git a/chromatic/package.json b/chromatic/package.json index 5fbcea4d76..5f14cd7456 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..de99da17b1 --- /dev/null +++ b/docs/components/package.json @@ -0,0 +1,88 @@ +{ + "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", + "./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:^", + "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/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..156e4d1d28 --- /dev/null +++ b/docs/components/src/search/search.css @@ -0,0 +1,154 @@ +/* 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); +} + +.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..759a83a7a2 --- /dev/null +++ b/docs/components/src/search/search.spec.ts @@ -0,0 +1,145 @@ +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 contain placeholder results', () => { + const results = el.renderRoot.querySelectorAll('.results li'); + + expect(results.length).to.be.greaterThan(0); + }); + + 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..9a1ad30fef --- /dev/null +++ b/docs/components/src/search/search.ts @@ -0,0 +1,184 @@ +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 } from '@sl-design-system/search-field'; +import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import styles from './search.css' with { type: 'css' }; + +Icon.register(faMagnifyingGlass); + +export class Search extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-icon': Icon, + 'sl-search-field': SearchField + }; + } + + /** @internal */ + static styles: CSSResultGroup = styles; + + #onKeydown = (event: KeyboardEvent): void => { + if (event.key === 'k' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.#open(); + } + }; + + @query('dialog') dialog?: HTMLDialogElement; + + @state() activeIndex = -1; + + 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" + > + +
    +
  • + +
    + Button + Buttons represent actions that are available to the user. + docs/components/button +
    +
  • +
  • + +
    + Dialog + + Dialogs, sometimes called "modals", appear above the page content. + + docs/components/dialog +
    +
  • +
  • + +
    + Drawer + Drawers slide in from a container to expose additional options. + docs/components/drawer +
    +
  • +
  • + +
    + Visual Tests + A page to visually test component styles against native styles. + docs/resources/visual-tests +
    +
  • +
+
+
+ `; + } + + #open(): void { + this.dialog?.showModal(); + this.activeIndex = -1; + + requestAnimationFrame(() => { + this.renderRoot.querySelector('sl-search-field')?.focus(); + }); + } + + #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; + } + + const items = this.renderRoot.querySelectorAll('.results li'); + + if (!items.length) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.activeIndex = this.activeIndex < items.length - 1 ? this.activeIndex + 1 : 0; + items[this.activeIndex]?.scrollIntoView({ block: 'nearest' }); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.activeIndex = this.activeIndex > 0 ? this.activeIndex - 1 : items.length - 1; + items[this.activeIndex]?.scrollIntoView({ block: 'nearest' }); + } else if ((event.key === 'Enter' || event.key === ' ') && this.activeIndex >= 0) { + event.preventDefault(); + items[this.activeIndex]?.dispatchEvent(new Event('click', { bubbles: true })); + } + } +} 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..82f9aa0dfc --- /dev/null +++ b/docs/components/src/site-nav/site-nav.ts @@ -0,0 +1,229 @@ +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; + } + + 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..6de7670b60 --- /dev/null +++ b/docs/components/tsdown.config.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { defineConfig } from 'tsdown' + +// This plugin handles CSS imports with { type: 'css' } and converts them to CSSStyleSheet +const cssPlugin = { + name: 'css-stylesheet', + transform(code, id) { + // Check if this is a CSS import with type: 'css' attribute + const cssImportRegex = /import\s+(\w+)\s+from\s+['"]([^'"]+\.css)['"]\s+with\s+\{\s*type:\s*['"]css['"]\s*\}/g; + + let transformedCode = code, + hasTransformations = false; + + transformedCode = transformedCode.replace(cssImportRegex, (match, varName, cssPath) => { + hasTransformations = true; + + // Resolve the CSS file path relative to the current file + const resolvedPath = new URL(cssPath, `file://${id}`).pathname; + + try { + const cssContent = readFileSync(resolvedPath, 'utf-8'); + + this.addWatchFile(resolvedPath); // Watch the CSS file for changes + + // Create a CSSStyleSheet with the CSS content + return ` + const ${varName} = new CSSStyleSheet(); + ${varName}.replaceSync(${JSON.stringify(cssContent)}); +`; + } catch (error) { + this.error(`Failed to read CSS file ${resolvedPath}: ${error.message}`); + } + }); + + return hasTransformations ? { code: transformedCode, map: null } : null; + } +}; + +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/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: [cssPlugin] +}) diff --git a/docs/website/eleventy.config.js b/docs/website/eleventy.config.js new file mode 100644 index 0000000000..a06ae83d96 --- /dev/null +++ b/docs/website/eleventy.config.js @@ -0,0 +1,164 @@ +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 { 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); + + 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(); + }); + + 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]); + } + } + + // 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..ae2fb2a83b --- /dev/null +++ b/docs/website/package.json @@ -0,0 +1,48 @@ +{ + "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.9.19", + "@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", + "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..150d20c1cd --- /dev/null +++ b/docs/website/src/content/components/actions/actions.md @@ -0,0 +1,11 @@ +--- +title: Actions +layout: docs +eleventyNavigation: + key: Actions + parent: Components + order: 1 + icon: bolt +--- + +Action components allow users to trigger operations or navigate. 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 `