diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 28edc2f..5ef69c3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,15 @@ +## SemVer Impact + + + +- [ ] **Patch** – backwards-compatible bug fix +- [ ] **Minor** – new feature, new component, or new public prop (backwards-compatible) +- [ ] **Major** – breaking change to an existing public API +- [ ] **None** – docs, CI, refactor, or other non-release change + ## Changes - diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 68d8ccb..016802b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,8 +16,11 @@ jobs: lint: uses: ./.github/workflows/lint.yml + typecheck: + uses: ./.github/workflows/typecheck.yml + test: - needs: lint + needs: [lint, typecheck] uses: ./.github/workflows/test.yml build: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1ba1bd3..be43136 100755 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -12,8 +12,11 @@ jobs: lint: uses: ./.github/workflows/lint.yml + typecheck: + uses: ./.github/workflows/typecheck.yml + test: - needs: lint + needs: [lint, typecheck] uses: ./.github/workflows/test.yml build: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..17dc485 --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,21 @@ +name: Typecheck + +on: + workflow_call: + +jobs: + typecheck: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 'lts/*' + cache: 'npm' + + - run: npm ci + + - run: npm run typecheck diff --git a/README.md b/README.md index b005dba..f42e99e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Components require a theme from `@ankh-studio/themes`: | Component | Description | |-----------|-------------| | `` | Button with variants: filled, outlined, text, elevated, tonal | +| `` | Icon using Material Symbols font, with size and fill variants | | `` | Focus indicator for keyboard navigation | | `` | Material-style ripple effect | @@ -55,6 +56,26 @@ Components require a theme from `@ankh-studio/themes`: Tonal ``` +### Icon + +```html + + + + +``` + +> Requires the [Material Symbols Outlined](https://fonts.google.com/icons) font to be loaded by the consumer. + +#### Icon Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `name` | `string` | — | Material Symbols icon name (required) | +| `size` | `sm` \| `md` \| `lg` \| `xl` | `md` | Rendered size | +| `filled` | `boolean` | `false` | Use the filled variant | +| `label` | `string` | — | Accessible label; omit for decorative icons | + #### Button Props | Prop | Type | Default | Description | @@ -73,6 +94,16 @@ Components work in any framework. For better DX, use Stencil's output targets: - **Angular**: `@stencil/angular-output-target` - **Vue**: `@stencil/vue-output-target` +## Versioning + +This package follows [SemVer](https://semver.org/). The public API surface includes component tag names, props, events, CSS custom properties, and slots. + +- **Patch** – bug fixes that don't change public behaviour +- **Minor** – new components, props, events, or slots (backwards-compatible) +- **Major** – renamed/removed tags, props, events, or breaking behavioural changes + +Every PR must declare its SemVer impact using the PR template checklist. + ## Browser Support Requires browsers supporting: @@ -89,7 +120,16 @@ Requires browsers supporting: ## Changelog -### 0.1.0-alpha +### 0.1.2 + +- Add `ankh-icon` component with Material Symbols font rendering +- Add accessibility tests with `vitest-axe` + +### 0.1.1 + +- Bug fixes and CI improvements + +### 0.1.0 - Initial release with button, focus-ring, ripple components diff --git a/docs/adr/002-icon-rendering-strategy.md b/docs/adr/002-icon-rendering-strategy.md new file mode 100644 index 0000000..4aadeb9 --- /dev/null +++ b/docs/adr/002-icon-rendering-strategy.md @@ -0,0 +1,89 @@ +# ADR-002: Icon Rendering Strategy + +**Status:** Accepted +**Date:** 2026-02-08 + +## Context + +We need an `ankh-icon` component to render icons consistently across the design system. The two viable strategies are: + +1. **Variable icon font** (Material Symbols) — a single WOFF2 file with ligature-based glyph resolution and four OpenType variation axes: fill, weight, grade, and optical size. +2. **Inline SVG** — per-icon imports, a sprite sheet, or an icon registry, rendered as `` elements in the DOM. + +The core tension is **developer ergonomics and design-axis control** (font) vs **payload precision and offline resilience** (SVG). + +## Decision + +**Use Material Symbols icon font (Outlined) as the rendering strategy.** + +### Why font over SVG + +**1. A single font file provides filled and outlined variants from one source.** +SVG requires two separate files per icon (or a complex path-swap mechanism). The font's `FILL` variation axis (`0` = outlined, `1` = filled) toggles between variants without switching glyph sources, doubling payload, or requiring a build-time icon variant resolver. + +**2. Optical sizing preserves visual clarity across the size scale.** +The `opsz` variation axis (continuous 20–48) adjusts stroke weight and counter proportions to match the rendered size. A 48px icon uses thinner relative strokes than an 18px icon, preventing visual heaviness at large sizes and ensuring legibility at small sizes. SVGs scale geometrically — a single SVG path rendered at 18px and 48px has identical stroke-to-body proportions, which is optically incorrect at both extremes. + +**3. Ligature resolution means zero JavaScript overhead for icon selection.** +The icon name (e.g. `home`, `settings`) is rendered as text content. The font's OpenType `liga` feature maps the string to the correct glyph at render time. No JS icon registry, no dynamic imports, no build-time tree-shaking configuration. This is particularly valuable because our component uses `shadow: false` (light DOM) — the text content is plainly readable in the DOM and debuggable without tooling. + +**4. Color theming works via `currentColor` with no component-level plumbing.** +The font glyph inherits `color` from the cascade. No SVG `fill` attribute management, no passing color tokens through props, no theme-aware style injection. The icon automatically follows whatever color the parent sets. + +**5. Developer experience is simpler — pass a string, get an icon.** +No imports per icon, no sprite setup, no icon registry to configure. The full Material Symbols set (~2,500+ icons) is available from the name prop alone after the font is loaded. + +**Where SVG would win (and why we accept the trade-off):** +- **Payload precision**: SVG allows shipping only the icons actually used. The font file is ~3–5 MB WOFF2 regardless. For our design system, where consumers typically use dozens of icons across an application, the font's one-time cache cost is acceptable. +- **Offline resilience**: SVGs are inline and available without font loading. Acceptable trade-off — the font caches after first load and `font-display: block` prevents FOIT for subsequent visits. +- **Custom icons**: SVG supports non-Material icons. If custom icon support is needed later, we can extend the component to accept SVG children alongside font-rendered icons. + +### How it works + +| Concern | Implementation | +|---------|---------------| +| **Icon selection (`name`)** | Ligature resolution — icon name rendered as text, font `liga` feature converts to glyph. | +| **Filled vs outlined (`filled`)** | `font-variation-settings` `FILL` axis: `0` = outlined, `1` = filled. Composed via private CSS custom properties to avoid axis override conflicts. | +| **Sizing (`size`)** | Maps to `--icon-size-sm/md/lg/xl` tokens from `@ankh-studio/tokens`. Optical size axis tracks rendered size for correct visual weight. | +| **Color** | `color: currentColor`. Inherits from parent, no hardcoded palette references. | +| **Font loading** | Consumer responsibility. The component sets `font-family` but does not bundle font files. | + +### Font axis composition + +`font-variation-settings` is an all-or-nothing property — a later declaration replaces all axes, not just one. To let size and fill classes compose without overriding each other, we use private CSS custom properties per axis: + +```css +.ankh-icon { + --_fill: 0; + --_opsz: 24; + font-variation-settings: 'FILL' var(--_fill), 'wght' 400, 'GRAD' 0, 'opsz' var(--_opsz); +} +.ankh-icon--filled { --_fill: 1; } +.ankh-icon--lg { --_opsz: 40; } +``` + +This pattern avoids the common pitfall where `.ankh-icon--filled` would accidentally reset `opsz` to its default. + +## Consequences + +- **Consumer must load the font.** The component does not self-load Material Symbols. A future `@ankh-studio/icons` package (or documentation) should provide the recommended import path. +- **Full icon set in one file.** No tree-shaking — acceptable for a design system where many icons are expected across an application. +- **No build-time name validation.** Typos in icon names render as blank text. The component does not ship a name registry; correctness is a consumer responsibility. +- **Single font style (Outlined) for v1.** If `Rounded` or `Sharp` styles are needed, a `style` prop can be added in a follow-up without breaking changes. + +## Shadow DOM Decision + +`ankh-icon` uses `shadow: false` (light DOM rendering). Three factors drive this choice: + +1. **Token cascade access.** The component's CSS references `--icon-size-*` custom properties from `@ankh-studio/tokens`. In light DOM, these custom properties flow in through the normal cascade without requiring `::part()` forwarding or CSS variable re-declaration on a shadow host. + +2. **Consumer styling flexibility.** With light DOM, the component's classes (`ankh-icon`, `ankh-icon--md`, etc.) are directly targetable with standard CSS selectors. Consumers can extend or override styles without shadow-piercing workarounds. + +3. **Font inheritance.** The component sets `font-family: 'Material Symbols Outlined'` on the inner span. Because `@font-face` declarations are scoped to the document, a shadow root would not inherit the font unless the consumer duplicated the `@font-face` rule inside it or the component self-bundled the font. Light DOM avoids this entirely. + +This is consistent with all other components in the system (`ankh-button`, `ankh-focus-ring`, `ankh-ripple`), which also use `shadow: false` for the same cascade and theming reasons. + +## Open Questions + +- [x] Should we provide a `@ankh-studio/icons` package that bundles/re-exports the Material Symbols font CSS? — **Resolved:** `@ankh-studio/icons` exists with `icons.css` and vendored WOFF2. +- [ ] Should `weight` and `grade` be exposed as props? (Deferred — keep API minimal for v1.) diff --git a/docs/adr/README.md b/docs/adr/README.md index d2139b2..56bea8f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,6 +16,7 @@ This directory contains Architecture Decision Records (ADRs) for @ankh-studio/co | ADR | Title | Status | |-----|-------|--------| | [001](./001-design-intentions.md) | Design Intentions Layer System | Proposed | +| [002](./002-icon-rendering-strategy.md) | Icon Rendering Strategy | Accepted | ## Creating an ADR diff --git a/package-lock.json b/package-lock.json index 6139199..759e6f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ankh-studio/components", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ankh-studio/components", - "version": "0.1.0", + "version": "0.1.2", "license": "MIT", "dependencies": { "@ankh-studio/themes": "^0.1.3", @@ -22,6 +22,7 @@ "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", "happy-dom": "^20.4.0", + "jsdom": "^28.0.0", "postcss": "^8.5.6", "postcss-custom-media": "^12.0.0", "postcss-nesting": "^14.0.0", @@ -29,12 +30,20 @@ "stylelint": "^17.1.0", "stylelint-config-standard": "^40.0.0", "unplugin-stencil": "^0.4.1", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "vitest-axe": "^0.1.0" }, "engines": { "node": ">=20" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ankh-studio/themes": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@ankh-studio/themes/-/themes-0.1.3.tgz", @@ -57,113 +66,39 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", + "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" } }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/@babel/code-frame": { "version": "7.29.0", @@ -276,9 +211,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", "dev": true, "funding": [ { @@ -292,7 +227,59 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.0.tgz", + "integrity": "sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { @@ -1056,6 +1043,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.12.0.tgz", + "integrity": "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1614,74 +1619,397 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@stencil/core": { + "version": "4.41.3", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.41.3.tgz", + "integrity": "sha512-zQ/7/hwsJBKYz0CzN3LyKJ3JdnNTdsYMY+futdyodTLkPNlPeRWVF1EI5syaBb09W+ay/T+TsYbPMXbXgG/3qQ==", + "dev": true, + "license": "MIT", + "bin": { + "stencil": "bin/stencil" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.10.0" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "4.34.9", + "@rollup/rollup-darwin-x64": "4.34.9", + "@rollup/rollup-linux-arm64-gnu": "4.34.9", + "@rollup/rollup-linux-arm64-musl": "4.34.9", + "@rollup/rollup-linux-x64-gnu": "4.34.9", + "@rollup/rollup-linux-x64-musl": "4.34.9", + "@rollup/rollup-win32-arm64-msvc": "4.34.9", + "@rollup/rollup-win32-x64-msvc": "4.34.9" + } + }, + "node_modules/@stencil/eslint-plugin": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@stencil/eslint-plugin/-/eslint-plugin-1.1.0.tgz", + "integrity": "sha512-1Qui7tOZni+Zz8rePz+5ss5hm8IjJa3PpcumJnpjZjzMGNRf3fQrzciqiJFFnn4H4j0FA2FSvFfcv00YZ1tE8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-utils": "^3.0.0", + "jsdom": "^26.1.0", + "tsutils": "^3.21.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.0.0 || ^9.0.0", + "eslint-plugin-react": "^7.37.4", + "typescript": "^4.9.4 || ^5.0.0" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@stencil/eslint-plugin/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stencil/eslint-plugin/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "punycode": "^2.3.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=18" } }, - "node_modules/@stencil/core": { - "version": "4.41.3", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.41.3.tgz", - "integrity": "sha512-zQ/7/hwsJBKYz0CzN3LyKJ3JdnNTdsYMY+futdyodTLkPNlPeRWVF1EI5syaBb09W+ay/T+TsYbPMXbXgG/3qQ==", + "node_modules/@stencil/eslint-plugin/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/@stencil/eslint-plugin/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", - "bin": { - "stencil": "bin/stencil" - }, "engines": { - "node": ">=16.0.0", - "npm": ">=7.10.0" - }, - "optionalDependencies": { - "@rollup/rollup-darwin-arm64": "4.34.9", - "@rollup/rollup-darwin-x64": "4.34.9", - "@rollup/rollup-linux-arm64-gnu": "4.34.9", - "@rollup/rollup-linux-arm64-musl": "4.34.9", - "@rollup/rollup-linux-x64-gnu": "4.34.9", - "@rollup/rollup-linux-x64-musl": "4.34.9", - "@rollup/rollup-win32-arm64-msvc": "4.34.9", - "@rollup/rollup-win32-x64-msvc": "4.34.9" + "node": ">=18" } }, - "node_modules/@stencil/eslint-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@stencil/eslint-plugin/-/eslint-plugin-1.1.0.tgz", - "integrity": "sha512-1Qui7tOZni+Zz8rePz+5ss5hm8IjJa3PpcumJnpjZjzMGNRf3fQrzciqiJFFnn4H4j0FA2FSvFfcv00YZ1tE8Q==", + "node_modules/@stencil/eslint-plugin/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "eslint-utils": "^3.0.0", - "jsdom": "^26.1.0", - "tsutils": "^3.21.0" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^7.0.0 || ^8.0.0", - "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", - "eslint": "^8.0.0 || ^9.0.0", - "eslint-plugin-react": "^7.37.4", - "typescript": "^4.9.4 || ^5.0.0" + "node": ">=18" } }, "node_modules/@types/chai": { @@ -2173,6 +2501,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -2357,6 +2695,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2364,6 +2712,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2628,41 +2986,43 @@ } }, "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/data-urls/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/data-view-buffer": { @@ -2800,6 +3160,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3986,16 +4353,16 @@ "license": "MIT" }, "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/html-tags": { @@ -4100,6 +4467,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -4614,35 +4991,35 @@ } }, "node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", + "tough-cookie": "^6.0.0", + "undici": "^7.20.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -4654,13 +5031,13 @@ } }, "node_modules/jsdom/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/json-buffer": { @@ -4771,6 +5148,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4799,11 +5183,14 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/magic-string": { "version": "0.30.21", @@ -4880,6 +5267,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5196,9 +5593,9 @@ } }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, "license": "MIT", "dependencies": { @@ -5265,16 +5662,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5531,6 +5918,20 @@ "dev": true, "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6165,6 +6566,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6559,22 +6973,22 @@ } }, "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^6.1.86" + "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "dev": true, "license": "MIT" }, @@ -6592,29 +7006,29 @@ } }, "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "tldts": "^6.1.32" + "tldts": "^7.0.5" }, "engines": { "node": ">=16" } }, "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/ts-api-utils": { @@ -6785,6 +7199,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7235,6 +7659,37 @@ } } }, + "node_modules/vitest-axe": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/vitest-axe/-/vitest-axe-0.1.0.tgz", + "integrity": "sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.0.0", + "axe-core": "^4.4.2", + "chalk": "^5.0.1", + "dom-accessibility-api": "^0.5.14", + "lodash-es": "^4.17.21", + "redent": "^3.0.0" + }, + "peerDependencies": { + "vitest": ">=0.16.0" + } + }, + "node_modules/vitest-axe/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -7262,13 +7717,13 @@ } }, "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/webpack-virtual-modules": { @@ -7303,17 +7758,18 @@ } }, "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/which": { diff --git a/package.json b/package.json index c692460..f505d37 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "lint": "npm run lint:ts && npm run lint:css", "lint:ts": "eslint src/", "lint:css": "stylelint 'src/**/*.css'", - "lint:fix": "eslint src/ --fix && stylelint 'src/**/*.css' --fix" + "lint:fix": "eslint src/ --fix && stylelint 'src/**/*.css' --fix", + "typecheck": "tsc --noEmit", + "check": "npm run lint && npm run typecheck && npm run test" }, "devDependencies": { "@stencil-community/postcss": "^2.2.0", @@ -63,6 +65,7 @@ "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", "happy-dom": "^20.4.0", + "jsdom": "^28.0.0", "postcss": "^8.5.6", "postcss-custom-media": "^12.0.0", "postcss-nesting": "^14.0.0", @@ -70,7 +73,8 @@ "stylelint": "^17.1.0", "stylelint-config-standard": "^40.0.0", "unplugin-stencil": "^0.4.1", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "vitest-axe": "^0.1.0" }, "dependencies": { "@ankh-studio/themes": "^0.1.3", diff --git a/src/components.d.ts b/src/components.d.ts index 4cc19be..354a4aa 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -6,7 +6,9 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { ButtonSize, ButtonVariant } from "./components/ankh-button/ankh-button"; +import { IconSize } from "./components/ankh-icon/ankh-icon"; export { ButtonSize, ButtonVariant } from "./components/ankh-button/ankh-button"; +export { IconSize } from "./components/ankh-icon/ankh-icon"; export namespace Components { interface AnkhButton { /** @@ -47,6 +49,26 @@ export namespace Components { */ "visible": boolean; } + interface AnkhIcon { + /** + * Whether to render the filled variant of the icon. Controls the FILL axis of the Material Symbols variable font. + * @default false + */ + "filled": boolean; + /** + * Accessible label for meaningful icons. When provided: sets role="img" and aria-label. When omitted: icon is treated as decorative (aria-hidden="true"). + */ + "label"?: string; + /** + * Icon name from Material Symbols (e.g. "home", "settings", "favorite"). Uses ligature resolution — the name is rendered as text content and the Material Symbols font converts it to the corresponding glyph. + */ + "name": string; + /** + * The rendered size of the icon. Maps to --icon-size-sm/md/lg/xl tokens with optical size tracking. + * @default 'md' + */ + "size": IconSize; + } interface AnkhRipple { /** * Whether the ripple effect is disabled @@ -68,6 +90,12 @@ declare global { prototype: HTMLAnkhFocusRingElement; new (): HTMLAnkhFocusRingElement; }; + interface HTMLAnkhIconElement extends Components.AnkhIcon, HTMLStencilElement { + } + var HTMLAnkhIconElement: { + prototype: HTMLAnkhIconElement; + new (): HTMLAnkhIconElement; + }; interface HTMLAnkhRippleElement extends Components.AnkhRipple, HTMLStencilElement { } var HTMLAnkhRippleElement: { @@ -77,6 +105,7 @@ declare global { interface HTMLElementTagNameMap { "ankh-button": HTMLAnkhButtonElement; "ankh-focus-ring": HTMLAnkhFocusRingElement; + "ankh-icon": HTMLAnkhIconElement; "ankh-ripple": HTMLAnkhRippleElement; } } @@ -120,6 +149,26 @@ declare namespace LocalJSX { */ "visible"?: boolean; } + interface AnkhIcon { + /** + * Whether to render the filled variant of the icon. Controls the FILL axis of the Material Symbols variable font. + * @default false + */ + "filled"?: boolean; + /** + * Accessible label for meaningful icons. When provided: sets role="img" and aria-label. When omitted: icon is treated as decorative (aria-hidden="true"). + */ + "label"?: string; + /** + * Icon name from Material Symbols (e.g. "home", "settings", "favorite"). Uses ligature resolution — the name is rendered as text content and the Material Symbols font converts it to the corresponding glyph. + */ + "name": string; + /** + * The rendered size of the icon. Maps to --icon-size-sm/md/lg/xl tokens with optical size tracking. + * @default 'md' + */ + "size"?: IconSize; + } interface AnkhRipple { /** * Whether the ripple effect is disabled @@ -130,6 +179,7 @@ declare namespace LocalJSX { interface IntrinsicElements { "ankh-button": AnkhButton; "ankh-focus-ring": AnkhFocusRing; + "ankh-icon": AnkhIcon; "ankh-ripple": AnkhRipple; } } @@ -139,6 +189,7 @@ declare module "@stencil/core" { interface IntrinsicElements { "ankh-button": LocalJSX.AnkhButton & JSXBase.HTMLAttributes; "ankh-focus-ring": LocalJSX.AnkhFocusRing & JSXBase.HTMLAttributes; + "ankh-icon": LocalJSX.AnkhIcon & JSXBase.HTMLAttributes; "ankh-ripple": LocalJSX.AnkhRipple & JSXBase.HTMLAttributes; } } diff --git a/src/components/ankh-icon/ankh-icon.a11y.spec.ts b/src/components/ankh-icon/ankh-icon.a11y.spec.ts new file mode 100644 index 0000000..1bc5b49 --- /dev/null +++ b/src/components/ankh-icon/ankh-icon.a11y.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as matchers from 'vitest-axe/matchers'; +import { axe } from 'vitest-axe'; +import type { AxeMatchers } from 'vitest-axe'; +import { createElement, createContainer } from '../../test-utils'; +import './ankh-icon.js'; + +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Assertion extends AxeMatchers {} + interface AsymmetricMatchersContaining extends AxeMatchers {} +} + +expect.extend(matchers); + +describe('ankh-icon a11y', () => { + let container: HTMLDivElement; + let cleanup: () => void; + + beforeEach(() => { + ({ container, cleanup } = createContainer()); + }); + + afterEach(() => { + cleanup(); + }); + + describe('decorative icons', () => { + it('has no a11y violations when used as decorative (no label)', async () => { + await createElement('ankh-icon', container, { name: 'home' }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('is hidden from the accessibility tree', async () => { + const el = await createElement('ankh-icon', container, { name: 'settings' }); + const span = el.querySelector('span.ankh-icon'); + expect(span?.getAttribute('aria-hidden')).toBe('true'); + expect(span?.hasAttribute('role')).toBe(false); + }); + }); + + describe('meaningful icons', () => { + it('has no a11y violations when used with a label', async () => { + await createElement('ankh-icon', container, { name: 'delete', label: 'Delete item' }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('is exposed to the accessibility tree with correct role and label', async () => { + const el = await createElement('ankh-icon', container, { name: 'close', label: 'Close dialog' }); + const span = el.querySelector('span.ankh-icon'); + expect(span?.getAttribute('role')).toBe('img'); + expect(span?.getAttribute('aria-label')).toBe('Close dialog'); + expect(span?.hasAttribute('aria-hidden')).toBe(false); + }); + }); + + describe('context combinations', () => { + it('has no a11y violations with filled decorative icon', async () => { + await createElement('ankh-icon', container, { name: 'favorite', filled: 'true' }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations with labeled icon at each size', async () => { + for (const size of ['sm', 'md', 'lg', 'xl']) { + container.innerHTML = ''; + await createElement('ankh-icon', container, { name: 'info', size, label: 'Information' }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + } + }); + }); +}); diff --git a/src/components/ankh-icon/ankh-icon.css b/src/components/ankh-icon/ankh-icon.css new file mode 100644 index 0000000..73251c7 --- /dev/null +++ b/src/components/ankh-icon/ankh-icon.css @@ -0,0 +1,101 @@ +/** + * Ankh Icon Component Styles + * Material Symbols icon font with token-mapped sizing + */ + +@layer components { + /* Host element */ + .ankh-icon-host { + display: inline-flex; + vertical-align: middle; + } + + .ankh-icon { + /* Composable font variation axes (private custom properties) */ + --_fill: 0; + --_opsz: 24; + + /* Material Symbols base — component sets the family per ADR-002; + consumer loads the @font-face, we just reference it here. */ + /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword -- icon font, not text */ + font-family: 'Material Symbols Outlined'; + font-variation-settings: 'FILL' var(--_fill), 'wght' 400, 'GRAD' 0, 'opsz' var(--_opsz); + + /* Default size maps to md token */ + font-size: var(--icon-size-md, 24px); + + /* Layout */ + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + letter-spacing: normal; + text-transform: none; + white-space: nowrap; + overflow-wrap: normal; + direction: ltr; + flex-shrink: 0; + + /* Color: inherit from parent, never hardcoded */ + color: currentcolor; + + /* Interaction */ + user-select: none; + + /* Font rendering */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizelegibility; + font-feature-settings: 'liga'; + } + + /* ======================================== + Size variants + Optical size tracks rendered size per M3 guidance. + opsz axis range: 20–48 (continuous). + ======================================== */ + + .ankh-icon--sm { + font-size: var(--icon-size-sm, 18px); + + --_opsz: 20; /* Clamped from 18 to axis minimum 20 */ + } + + .ankh-icon--md { + font-size: var(--icon-size-md, 24px); + + --_opsz: 24; + } + + .ankh-icon--lg { + font-size: var(--icon-size-lg, 36px); + + --_opsz: 40; + } + + .ankh-icon--xl { + font-size: var(--icon-size-xl, 48px); + + --_opsz: 48; + } + + /* ======================================== + Fill variant + Uses private custom property so it composes + with size variants without overriding opsz. + ======================================== */ + + .ankh-icon--filled { + --_fill: 1; + } + + /* ======================================== + Forced colors (high contrast mode) + ======================================== */ + + @media (forced-colors: active) { + .ankh-icon { + color: CanvasText; + } + } +} diff --git a/src/components/ankh-icon/ankh-icon.spec.ts b/src/components/ankh-icon/ankh-icon.spec.ts new file mode 100644 index 0000000..5b118f7 --- /dev/null +++ b/src/components/ankh-icon/ankh-icon.spec.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createElement, createContainer, waitForHydration } from '../../test-utils'; +import './ankh-icon.js'; + +describe('ankh-icon', () => { + let container: HTMLDivElement; + let cleanup: () => void; + + beforeEach(() => { + ({ container, cleanup } = createContainer()); + }); + + afterEach(() => { + cleanup(); + }); + + describe('default rendering', () => { + it('renders host with ankh-icon-host class', async () => { + const el = await createElement('ankh-icon', container, { name: 'home' }); + expect(el.classList.contains('ankh-icon-host')).toBe(true); + }); + + it('renders a span with ankh-icon class', async () => { + const el = await createElement('ankh-icon', container, { name: 'home' }); + const span = el.querySelector('span.ankh-icon'); + expect(span).toBeTruthy(); + expect(span!.classList.contains('ankh-icon')).toBe(true); + }); + + it('renders the icon name as text content', async () => { + const el = await createElement('ankh-icon', container, { name: 'settings' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.textContent).toBe('settings'); + }); + + it('renders with md size by default', async () => { + const el = await createElement('ankh-icon', container, { name: 'home' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains('ankh-icon--md')).toBe(true); + }); + + it('does not apply filled class by default', async () => { + const el = await createElement('ankh-icon', container, { name: 'home' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains('ankh-icon--filled')).toBe(false); + }); + }); + + describe('name prop', () => { + it('renders different icon names', async () => { + const el = await createElement('ankh-icon', container, { name: 'favorite' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.textContent).toBe('favorite'); + }); + + it('renders multi-word icon names', async () => { + const el = await createElement('ankh-icon', container, { name: 'arrow_back' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.textContent).toBe('arrow_back'); + }); + }); + + describe('name prop — edge cases', () => { + it('renders an empty span when name is empty string', async () => { + const el = await createElement('ankh-icon', container, { name: '' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.textContent).toBe(''); + }); + + it('still applies base classes when name is empty', async () => { + const el = await createElement('ankh-icon', container, { name: '' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains('ankh-icon')).toBe(true); + expect(span!.classList.contains('ankh-icon--md')).toBe(true); + }); + + it('treats empty-name icon as decorative by default', async () => { + const el = await createElement('ankh-icon', container, { name: '' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.getAttribute('aria-hidden')).toBe('true'); + expect(span!.hasAttribute('role')).toBe(false); + }); + }); + + describe('size prop', () => { + it.each(['sm', 'md', 'lg', 'xl'] as const)('applies ankh-icon--%s class for size="%s"', async (size) => { + const el = await createElement('ankh-icon', container, { name: 'home', size }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains(`ankh-icon--${size}`)).toBe(true); + }); + + it('falls back to md for an invalid size value', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', size: 'huge' }); + const span = el.querySelector('span.ankh-icon')!; + expect(span.classList.contains('ankh-icon--md')).toBe(true); + expect(span.classList.contains('ankh-icon--huge')).toBe(false); + }); + + it('falls back to md when size is set to empty string', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', size: '' }); + const span = el.querySelector('span.ankh-icon')!; + expect(span.classList.contains('ankh-icon--md')).toBe(true); + }); + + it('only applies one size class at a time', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', size: 'lg' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains('ankh-icon--lg')).toBe(true); + expect(span!.classList.contains('ankh-icon--md')).toBe(false); + expect(span!.classList.contains('ankh-icon--sm')).toBe(false); + expect(span!.classList.contains('ankh-icon--xl')).toBe(false); + }); + }); + + describe('filled prop', () => { + it('applies ankh-icon--filled class when filled is set', async () => { + const el = await createElement('ankh-icon', container, { name: 'favorite', filled: 'true' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains('ankh-icon--filled')).toBe(true); + }); + + it('does not apply filled class when filled is not set', async () => { + const el = await createElement('ankh-icon', container, { name: 'favorite' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains('ankh-icon--filled')).toBe(false); + }); + + it('applies filled and size classes together', async () => { + const el = await createElement('ankh-icon', container, { name: 'favorite', filled: 'true', size: 'xl' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains('ankh-icon--filled')).toBe(true); + expect(span!.classList.contains('ankh-icon--xl')).toBe(true); + }); + }); + + describe('accessibility — decorative icons (no label)', () => { + it('sets aria-hidden="true" when no label is provided', async () => { + const el = await createElement('ankh-icon', container, { name: 'home' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.getAttribute('aria-hidden')).toBe('true'); + }); + + it('does not set role when no label is provided', async () => { + const el = await createElement('ankh-icon', container, { name: 'home' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.hasAttribute('role')).toBe(false); + }); + + it('does not set aria-label when no label is provided', async () => { + const el = await createElement('ankh-icon', container, { name: 'home' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.hasAttribute('aria-label')).toBe(false); + }); + }); + + describe('accessibility — meaningful icons (with label)', () => { + it('sets role="img" when label is provided', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', label: 'Home' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.getAttribute('role')).toBe('img'); + }); + + it('sets aria-label to the provided label', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', label: 'Go to home page' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.getAttribute('aria-label')).toBe('Go to home page'); + }); + + it('does not set aria-hidden when label is provided', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', label: 'Home' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.hasAttribute('aria-hidden')).toBe(false); + }); + }); + + describe('accessibility — whitespace-only labels', () => { + it('treats whitespace-only label as decorative', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', label: ' ' }); + const span = el.querySelector('span.ankh-icon')!; + expect(span.getAttribute('aria-hidden')).toBe('true'); + expect(span.hasAttribute('role')).toBe(false); + expect(span.hasAttribute('aria-label')).toBe(false); + }); + + it('trims label with leading/trailing whitespace', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', label: ' Home ' }); + const span = el.querySelector('span.ankh-icon')!; + expect(span.getAttribute('role')).toBe('img'); + expect(span.getAttribute('aria-label')).toBe('Home'); + }); + }); + + describe('accessibility — dynamic label changes', () => { + it('transitions from decorative to meaningful when label is added', async () => { + const el = await createElement('ankh-icon', container, { name: 'home' }); + el.setAttribute('label', 'Home page'); + await waitForHydration(); + const span = el.querySelector('span.ankh-icon')!; + expect(span.getAttribute('role')).toBe('img'); + expect(span.getAttribute('aria-label')).toBe('Home page'); + expect(span.hasAttribute('aria-hidden')).toBe(false); + }); + + it('transitions from meaningful to decorative when label is removed', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', label: 'Home page' }); + el.removeAttribute('label'); + await waitForHydration(); + const span = el.querySelector('span.ankh-icon')!; + expect(span.getAttribute('aria-hidden')).toBe('true'); + expect(span.hasAttribute('role')).toBe(false); + expect(span.hasAttribute('aria-label')).toBe(false); + }); + }); + + describe('class composition', () => { + it('always includes ankh-icon base class', async () => { + const el = await createElement('ankh-icon', container, { name: 'home', size: 'sm', filled: 'true' }); + const span = el.querySelector('span.ankh-icon'); + expect(span!.classList.contains('ankh-icon')).toBe(true); + }); + + it('composes size, filled, and base classes correctly', async () => { + const el = await createElement('ankh-icon', container, { name: 'star', size: 'lg', filled: 'true' }); + const span = el.querySelector('span.ankh-icon'); + const classes = Array.from(span!.classList); + expect(classes).toContain('ankh-icon'); + expect(classes).toContain('ankh-icon--lg'); + expect(classes).toContain('ankh-icon--filled'); + expect(classes).toHaveLength(3); + }); + }); +}); diff --git a/src/components/ankh-icon/ankh-icon.tsx b/src/components/ankh-icon/ankh-icon.tsx new file mode 100644 index 0000000..b21a12a --- /dev/null +++ b/src/components/ankh-icon/ankh-icon.tsx @@ -0,0 +1,74 @@ +import { Component, Prop, h, Host } from '@stencil/core'; +import { cn } from '@/utils'; + +/** + * Icon size options, mapped to --icon-size-* tokens + */ +export type IconSize = 'sm' | 'md' | 'lg' | 'xl'; + +const VALID_SIZES: readonly IconSize[] = ['sm', 'md', 'lg', 'xl']; + +@Component({ + tag: 'ankh-icon', + styleUrl: 'ankh-icon.css', + shadow: false, +}) +export class AnkhIcon { + /** + * Icon name from Material Symbols (e.g. "home", "settings", "favorite"). + * Uses ligature resolution — the name is rendered as text content and + * the Material Symbols font converts it to the corresponding glyph. + */ + @Prop() name!: string; + + /** + * The rendered size of the icon. + * Maps to --icon-size-sm/md/lg/xl tokens with optical size tracking. + * @default 'md' + */ + @Prop() size: IconSize = 'md'; + + /** + * Whether to render the filled variant of the icon. + * Controls the FILL axis of the Material Symbols variable font. + * @default false + */ + @Prop() filled: boolean = false; + + /** + * Accessible label for meaningful icons. + * When provided: sets role="img" and aria-label. + * When omitted: icon is treated as decorative (aria-hidden="true"). + */ + @Prop() label?: string; + + render() { + if (!this.name) { + console.warn('[ankh-icon] Missing required "name" prop.'); + } + + const validSize = VALID_SIZES.includes(this.size); + if (!validSize) { + console.warn( + `[ankh-icon] Invalid size "${this.size}" — falling back to "md". Expected one of: ${VALID_SIZES.join(', ')}`, + ); + } + + const normalizedLabel = this.label?.trim(); + const isDecorative = !normalizedLabel; + const size = validSize ? this.size : 'md'; + + return ( + + + {this.name} + + + ); + } +} diff --git a/src/components/ankh-icon/readme.md b/src/components/ankh-icon/readme.md new file mode 100644 index 0000000..b14c1e4 --- /dev/null +++ b/src/components/ankh-icon/readme.md @@ -0,0 +1,20 @@ +# ankh-icon + + + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ | ----------- | +| `filled` | `filled` | Whether to render the filled variant of the icon. Controls the FILL axis of the Material Symbols variable font. | `boolean` | `false` | +| `label` | `label` | Accessible label for meaningful icons. When provided: sets role="img" and aria-label. When omitted: icon is treated as decorative (aria-hidden="true"). | `string \| undefined` | `undefined` | +| `name` _(required)_ | `name` | Icon name from Material Symbols (e.g. "home", "settings", "favorite"). Uses ligature resolution — the name is rendered as text content and the Material Symbols font converts it to the corresponding glyph. | `string` | `undefined` | +| `size` | `size` | The rendered size of the icon. Maps to --icon-size-sm/md/lg/xl tokens with optical size tracking. | `"lg" \| "md" \| "sm" \| "xl"` | `'md'` | + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/vitest.config.ts b/vitest.config.ts index e840d06..5090224 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ plugins: [stencil()], test: { environment: 'happy-dom', + environmentMatchGlobs: [['**/*.a11y.spec.ts', 'jsdom']], include: ['src/**/*.spec.ts', 'src/**/*.test.ts'], }, });