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 f40f0f9..cf006ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ankh-studio/components", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ankh-studio/components", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@ankh-studio/themes": "^0.1.3", @@ -30,7 +30,7 @@ "stylelint": "^17.1.0", "stylelint-config-standard": "^40.0.0", "unplugin-stencil": "^0.4.1", - "vite-tsconfig-paths": "^6.1.0", + "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.18", "vitest-axe": "^0.1.0" }, @@ -1045,9 +1045,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", - "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", + "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": { @@ -1645,9 +1645,9 @@ } }, "node_modules/@stencil/core": { - "version": "4.42.1", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.42.1.tgz", - "integrity": "sha512-KsjkaWnJmjMcsLjTK77FJV/8OK9qZ4f83/sXzYsjkUF21pqpNrDUkY6/+rnVFWnVfoYMpNdjWfGyJem4VlcMXw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.0.tgz", + "integrity": "sha512-6Uj2Z3lzLuufYAE7asZ6NLKgSwsB9uxl84Eh34PASnUjfj32GkrP4DtKK7fNeh1WFGGyffsTDka3gwtl+4reUg==", "dev": true, "license": "MIT", "bin": { @@ -7423,9 +7423,9 @@ } }, "node_modules/vite-tsconfig-paths": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.0.tgz", - "integrity": "sha512-kpd3sY9glHIDaq4V/Tlc1Y8WaKtutoc3B525GHxEVKWX42FKfQsXvjFOemu1I8VIN8pNbrMLWVTbW79JaRUxKg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", + "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fb4d5b8..aaec324 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", @@ -71,7 +73,7 @@ "stylelint": "^17.1.0", "stylelint-config-standard": "^40.0.0", "unplugin-stencil": "^0.4.1", - "vite-tsconfig-paths": "^6.1.0", + "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.18", "vitest-axe": "^0.1.0" }, diff --git a/src/components.d.ts b/src/components.d.ts index ec133d9..d6e61a7 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,10 +105,13 @@ declare global { interface HTMLElementTagNameMap { "ankh-button": HTMLAnkhButtonElement; "ankh-focus-ring": HTMLAnkhFocusRingElement; + "ankh-icon": HTMLAnkhIconElement; "ankh-ripple": HTMLAnkhRippleElement; } } declare namespace LocalJSX { + type OneOf = { [P in K]: PropT } & { [P in `attr:${K}` | `prop:${K}`]?: never } | { [P in `attr:${K}`]: AttrT } & { [P in K | `prop:${K}`]?: never } | { [P in `prop:${K}`]: PropT } & { [P in K | `attr:${K}`]?: never }; + interface AnkhButton { /** * Whether the button is disabled @@ -120,6 +151,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 @@ -139,6 +190,12 @@ declare namespace LocalJSX { "visible": boolean; "inward": boolean; } + interface AnkhIconAttributes { + "name": string; + "size": IconSize; + "filled": boolean; + "label": string; + } interface AnkhRippleAttributes { "disabled": boolean; } @@ -146,6 +203,7 @@ declare namespace LocalJSX { interface IntrinsicElements { "ankh-button": Omit & { [K in keyof AnkhButton & keyof AnkhButtonAttributes]?: AnkhButton[K] } & { [K in keyof AnkhButton & keyof AnkhButtonAttributes as `attr:${K}`]?: AnkhButtonAttributes[K] } & { [K in keyof AnkhButton & keyof AnkhButtonAttributes as `prop:${K}`]?: AnkhButton[K] }; "ankh-focus-ring": Omit & { [K in keyof AnkhFocusRing & keyof AnkhFocusRingAttributes]?: AnkhFocusRing[K] } & { [K in keyof AnkhFocusRing & keyof AnkhFocusRingAttributes as `attr:${K}`]?: AnkhFocusRingAttributes[K] } & { [K in keyof AnkhFocusRing & keyof AnkhFocusRingAttributes as `prop:${K}`]?: AnkhFocusRing[K] }; + "ankh-icon": Omit & { [K in keyof AnkhIcon & keyof AnkhIconAttributes]?: AnkhIcon[K] } & { [K in keyof AnkhIcon & keyof AnkhIconAttributes as `attr:${K}`]?: AnkhIconAttributes[K] } & { [K in keyof AnkhIcon & keyof AnkhIconAttributes as `prop:${K}`]?: AnkhIcon[K] } & OneOf<"name", AnkhIcon["name"], AnkhIconAttributes["name"]>; "ankh-ripple": Omit & { [K in keyof AnkhRipple & keyof AnkhRippleAttributes]?: AnkhRipple[K] } & { [K in keyof AnkhRipple & keyof AnkhRippleAttributes as `attr:${K}`]?: AnkhRippleAttributes[K] } & { [K in keyof AnkhRipple & keyof AnkhRippleAttributes as `prop:${K}`]?: AnkhRipple[K] }; } } @@ -155,6 +213,7 @@ declare module "@stencil/core" { interface IntrinsicElements { "ankh-button": LocalJSX.IntrinsicElements["ankh-button"] & JSXBase.HTMLAttributes; "ankh-focus-ring": LocalJSX.IntrinsicElements["ankh-focus-ring"] & JSXBase.HTMLAttributes; + "ankh-icon": LocalJSX.IntrinsicElements["ankh-icon"] & JSXBase.HTMLAttributes; "ankh-ripple": LocalJSX.IntrinsicElements["ankh-ripple"] & 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 c3166e7..07f0f4d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ plugins: [stencil(), tsconfigPaths()], test: { environment: 'happy-dom', + environmentMatchGlobs: [['**/*.a11y.spec.ts', 'jsdom']], include: ['src/**/*.spec.ts', 'src/**/*.test.ts'], }, });