diff --git a/package.json b/package.json index 4585a7a..b5f6d74 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "default": "./mjs/internal/focus-visible/index.js" }, "./internal/style-api": "./internal/style-api/index.scss", + "./internal/style-api/docs": { + "require": "./internal/style-api/docs.js", + "default": "./mjs/internal/style-api/docs.js" + }, "./internal/metrics": { "require": "./internal/metrics.js", "default": "./mjs/internal/metrics.js" diff --git a/src/internal/style-api/__tests__/docs.test.ts b/src/internal/style-api/__tests__/docs.test.ts new file mode 100644 index 0000000..d77ec7a --- /dev/null +++ b/src/internal/style-api/__tests__/docs.test.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { extractStyleApiDocs } from '../docs'; + +// Emulates the compiled output of `@include style-api.docs($name, $map)`. +const marker = (name: string, tokens: string[]) => + `/* awsui:style-api-slot name=${name} tokens=${tokens.join(', ')} */`; + +test('returns no slots when there are no markers', () => { + const css = ` + .root { padding-inline: var(--awsui-style-padding-inline, 8px); } + `; + expect(extractStyleApiDocs(css)).toEqual({ slots: [] }); +}); + +test('reads a slot and its tokens from a marker', () => { + const css = ` + ${marker('label', ['color-text', 'color-background'])} + .root { padding-inline: var(--awsui-style-padding-inline, 8px); } + `; + expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'label', tokens: ['color-text', 'color-background'] }]); +}); + +test('reads multiple slots having the same token name', () => { + const css = ` + ${marker('input', ['color-text', 'color-background'])} + ${marker('dropdown', ['color-text', 'color-background'])} + `; + const docs = extractStyleApiDocs(css); + expect(docs.slots).toEqual([ + { name: 'input', tokens: ['color-text', 'color-background'] }, + { name: 'dropdown', tokens: ['color-text', 'color-background'] }, + ]); +}); + +test('throws on a duplicate slot name (each slot must be annotated exactly once)', () => { + const css = ` + ${marker('input', ['color-text', 'color-background'])} + ${marker('input', ['padding-inline', 'padding-block'])} + `; + expect(() => extractStyleApiDocs(css)).toThrow(/multiple .+ annotations with the same name: "input"/); +}); + +test('tolerates empty slots', () => { + const css = ` + ${marker('empty', [])} + `; + expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'empty', tokens: [] }]); +}); + +test('tolerates whitespaces inside the marker', () => { + const css = `/* \nawsui:style-api-slot name=header tokens=color-text, color-border */`; + expect(extractStyleApiDocs(css).slots).toEqual([{ name: 'header', tokens: ['color-text', 'color-border'] }]); +}); diff --git a/src/internal/style-api/docs.ts b/src/internal/style-api/docs.ts new file mode 100644 index 0000000..d6a659e --- /dev/null +++ b/src/internal/style-api/docs.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Extracts the Style API documentation surface from a component's *compiled* CSS. +// +// Slots are declared explicitly by the author with the `style-api.docs($name, $tokens)` mixin, which +// emits a machine-readable marker comment into the compiled CSS: +// +// /* awsui:style-api-slot name= tokens=, */ +// +// This module parses those markers. + +const MARKER = /awsui:style-api-slot\s+name=([\w-]+)\s+tokens=([^*]*)\*\//g; + +export interface StyleApiDocs { + /** + * The component's themeable slots (defined by classNames), each with its own set of style tokens. + */ + slots: StyleApiSlotDocs[]; +} + +export interface StyleApiSlotDocs { + /** + * The first argument of `style-api.docs(...)` - must match the corresponding classNames slot. + */ + name: string; + /** + * The public style tokens this slot supports (without "--awsui-style" prefix). + */ + tokens: string[]; +} + +/** + * Extracts the Style API slot documentation from a component's compiled CSS by reading the + * explicit slot markers emitted by `style-api.docs(...)`. + */ +export function extractStyleApiDocs(css: string): StyleApiDocs { + const slots = new Array(); + const usedSlots = new Set(); + + for (const match of css.matchAll(MARKER)) { + const name = match[1]; + const tokens = match[2].split(/[\s,]+/).filter(Boolean); + slots.push({ name, tokens }); + if (!usedSlots.has(name)) { + usedSlots.add(name); + } else { + throw new Error(`Found multiple style-api.docs(...) annotations with the same name: "${name}"`); + } + } + return { slots }; +} diff --git a/src/internal/style-api/index.scss b/src/internal/style-api/index.scss index 477e4cc..8ed72f0 100644 --- a/src/internal/style-api/index.scss +++ b/src/internal/style-api/index.scss @@ -7,53 +7,50 @@ @use 'sass:list'; @use 'sass:string'; -// Style API v2 authoring utils for shared, component-agnostic style tokens (e.g. --awsui-style-color-text, -// not --awsui-style-button-color-text). The owning component is implied by where the consumer applies the -// class, so one abstract token name is reused across components and shared token groups can be composed. +// Style API v2 authoring utils for shared, component-agnostic style tokens (e.g. color-text, +// color-background). Each public token is exposed as a `--awsui-style-` custom property, +// registered as `@property { syntax:'*'; inherits:false }`. A slot can read a token in one of two layers: +// - PUBLIC layer: read the public token directly, on the element the consumer themes (single-element slots). +// - CARRIER layer: mirror the public token into an inherited internal carrier `--awsui-internal-style-` +// re-anchored on the component root (carriers() mixin), so descendants can read it per instance. // -// Model: each public token `--awsui-style-` is registered `@property { syntax:'*'; inherits:false }`. -// A component resolves its prop set into a -> custom-property map for one of two LAYERS, then reads via -// `var(#{read($tokens, )}, )` (uniform at every read site): +// The reusable primitive is a per-slot token map produced by resolve() (`token -> custom-property`). Write +// each slot's token names once in resolve(); then register(), docs() and read() all consume the same maps — +// so the docs can never drift from the implementation, and a map can be shared by several slots. // -// - PUBLIC layer: read the public token directly, on the element the consumer themes. It doesn't inherit, so -// it can't pick up an ancestor component's value. Use for single-element components (e.g. a button). -// - CARRIER layer: the public token is mirrored into an INHERITED internal carrier `--awsui-internal-style-` -// re-anchored on the component root (carriers() mixin), so descendants can read it. Each root re-anchors from -// its own (unset) public token, so values scope to the instance and reset at every nested boundary — no -// leakage. Registrations are byte-identical across components, so order-independent. -// -// Example: +// Flow (public layer): // // @use '@cloudscape-design/component-toolkit/internal/style-api' as style-api; // -// $props: style-api.combine((color-background, color-text), (icon-color)); // 1. union token lists (deduped) -// $tokens: style-api.resolve($props, carrier); // 2. map for the carrier layer -> pass to read() -// @include style-api.register($props); // 3. declare the public @property (top level, once) +// $root: style-api.resolve((color-background), public); // 1. token names written once, per slot +// $info: style-api.resolve((color-text), public); +// @include style-api.register($root, $text); // 2. declare @property for every token (once) +// +// @include style-api.docs('root', $root); // 3. declare the slots (docs only, top level) +// @include style-api.docs('info', $info); // -// .alert-root { -// @include style-api.carriers($props); // 4. re-anchor public -> internal on the boundary -// color: var(#{style-api.read($tokens, color-text)}, #16191f); // 5. apply token (default via awsui.$… in a real component) -// } +// .root { background: var(#{style-api.read($root, color-background)}, …); } // 4. apply the tokens +// .info { color: var(#{style-api.read($info, color-text)}, …); } // -// A consumer themes an instance by setting the public token on a class applied to the component: -// .my-alert { --awsui-style-color-text: light-dark(black, white); } +// Carrier layer: resolve($tokens, carrier) and `@include style-api.carriers($map)` on the boundary element. -// 1. Deduped union of token lists. +// Deduped union of token lists — for composing shared token groups before resolve(). @function combine($lists...) { $result: (); @each $list in $lists { - @each $prop in $list { - @if not list.index($result, $prop) { - $result: list.append($result, $prop); + @each $token in $list { + @if not list.index($result, $token) { + $result: list.append($result, $token); } } } @return $result; } -// 2. Resolves `$props` into a -> custom-property map for `$layer` (`public` or `carrier`), consumed by -// read() at read sites. `$layer` is required so the layer choice is always explicit at the call site. -@function resolve($props, $layer) { +// Resolves `$tokens` (a token name or list of token names) into a `token -> custom-property` map for +// `$layer` (`public` or `carrier`), consumed by docs(), read() and carriers(). `$layer` is required +// so the layer choice is always explicit at the definition site. +@function resolve($tokens, $layer) { @if $layer != public and $layer != carrier { @error 'Unknown layer "#{$layer}". Use `public` or `carrier`.'; } @@ -61,36 +58,49 @@ @if $layer == public { $prefix: '--awsui-style-'; } - $tokens: (); - @each $prop in $props { - $tokens: map.set($tokens, $prop, string.unquote('#{$prefix}#{$prop}')); + $map: (); + @each $token in $tokens { + $map: map.set($map, $token, string.unquote('#{$prefix}#{$token}')); } - @return $tokens; + @return $map; } -// 3. Registers the public tokens as non-inheriting @property. Top level only. -@mixin register($props) { - @each $prop in $props { - @property --awsui-style-#{$prop} { - syntax: '*'; - inherits: false; +// Registers the public tokens of the given slot maps as non-inheriting @property. Top level, once. +// Accepts every slot's map so the registered set is exactly the union of the tokens the slots use. +@mixin register($maps...) { + $seen: (); + @each $map in $maps { + @each $token in map.keys($map) { + @if not list.index($seen, $token) { + $seen: list.append($seen, $token); + @property --awsui-style-#{$token} { + syntax: '*'; + inherits: false; + } + } } } } -// 4. Re-anchors each public token into its inherited internal carrier. Include on the component's root; pair -// with a `resolve($props, carrier)` map so descendants read the re-anchored values. -@mixin carriers($props) { - @each $prop in $props { - --awsui-internal-style-#{$prop}: var(--awsui-style-#{$prop}); +// Re-anchors each token of a carrier map into its inherited internal carrier. Include on the +// component's boundary element; descendants read the re-anchored values via the same map. +@mixin carriers($map) { + @each $token in map.keys($map) { + --awsui-internal-style-#{$token}: var(--awsui-style-#{$token}); } } -// 5. The custom-property to read for `$prop`, looked up in the given map. Errors at compile time on an -// undeclared prop (a typo, or a token the component never composed into its set). -@function read($tokens, $prop) { - @if not map.has-key($tokens, $prop) { - @error 'Unknown style token "#{$prop}". Declared: #{map.keys($tokens)}.'; +// The custom-property to read for `$token`, looked up in a slot map. Errors at compile time on an +// unknown token (a typo, or a token not in this slot's set). +@function read($map, $token) { + @if not map.has-key($map, $token) { + @error 'Unknown style token "#{$token}". This slot declares: #{map.keys($map)}.'; } - @return map.get($tokens, $prop); + @return map.get($map, $token); +} + +// Documents a themeable slot (emits docs only — no styling effect), from the slot map. +// The `$name` must match the component's `classNames` property entry. +@mixin docs($name, $map) { + /* awsui:style-api-slot name=#{$name} tokens=#{map.keys($map)} */ } diff --git a/test-pages/src/pages/style-api.module.scss b/test-pages/src/pages/style-api.module.scss index adae017..4610164 100644 --- a/test-pages/src/pages/style-api.module.scss +++ b/test-pages/src/pages/style-api.module.scss @@ -6,28 +6,16 @@ // Two demo components exercise the two layers: // - .panel uses the CARRIER layer, so descendants (.title/.body) read the re-anchored tokens. // - .pill uses the PUBLIC layer, reading the tokens directly on the element the consumer themes. -$panel-props: ( - color-background, - color-border, - border-width, - border-radius, - color-text, - color-text-secondary -); -$pill-props: ( - color-background, - color-border, - color-text, - border-radius -); +// Token names are written once, in resolve(); register/slot/carriers/read all reuse the same maps. +$panel: style-api.resolve((color-background, color-border, border-width, border-radius, color-text, color-text-secondary), carrier); +$pill: style-api.resolve((color-background, color-border, color-text, border-radius), public); -@include style-api.register(style-api.combine($panel-props, $pill-props)); - -$panel: style-api.resolve($panel-props, carrier); -$pill: style-api.resolve($pill-props, public); +@include style-api.register($panel, $pill); +@include style-api.docs('panel', $panel); +@include style-api.docs('pill', $pill); .panel { - @include style-api.carriers($panel-props); // re-anchor on the boundary so descendants can read the tokens + @include style-api.carriers($panel); // re-anchor on the boundary so descendants can read the tokens box-sizing: border-box; max-inline-size: 360px; background: var(#{style-api.read($panel, color-background)}, #445566);