diff --git a/package.json b/package.json index a4a3a26..daafc95 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,10 @@ "scripts": { "prebuild": "rm -rf lib", "build": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.cjs.json", - "postbuild": "npm run postbuild:root && npm run postbuild:focus-visible && node ./scripts/generate-deep-package.js", + "postbuild": "npm run postbuild:root && npm run postbuild:focus-visible && npm run postbuild:style-api && node ./scripts/generate-deep-package.js", "postbuild:root": "cp package.json README.md LICENSE NOTICE lib", "postbuild:focus-visible": "cp ./src/internal/focus-visible/index.scss lib/internal/focus-visible/index.scss", + "postbuild:style-api": "mkdir -p lib/internal/style-api && cp ./src/internal/style-api/index.scss lib/internal/style-api/index.scss", "test-pages": "vite --config ./test-pages/vite.config.mts", "test:unit": "jest -c jest.unit.config.cjs", "test:integ": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest -c jest.integ.config.cjs", diff --git a/src/internal/style-api/index.scss b/src/internal/style-api/index.scss new file mode 100644 index 0000000..477e4cc --- /dev/null +++ b/src/internal/style-api/index.scss @@ -0,0 +1,96 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use 'sass:map'; +@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. +// +// 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): +// +// - 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: +// +// @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) +// +// .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) +// } +// +// 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); } + +// 1. Deduped union of token lists. +@function combine($lists...) { + $result: (); + @each $list in $lists { + @each $prop in $list { + @if not list.index($result, $prop) { + $result: list.append($result, $prop); + } + } + } + @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) { + @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}')); + } + @return $tokens; +} + +// 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; + } + } +} + +// 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}); + } +} + +// 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)}.'; + } + @return map.get($tokens, $prop); +} diff --git a/test-pages/src/pages/style-api.module.scss b/test-pages/src/pages/style-api.module.scss new file mode 100644 index 0000000..adae017 --- /dev/null +++ b/test-pages/src/pages/style-api.module.scss @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +@use '../../../src/internal/style-api' as style-api; + +// 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 +); + +@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); + +.panel { + @include style-api.carriers($panel-props); // 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); + border: var(#{style-api.read($panel, border-width)}, 1px) solid var(#{style-api.read($panel, color-border)}, #879596); + border-radius: var(#{style-api.read($panel, border-radius)}, 8px); + padding: 16px; +} + +.title { + color: var(#{style-api.read($panel, color-text)}, #ffffff); + font-weight: 700; +} + +.body { + margin-block-start: 8px; + color: var(#{style-api.read($panel, color-text-secondary)}, #ffffdd); +} + +.pill { + box-sizing: border-box; + display: inline-block; + background: var(#{style-api.read($pill, color-background)}, #0972d3); + color: var(#{style-api.read($pill, color-text)}, #ffffff); + border: 1px solid var(#{style-api.read($pill, color-border)}, #0972d3); + border-radius: var(#{style-api.read($pill, border-radius)}, 20px); + padding: 4px 12px; +} + +// Consumer theme overrides: set the shared public --awsui-style-* tokens. +.theme-panel { + --awsui-style-color-background: #336688; + --awsui-style-color-text: #ffffaa; + --awsui-style-color-text-secondary: #cccc44; + --awsui-style-color-border: #cccc44; + --awsui-style-border-width: 2px; + --awsui-style-border-radius: 4px; +} + +.theme-pill { + --awsui-style-color-background: #c2185b; + --awsui-style-color-text: #ffffff; + --awsui-style-color-border: #c2185b; + --awsui-style-border-radius: 4px; +} \ No newline at end of file diff --git a/test-pages/src/pages/style-api.page.tsx b/test-pages/src/pages/style-api.page.tsx new file mode 100644 index 0000000..a5026fb --- /dev/null +++ b/test-pages/src/pages/style-api.page.tsx @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from 'react'; +import styles from './style-api.module.scss'; + +export default function StyleApiPage() { + return ( +
+

Style API v2 tools

+ +

Without carrier tokens (public layer)

+

Single element; reads --awsui-style-* directly on the themed element.

+

+ Default Themed +

+ +

With carrier tokens (carrier layer)

+

The root re-anchors carriers; the title and body (descendants) read them.

+ Default style (no overrides). +
+ +
+
Themed via --awsui-style-* on the root; the title and body colors resolve through carriers.
+
+ Default style (no overrides). +
+
+
+ ); +} + +function CustomPill(props: { className?: string; children?: React.ReactNode }) { + return {props.children}; +} + +function CustomPanel(props: { title: React.ReactNode; className?: string; children?: React.ReactNode }) { + return ( +
+
{props.title}
+
{props.children}
+
+ ); +} + +function cx(...names: Array) { + return names.filter(Boolean).join(' '); +}