Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions src/internal/style-api/__tests__/docs.test.ts
Original file line number Diff line number Diff line change
@@ -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'] }]);
});
52 changes: 52 additions & 0 deletions src/internal/style-api/docs.ts
Original file line number Diff line number Diff line change
@@ -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=<slot> tokens=<t1>, <t2> */
//
// 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<StyleApiSlotDocs>();
const usedSlots = new Set<string>();

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 };
}
112 changes: 61 additions & 51 deletions src/internal/style-api/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,90 +7,100 @@
@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-<token>` 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-<token>`
// re-anchored on the component root (carriers() mixin), so descendants can read it per instance.
//
// Model: each public token `--awsui-style-<prop>` is registered `@property { syntax:'*'; inherits:false }`.
// A component resolves its prop set into a <prop> -> custom-property map for one of two LAYERS, then reads via
// `var(#{read($tokens, <prop>)}, <default>)` (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-<prop>`
// 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 <prop> -> 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`.';
}
$prefix: '--awsui-internal-style-';
@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)} */
}
26 changes: 7 additions & 19 deletions test-pages/src/pages/style-api.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading