Skip to content
Merged
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
96 changes: 96 additions & 0 deletions src/internal/style-api/index.scss
Original file line number Diff line number Diff line change
@@ -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-<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):
//
// - 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:
//
// @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 <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) {
@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);
}
74 changes: 74 additions & 0 deletions test-pages/src/pages/style-api.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
48 changes: 48 additions & 0 deletions test-pages/src/pages/style-api.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ margin: 16 }}>
<h1>Style API v2 tools</h1>

<h2>Without carrier tokens (public layer)</h2>
<p>Single element; reads --awsui-style-* directly on the themed element.</p>
<p>
<CustomPill>Default</CustomPill> <CustomPill className={styles['theme-pill']}>Themed</CustomPill>
</p>

<h2>With carrier tokens (carrier layer)</h2>
<p>The root re-anchors carriers; the title and body (descendants) read them.</p>
<CustomPanel title="Default">Default style (no overrides).</CustomPanel>
<br />
<CustomPanel className={styles['theme-panel']} title="Themed">
<div>
<div>Themed via --awsui-style-* on the root; the title and body colors resolve through carriers.</div>
<br />
<CustomPanel title="Default">Default style (no overrides).</CustomPanel>
</div>
</CustomPanel>
</div>
);
}

function CustomPill(props: { className?: string; children?: React.ReactNode }) {
return <span className={cx(styles.pill, props.className)}>{props.children}</span>;
}

function CustomPanel(props: { title: React.ReactNode; className?: string; children?: React.ReactNode }) {
return (
<div className={cx(styles.panel, props.className)}>
<div className={styles.title}>{props.title}</div>
<div className={styles.body}>{props.children}</div>
</div>
);
}

function cx(...names: Array<string | undefined>) {
return names.filter(Boolean).join(' ');
}
Loading