From 120a21d71500311e5eece4659898934b12325035 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Tue, 20 Oct 2026 10:42:22 +0200 Subject: [PATCH 1/2] chore: Adds internal slot mixins helper --- package.json | 7 +- .../__snapshots__/slot-mixins.test.ts.snap | 98 +++++++++++++++++++ .../style-api/__tests__/mixins.test.ts | 15 +-- .../style-api/__tests__/sass-utils.ts | 20 ++++ .../style-api/__tests__/slot-mixins.test.ts | 94 ++++++++++++++++++ .../style-api/generate-slot-mixins.ts | 84 ++++++++++++++++ src/internal/style-api/slot-mixins.scss | 37 +++++++ 7 files changed, 342 insertions(+), 13 deletions(-) create mode 100644 src/internal/style-api/__tests__/__snapshots__/slot-mixins.test.ts.snap create mode 100644 src/internal/style-api/__tests__/sass-utils.ts create mode 100644 src/internal/style-api/__tests__/slot-mixins.test.ts create mode 100644 src/internal/style-api/generate-slot-mixins.ts create mode 100644 src/internal/style-api/slot-mixins.scss diff --git a/package.json b/package.json index ff1f191..9b14c3d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,11 @@ "default": "./mjs/internal/focus-visible/index.js" }, "./internal/style-api": "./internal/style-api/index.scss", + "./internal/style-api/slot-mixins": "./internal/style-api/slot-mixins.scss", + "./internal/style-api/generate-slot-mixins": { + "require": "./internal/style-api/generate-slot-mixins.js", + "default": "./mjs/internal/style-api/generate-slot-mixins.js" + }, "./internal/style-api/docs": { "require": "./internal/style-api/docs.js", "default": "./mjs/internal/style-api/docs.js" @@ -82,7 +87,7 @@ "postbuild": "npm run postbuild:root && npm run postbuild:focus-visible && npm run postbuild:style-api && npm run postbuild:styling && 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", + "postbuild:style-api": "mkdir -p lib/internal/style-api && cp ./src/internal/style-api/index.scss ./src/internal/style-api/slot-mixins.scss lib/internal/style-api/", "postbuild:styling": "mkdir -p lib/styling && cp ./src/styling/index.scss lib/styling/index.scss", "test-pages": "vite --config ./test-pages/vite.config.mts", "test:unit": "jest -c jest.unit.config.cjs", diff --git a/src/internal/style-api/__tests__/__snapshots__/slot-mixins.test.ts.snap b/src/internal/style-api/__tests__/__snapshots__/slot-mixins.test.ts.snap new file mode 100644 index 0000000..3e2678f --- /dev/null +++ b/src/internal/style-api/__tests__/__snapshots__/slot-mixins.test.ts.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generated mixins compile to CSS 1`] = ` +".badge:not(#\\9 ):not(#\\9 ) { + background: linear-gradient(red, blue), #fff; + border-inline-color: red blue; + border-radius: 4px 4px 0 0; + border-start-start-radius: 12px; + padding: 12px 0 0 2px; + padding-inline: 4px; + font-family: "Open Sans", sans-serif; +} +.badge:hover:not(#\\9 ):not(#\\9 ) { + color: red; +} + +.handle { + --awsui-style-border-radius: 4px; +} +.handle:not(#\\9 ):not(#\\9 ) { + color: red; +}" +`; + +exports[`generates a module per component 1`] = ` +{ + "badge": "// GENERATED from the badge Style API docs — do not edit. +@use '@cloudscape-design/component-toolkit/internal/style-api/slot-mixins' as slot-mixins; + +@mixin root( + $color: null, + $background: null, + $border-color: null, + $border-width: null, + $border-style: null, + $border-block-color: null, + $border-block-width: null, + $border-block-style: null, + $border-inline-color: null, + $border-inline-width: null, + $border-inline-style: null, + $border-radius: null, + $border-start-start-radius: null, + $border-start-end-radius: null, + $border-end-start-radius: null, + $border-end-end-radius: null, + $padding: null, + $padding-block: null, + $padding-inline: null, + $font-family: null +) { + @include slot-mixins.apply( + 'badge.root', + $properties: ( + 'color': $color, + 'background': $background, + 'border-color': $border-color, + 'border-width': $border-width, + 'border-style': $border-style, + 'border-block-color': $border-block-color, + 'border-block-width': $border-block-width, + 'border-block-style': $border-block-style, + 'border-inline-color': $border-inline-color, + 'border-inline-width': $border-inline-width, + 'border-inline-style': $border-inline-style, + 'border-radius': $border-radius, + 'border-start-start-radius': $border-start-start-radius, + 'border-start-end-radius': $border-start-end-radius, + 'border-end-start-radius': $border-end-start-radius, + 'border-end-end-radius': $border-end-end-radius, + 'padding': $padding, + 'padding-block': $padding-block, + 'padding-inline': $padding-inline, + 'font-family': $font-family, + ), + ); +} +", + "slider": "// GENERATED from the slider Style API docs — do not edit. +@use '@cloudscape-design/component-toolkit/internal/style-api/slot-mixins' as slot-mixins; + +@mixin handle( + $border-radius: null, + $color: null +) { + @include slot-mixins.apply( + 'slider.handle', + $tokens: ( + 'border-radius': $border-radius, + ), + $properties: ( + 'color': $color, + ), + ); +} +", +} +`; diff --git a/src/internal/style-api/__tests__/mixins.test.ts b/src/internal/style-api/__tests__/mixins.test.ts index eefc0ea..9ac895c 100644 --- a/src/internal/style-api/__tests__/mixins.test.ts +++ b/src/internal/style-api/__tests__/mixins.test.ts @@ -4,22 +4,13 @@ /** * @jest-environment node */ -import fs from 'fs'; -import path from 'path'; -import { compileString } from 'sass'; - import { extractStyleApiDocs } from '../docs'; +import { compileWithModules, readStyleApiSource } from './sass-utils'; -const styleApiSource = fs.readFileSync(path.resolve(__dirname, '../index.scss'), 'utf8'); - -// Serves the style-api module through an importer: Sass's own file resolution doesn't work in jest's sandbox. -const importer = { - canonicalize: (url: string) => (url === 'style-api' ? new URL('style-api:index') : null), - load: () => ({ contents: styleApiSource, syntax: 'scss' as const }), -}; +const modules = { 'style-api': readStyleApiSource('index.scss') }; function compile(scss: string) { - return compileString(`@use 'style-api';\n${scss}`, { importers: [importer] }).css; + return compileWithModules(modules, `@use 'style-api';\n${scss}`); } test('docs produces markers that the extractor reads back', () => { diff --git a/src/internal/style-api/__tests__/sass-utils.ts b/src/internal/style-api/__tests__/sass-utils.ts new file mode 100644 index 0000000..44ff625 --- /dev/null +++ b/src/internal/style-api/__tests__/sass-utils.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'fs'; +import path from 'path'; +import { compileString } from 'sass'; + +export function readStyleApiSource(file: string) { + return fs.readFileSync(path.resolve(__dirname, '..', file), 'utf8'); +} + +// Compiles `scss`, serving each module source under its `@use` URL: Sass's own file resolution doesn't work in +// jest's sandbox. +export function compileWithModules(modules: Record, scss: string) { + const importer = { + canonicalize: (url: string) => (url in modules ? new URL(`module:${encodeURIComponent(url)}`) : null), + load: (url: URL) => ({ contents: modules[decodeURIComponent(url.pathname)], syntax: 'scss' as const }), + }; + return compileString(scss, { importers: [importer] }).css; +} diff --git a/src/internal/style-api/__tests__/slot-mixins.test.ts b/src/internal/style-api/__tests__/slot-mixins.test.ts new file mode 100644 index 0000000..7faae34 --- /dev/null +++ b/src/internal/style-api/__tests__/slot-mixins.test.ts @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * @jest-environment node + */ +import { StyleApiDocs } from '../docs'; +import { generateSlotMixins } from '../generate-slot-mixins'; +import { compileWithModules, readStyleApiSource } from './sass-utils'; + +const tokenSlot = (name: string, tokens: string[], properties: string[] = []) => ({ + name, + tokens, + tokenDescriptions: {}, + properties, + propertyDescriptions: {}, +}); + +const docs: Record = { + badge: { + slots: [ + tokenSlot( + 'root', + [], + ['color', 'background', 'border', 'border-radius', 'padding', 'padding-inline', 'font-family'] + ), + tokenSlot('hook', []), + { name: 'dismissButton', forwardsTo: { component: 'button', slot: 'root' } }, + ], + }, + slider: { slots: [tokenSlot('handle', ['border-radius'], ['color'])] }, +}; + +const modules = Object.fromEntries( + Object.entries(docs).map(([component, componentDocs]) => [ + component, + generateSlotMixins({ component, docs: componentDocs }), + ]) +); + +function compile(scss: string) { + const uses = Object.keys(modules).map(name => `@use '${name}';`); + return compileWithModules( + { + ...modules, + '@cloudscape-design/component-toolkit/internal/style-api/slot-mixins': readStyleApiSource('slot-mixins.scss'), + }, + [...uses, scss].join('\n') + ); +} + +test('generates a module per component', () => { + expect(modules).toMatchSnapshot(); +}); + +test('generated mixins compile to CSS', () => { + expect( + compile(` + .empty { + @include badge.root; + } + .badge { + @include badge.root( + $padding-inline: 4px, + $padding: 12px 0 0 2px, + $border-start-start-radius: 12px, + $border-radius: 4px 4px 0 0, + $border-inline-color: red blue, + $background: (linear-gradient(red, blue), #fff), + $font-family: ("Open Sans", sans-serif) + ); + &:hover { + @include badge.root($color: red); + } + } + .handle { + @include slider.handle($color: red, $border-radius: 4px); + } + `) + ).toMatchSnapshot(); +}); + +test.each([ + ['an unknown argument', `.x { @include badge.root($letter-spacing: 1px); }`, 'No parameter named $letter-spacing'], + ['use outside of a rule', `@include badge.root($color: red);`, 'badge.root must be included inside a rule'], +])('fails the build for %s', (_, scss, error) => { + expect(() => compile(scss)).toThrow(error); +}); + +test('fails generation when a token and a property argument share a name', () => { + expect(() => + generateSlotMixins({ component: 'x', docs: { slots: [tokenSlot('root', ['border-color'], ['border'])] } }) + ).toThrow('declares "border-color" as both a token and a property'); +}); diff --git a/src/internal/style-api/generate-slot-mixins.ts b/src/internal/style-api/generate-slot-mixins.ts new file mode 100644 index 0000000..12ad622 --- /dev/null +++ b/src/internal/style-api/generate-slot-mixins.ts @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StyleApiDocs, StyleApiTokenSlotDocs } from './docs.js'; + +export interface GenerateSlotMixinsOptions { + /** The component name, as used in the module path and in error messages (e.g. "badge"). */ + component: string; + /** The component's Style API docs, as returned by extractStyleApiDocs. */ + docs: StyleApiDocs; +} + +// The mixin arguments an allowlisted shorthand expands to. The order is general to specific, because the runtime +// emits declarations in this order and a later declaration wins. +const PROPERTY_ARGUMENTS: Record = { + padding: ['padding', 'padding-block', 'padding-inline'], + border: [ + 'border-color', + 'border-width', + 'border-style', + 'border-block-color', + 'border-block-width', + 'border-block-style', + 'border-inline-color', + 'border-inline-width', + 'border-inline-style', + ], + 'border-radius': [ + 'border-radius', + 'border-start-start-radius', + 'border-start-end-radius', + 'border-end-start-radius', + 'border-end-end-radius', + ], +}; + +const RUNTIME_MODULE = '@cloudscape-design/component-toolkit/internal/style-api/slot-mixins'; + +/** + * Generates a Sass module with one mixin per slot of a component. A mixin takes one keyword argument per token and + * CSS property of the slot, named after it, e.g. `@include badge.root($color: red, $padding-inline: 12px)`. Slots + * without tokens or properties, including forwarding slots, get no mixin: a forwarding slot is styled with the + * mixin of the slot it forwards to. + */ +export function generateSlotMixins({ component, docs }: GenerateSlotMixinsOptions): string { + const mixins = docs.slots + .filter((slot): slot is StyleApiTokenSlotDocs => 'tokens' in slot) + .filter(slot => slot.tokens.length > 0 || slot.properties.length > 0) + .map(slot => generateMixin(component, slot)); + + const header = [`// GENERATED from the ${component} Style API docs — do not edit.`]; + if (mixins.length > 0) { + header.push(`@use '${RUNTIME_MODULE}' as slot-mixins;`); + } + return [header.join('\n'), ...mixins].join('\n\n') + '\n'; +} + +function generateMixin(component: string, slot: StyleApiTokenSlotDocs): string { + const properties = [...new Set(slot.properties.flatMap(property => PROPERTY_ARGUMENTS[property] ?? [property]))]; + const clash = slot.tokens.find(name => properties.includes(name)); + if (clash) { + throw new Error(`Slot "${component}.${slot.name}" declares "${clash}" as both a token and a property.`); + } + const parameters = [...slot.tokens, ...properties].map(name => ` $${name}: null`); + const argumentMap = (names: string[]) => names.map(name => ` '${name}': $${name},`); + + const call = [` '${component}.${slot.name}',`]; + if (slot.tokens.length > 0) { + call.push(' $tokens: (', ...argumentMap(slot.tokens), ' ),'); + } + if (properties.length > 0) { + call.push(' $properties: (', ...argumentMap(properties), ' ),'); + } + + return [ + `@mixin ${slot.name}(`, + parameters.join(',\n'), + `) {`, + ` @include slot-mixins.apply(`, + ...call, + ` );`, + `}`, + ].join('\n'); +} diff --git a/src/internal/style-api/slot-mixins.scss b/src/internal/style-api/slot-mixins.scss new file mode 100644 index 0000000..3a68958 --- /dev/null +++ b/src/internal/style-api/slot-mixins.scss @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +@use 'sass:map'; +@use 'sass:meta'; + +// Runtime of the mixins generated by generate-slot-mixins.ts. Emits the non-null tokens as custom properties and the +// non-null properties as written, in the order they are passed. `$context` names the generated mixin in errors. +@mixin apply($context, $tokens: (), $properties: ()) { + @if not & { + @error '#{$context} must be included inside a rule that targets the slot class.'; + } + + // Tokens and properties go into nested rules instead of the consumer's rule. Otherwise a second include in the same + // rule would add declarations after the first include's nested rule, which Dart Sass deprecates (mixed-decls). + & { + @each $name, $value in _non-null($tokens) { + --awsui-style-#{$name}: #{meta.inspect($value)}; + } + } + // Component selectors carry one `:not(#\9)`; two make the consumer rule win. + &:not(#\9):not(#\9) { + @each $name, $value in _non-null($properties) { + #{$name}: $value; + } + } +} + +@function _non-null($arguments) { + $result: (); + @each $name, $value in $arguments { + @if $value != null { + $result: map.set($result, $name, $value); + } + } + @return $result; +} From ec64e0404c7ca1647089b4c524c102a4d4ebdc0c Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Tue, 20 Oct 2026 11:20:24 +0200 Subject: [PATCH 2/2] general properties rule --- src/internal/style-api/__tests__/slot-mixins.test.ts | 12 +++++++----- src/internal/style-api/generate-slot-mixins.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/internal/style-api/__tests__/slot-mixins.test.ts b/src/internal/style-api/__tests__/slot-mixins.test.ts index 7faae34..b3b6965 100644 --- a/src/internal/style-api/__tests__/slot-mixins.test.ts +++ b/src/internal/style-api/__tests__/slot-mixins.test.ts @@ -19,11 +19,7 @@ const tokenSlot = (name: string, tokens: string[], properties: string[] = []) => const docs: Record = { badge: { slots: [ - tokenSlot( - 'root', - [], - ['color', 'background', 'border', 'border-radius', 'padding', 'padding-inline', 'font-family'] - ), + tokenSlot('root', [], ['color', 'background', 'border', 'border-radius', 'padding', 'font-family']), tokenSlot('hook', []), { name: 'dismissButton', forwardsTo: { component: 'button', slot: 'root' } }, ], @@ -87,6 +83,12 @@ test.each([ expect(() => compile(scss)).toThrow(error); }); +test('fails generation when a slot allowlists a specific argument instead of its shorthand', () => { + expect(() => + generateSlotMixins({ component: 'x', docs: { slots: [tokenSlot('root', [], ['padding-inline'])] } }) + ).toThrow('Slot "x.root" allowlists "padding-inline". Allow "padding" instead.'); +}); + test('fails generation when a token and a property argument share a name', () => { expect(() => generateSlotMixins({ component: 'x', docs: { slots: [tokenSlot('root', ['border-color'], ['border'])] } }) diff --git a/src/internal/style-api/generate-slot-mixins.ts b/src/internal/style-api/generate-slot-mixins.ts index 12ad622..6e5ac7b 100644 --- a/src/internal/style-api/generate-slot-mixins.ts +++ b/src/internal/style-api/generate-slot-mixins.ts @@ -10,8 +10,8 @@ export interface GenerateSlotMixinsOptions { docs: StyleApiDocs; } -// The mixin arguments an allowlisted shorthand expands to. The order is general to specific, because the runtime -// emits declarations in this order and a later declaration wins. +// The mixin arguments an allowlisted shorthand expands to. Allowlists name the shorthand, never one of its arguments. +// The order is general to specific, because the runtime emits declarations in this order and a later one wins. const PROPERTY_ARGUMENTS: Record = { padding: ['padding', 'padding-block', 'padding-inline'], border: [ @@ -56,7 +56,13 @@ export function generateSlotMixins({ component, docs }: GenerateSlotMixinsOption } function generateMixin(component: string, slot: StyleApiTokenSlotDocs): string { - const properties = [...new Set(slot.properties.flatMap(property => PROPERTY_ARGUMENTS[property] ?? [property]))]; + for (const property of slot.properties) { + const shorthand = Object.keys(PROPERTY_ARGUMENTS).find(key => PROPERTY_ARGUMENTS[key].includes(property)); + if (shorthand && shorthand !== property) { + throw new Error(`Slot "${component}.${slot.name}" allowlists "${property}". Allow "${shorthand}" instead.`); + } + } + const properties = slot.properties.flatMap(property => PROPERTY_ARGUMENTS[property] ?? [property]); const clash = slot.tokens.find(name => properties.includes(name)); if (clash) { throw new Error(`Slot "${component}.${slot.name}" declares "${clash}" as both a token and a property.`);