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
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
),
);
}
",
}
`;
15 changes: 3 additions & 12 deletions src/internal/style-api/__tests__/mixins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
20 changes: 20 additions & 0 deletions src/internal/style-api/__tests__/sass-utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, 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;
}
96 changes: 96 additions & 0 deletions src/internal/style-api/__tests__/slot-mixins.test.ts
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

/**
* @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<string, StyleApiDocs> = {
badge: {
slots: [
tokenSlot('root', [], ['color', 'background', 'border', 'border-radius', 'padding', '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 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'])] } })
).toThrow('declares "border-color" as both a token and a property');
});
90 changes: 90 additions & 0 deletions src/internal/style-api/generate-slot-mixins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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. 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<string, string[]> = {
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 {
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.`);
}
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');
}
37 changes: 37 additions & 0 deletions src/internal/style-api/slot-mixins.scss
Original file line number Diff line number Diff line change
@@ -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;
}
Loading