From 8d1c0d54c5367809b2c2b28054b701304b16f10f Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 27 Aug 2026 15:30:34 -0500 Subject: [PATCH 01/90] More chip refinement --- package-lock.json | 8 + packages/craftcms-ui/.storybook/preview.ts | 7 + .../custom-elements-manifest.config.mjs | 37 ++ packages/craftcms-ui/package.json | 1 + .../craftcms-ui/src/components/chip/Chip.mdx | 98 +++++ .../src/components/chip/chip.stories.ts | 353 +++++++++++++++--- .../src/components/chip/chip.styles.ts | 87 +++-- .../craftcms-ui/src/components/chip/chip.ts | 100 +++-- .../components/truncate/truncate.styles.ts | 1 + src/Cp/Html/ElementHtml.php | 1 - 10 files changed, 582 insertions(+), 111 deletions(-) create mode 100644 packages/craftcms-ui/src/components/chip/Chip.mdx diff --git a/package-lock.json b/package-lock.json index 90950245c7a..c300b91d8df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8441,6 +8441,13 @@ "vue": "^3.5.0" } }, + "node_modules/@wc-toolkit/type-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@wc-toolkit/type-parser/-/type-parser-1.3.1.tgz", + "integrity": "sha512-xmRf9B3L37IO7HXSf/37Uv1xWlsA3GluslA4Ao8S0pFFlo5qEwrlgsnBxcxtnSh36zkoLC7p8VpW5dvRNbk8vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@web/config-loader": { "version": "0.1.3", "dev": true, @@ -25283,6 +25290,7 @@ "@types/node": "^25.6.0", "@vitest/coverage-v8": "4.1.10", "@wc-toolkit/storybook-helpers": "^10.3.0", + "@wc-toolkit/type-parser": "^1.3.1", "del": "^8.0.1", "dom-accessibility-api": "^0.7.1", "esbuild": "^0.28.0", diff --git a/packages/craftcms-ui/.storybook/preview.ts b/packages/craftcms-ui/.storybook/preview.ts index 253dd644efa..2c5321a7737 100644 --- a/packages/craftcms-ui/.storybook/preview.ts +++ b/packages/craftcms-ui/.storybook/preview.ts @@ -50,6 +50,13 @@ const preview: Preview = { }, }, + docs: { + // Show each story's source in a "Code" panel alongside Controls, so the + // markup is available from the story view and not only from the docs + // page. Stories that pin `docs.source.code` supply that value here too. + codePanel: true, + }, + options: { storySort: { method: 'alphabetical', diff --git a/packages/craftcms-ui/custom-elements-manifest.config.mjs b/packages/craftcms-ui/custom-elements-manifest.config.mjs index e2767bf8ccc..2ef35cc67c9 100644 --- a/packages/craftcms-ui/custom-elements-manifest.config.mjs +++ b/packages/craftcms-ui/custom-elements-manifest.config.mjs @@ -1,3 +1,26 @@ +import {getTsProgram, typeParserPlugin} from '@wc-toolkit/type-parser'; + +/** + * The type parser warns once for every type it declines to expand: DOM + * interfaces reached through a property (`Element`, `HTMLCanvasElement`), the + * component classes themselves, and anything past its depth or property + * limits. Expanding those was never the point — only the union aliases behind + * `size`, `variant`, and `appearance` reach the Storybook controls — so the + * bail-out notices are dropped here. + * + * The filter is deliberately narrow. Real parser warnings, such as a bad + * `tsconfig.json`, use a different prefix and still print. The parser calls + * `console.warn(colorFormat, message)`, so the message is the second argument. + */ +const SKIPPED_TYPE_NOTICE = '[type-parser] - Skipped parsing type'; +const consoleWarn = console.warn; +console.warn = (...args) => { + if (args.some((arg) => typeof arg === 'string' && arg.includes(SKIPPED_TYPE_NOTICE))) { + return; + } + consoleWarn(...args); +}; + /** * A leaked TypeScript AST node (e.g. a `SourceFile`, which holds a circular * `parent` pointer) is a TS node, not a manifest value. The CEM manifest schema @@ -47,7 +70,21 @@ export default { globs: ['src/components/**/*.ts'], exclude: ['**/*.stories.ts', '**/*.styles.ts', '**/*.test.ts'], outdir: 'dist', + // The type parser needs a real TypeScript program so it can resolve type + // aliases (`SizeValue`) back to their union members ('small' | 'medium' | + // 'large'). Without this the manifest only records the alias name, and + // Storybook renders a text box instead of a select. + overrideModuleCreation: ({ts, globs}) => { + const program = getTsProgram(ts, globs, 'tsconfig.json'); + return program + .getSourceFiles() + .filter((sf) => globs.find((glob) => sf.fileName.includes(glob))); + }, plugins: [ + // Expand type aliases into `parsedTypes`, which `.storybook/preview.ts` + // reads via `setStorybookHelpersConfig({typeRef: 'parsedTypes'})`. + typeParserPlugin({propertyName: 'parsedTypes'}), + // Add a plugin to prevent inheritance tree analysis errors { name: 'skip-external-inheritance', diff --git a/packages/craftcms-ui/package.json b/packages/craftcms-ui/package.json index e20b513aa60..9949c0c88e0 100644 --- a/packages/craftcms-ui/package.json +++ b/packages/craftcms-ui/package.json @@ -99,6 +99,7 @@ "@types/node": "^25.6.0", "@vitest/coverage-v8": "4.1.10", "@wc-toolkit/storybook-helpers": "^10.3.0", + "@wc-toolkit/type-parser": "^1.3.1", "del": "^8.0.1", "dom-accessibility-api": "^0.7.1", "esbuild": "^0.28.0", diff --git a/packages/craftcms-ui/src/components/chip/Chip.mdx b/packages/craftcms-ui/src/components/chip/Chip.mdx new file mode 100644 index 00000000000..faa648e7dab --- /dev/null +++ b/packages/craftcms-ui/src/components/chip/Chip.mdx @@ -0,0 +1,98 @@ +import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs/blocks'; +import * as ChipStories from './chip.stories'; + + + +# Chip + +`` is a container that represents a single entity. That could be an entry, an asset, a user, a category, etc. +It pairs a label with an optional leading prefix and an optional trailing suffix. + + + +Use a chip to represent an entity that can be acted on. +To represent a piece of state instead — a status, a count, or a label — use [Badge](?path=/docs/components-badge--docs). + +## Composition + +A chip has three regions: an optional prefix, the label, and an optional suffix. +The prefix and suffix are only rendered when there is content for them, so a chip with nothing but a label renders neither. + + + +The label is the chip's default slot. +The suffix is rendered as soon as the `suffix` slot is filled, and is where per-chip actions belong: + + + +Chips are frequently populated after they have been added to the page, such as when an action menu is attached once the entity's available actions are known. +A chip watches its own light DOM for changes, so content added to a slot after the initial render is picked up automatically. + + + +## The Prefix + +There are two ways to fill the prefix, and they are mutually exclusive. + +Fill the `prefix` slot to supply your own leading content. +This replaces the entire prefix region, and the built-in slots described below are ignored: + + + +Otherwise, use one of the built-in prefix slots: + +- `thumbnail` – Requires `show-thumb`. Without it the slot is not rendered, and its content does not appear. +- `icon` – Rendered when the `icon` attribute is set. +- `status` – Rendered whenever the slot is filled, or when `show-status` is set. + + + +The `icon` attribute is a shorthand for the `icon` slot, and does not require a slot of its own: + + + +## Variant and Appearance + +`variant` sets the semantic color group the chip draws its tokens from, and `appearance` determines how those tokens are applied. +The two are independent, and they behave the same way here as on every other component that accepts them. +See [Variants & Appearances](?path=/docs/tokens-variants-appearances--docs) for the underlying token mapping. + + + +`plain` removes the chip's border, background, padding, and shadow, leaving the label and prefix inline with the surrounding content. +Use it when a chip appears within running text or a dense table, rather than as a standalone object. + +A chip stamps `data-color="white"` on itself when it connects, so it reads as a raised surface by default. +Set `data-color` on the chip to override that. Because the attribute lands on the chip itself, an ancestor's `data-color` does not reach it — colour each chip directly: + + + +## Styling + +The chip exposes three CSS parts — `chip`, `prefix`, and `suffix` — and a set of `--c-chip-*` custom properties for its height, radius, padding, shadow, and border. +Both are listed in full under [Properties](#properties). + +Prefer the custom properties over styling the parts directly. +A chip is sized by its padding and content, so scaling one is usually a single declaration: + +```css +craft-chip { + --c-chip-height: var(--c-size-control-md); +} +``` + +## Accessibility + +A chip is a container, not a control. +It has no implicit role and does not receive focus. +The content you place in it carries its own semantics, which makes two things your responsibility: + +- **Interactive content must provide its own accessible name.** An action button in the `suffix` slot requires a label, and an icon-only button requires one on the icon. The chip's label does not name it. +- **Decorative prefixes must remain decorative.** A thumbnail that only repeats the label takes `alt=""`. A status indicator that conveys meaning the label does not should be given a `label` of its own. + +Setting `selectable` renders a checkbox before the prefix, for chips within a multi-select list. +That checkbox has no label of its own, so the chip must provide an accessible name. + +## Properties + + diff --git a/packages/craftcms-ui/src/components/chip/chip.stories.ts b/packages/craftcms-ui/src/components/chip/chip.stories.ts index 3a93695b529..91a37bb3ead 100644 --- a/packages/craftcms-ui/src/components/chip/chip.stories.ts +++ b/packages/craftcms-ui/src/components/chip/chip.stories.ts @@ -1,92 +1,331 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; -import {html} from 'lit'; +import {html, nothing} from 'lit'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; import {Color} from '../../constants/colors'; import './chip.js'; import '../status/status.js'; import '../button/button.js'; +import '../icon/icon.js'; +import '../action-menu/action-menu.js'; +import '../avatar/avatar.js'; +import '../badge/badge.js'; +import type CraftChip from './chip.js'; + +/** + * `args` and `argTypes` are derived from the custom elements manifest, so the + * controls and the API tables follow the component's JSDoc. Adding a property + * to `chip.ts` surfaces it here without touching this file. + */ +const {args, argTypes, template} = getStorybookHelpers('craft-chip'); + +const ACTION_BUTTON = ` + +`; -// More on how to set up stories at: https://storybook.js.org/docs/writing-stories const meta = { title: 'Components/Chip', component: 'craft-chip', - argTypes: {}, - render: (args) => html` This is a chip `, + args: {...args, 'default-slot': 'Homepage'}, + argTypes, + // Render from args alone so every control — attributes and slots — drives + // the story. Stories below vary the args, not the template. + render: (args) => template(args), } satisfies Meta; export default meta; type Story = StoryObj; -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -export const Default: Story = { - args: {}, -}; +/** Every `size` value, including unset, for the size stories below. */ +const chipSizes = [ + {size: 'small', label: 'Small'}, + {size: 'medium', label: 'Medium'}, + {size: 'large', label: 'Large'}, +] as const; +/** + * A chip with nothing but a label renders neither the prefix nor the suffix + * region. + */ +export const Default: Story = {}; + +/** + * The two regions are independent. Fill `suffix` for a per-chip action, and + * `prefix` to supply your own leading content. + */ export const PrefixAndSuffix: Story = { - args: {}, - render: (args) => html` - - - This is a chip - - - - - `, + args: { + 'status-slot': '', + 'suffix-slot': ACTION_BUTTON, + }, +}; + +export const CustomPrefix: Story = { + args: { + 'prefix-slot': `
+ Btn +
`, + }, +}; + +export const SuffixOnly: Story = { + args: {'suffix-slot': ACTION_BUTTON}, +}; + +/** + * Attaching an action menu after the chip has rendered, then adding items to + * it. "Attach action menu" appends a `craft-action-menu` to the chip's + * `suffix` slot, and the chip renders the suffix region without being told to + * re-render. "Add action" appends an item to the menu that is already there. + */ +export const DeferredActions: Story = { + parameters: { + controls: {disable: true}, + docs: { + // The feature here is imperative, so the rendered markup does not show + // it. Keep this in sync with the handlers below. + source: { + code: `const chip = document.querySelector('craft-chip'); + +// Attach the menu once the entity's available actions are known. +const menu = document.createElement('craft-action-menu'); +menu.slot = 'suffix'; +menu.label = 'Actions'; +menu.icon = 'ellipsis'; +menu.actions = [ + {label: 'View', icon: 'eye', onClick: () => {}}, + {label: 'Edit', icon: 'pen', onClick: () => {}}, + {label: 'Delete', icon: 'trash', variant: 'danger', onClick: () => {}}, +]; + +// The chip observes its own light DOM, so the suffix region appears on its own. +chip.append(menu); + +// \`actions\` is a reactive property. Add an item by reassigning the array — +// pushing onto it in place does not trigger a re-render. +menu.actions = [ + ...menu.actions, + {label: 'Custom action 1', icon: 'lightbulb', onClick: () => {}}, +];`, + language: 'js', + }, + }, + }, + render: () => { + let added = 0; + + const menuFor = (trigger: HTMLElement) => + trigger.parentElement?.querySelector('craft-action-menu') ?? null; + + const attachMenu = (event: Event) => { + const trigger = event.currentTarget as HTMLElement; + const chip = trigger.parentElement?.querySelector('craft-chip'); + if (!chip || chip.querySelector('[slot="suffix"]')) { + return; + } + + const menu = document.createElement('craft-action-menu'); + menu.slot = 'suffix'; + menu.label = 'Actions'; + menu.icon = 'ellipsis'; + menu.actions = [ + {label: 'View', icon: 'eye', onClick: () => {}}, + {label: 'Edit', icon: 'pen', onClick: () => {}}, + {label: 'Delete', icon: 'trash', variant: 'danger', onClick: () => {}}, + ]; + + chip.append(menu); + }; + + const addAction = (event: Event) => { + const menu = menuFor(event.currentTarget as HTMLElement); + if (!menu) { + return; + } + + const current = Array.isArray(menu.actions) ? menu.actions : []; + added++; + + // Reassign rather than push: `actions` is a reactive property, and Lit + // compares by reference. + menu.actions = [ + ...current, + { + label: `Custom action ${added}`, + icon: 'lightbulb', + onClick: () => {}, + }, + ]; + }; + + return html` +
+ Homepage + + Attach action menu + + + Add action + +
+ `; + }, }; +/** + * `show-thumb` is required. Without it, the `thumbnail` slot is not rendered, + * and its content does not appear. + */ export const Thumbnail: Story = { - args: {}, - render: (args) => html` - - - This is a chip - - - - + args: { + 'show-thumb': true, + 'thumbnail-slot': '', + 'suffix-slot': ACTION_BUTTON, + }, +}; + +/** + * The `icon` attribute is a shorthand for the `icon` slot, and setting it is + * what causes that slot to be rendered. + */ +export const Icon: Story = { + args: {icon: 'star'}, +}; + +/** + * The four `size` values on a bare chip. Each step is taller than the last: + * `small` adds block padding, and `medium` applies a minimum height. `large` + * has no styles of its own, so it renders the same as an unset `size`. + */ +export const Sizes: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +
+ ${chipSizes.map( + ({size, label}) => + html`${label}` + )} +
`, }; -export const PrefixOnly: Story = { - args: {}, - render: (args) => html` - - - This is a chip - +/** + * The same sizes with a thumbnail and a suffix. `medium` sets a minimum height + * on the chip's regions, so the difference is clearest once there is content + * that does not already fill them. + */ +export const SizesWithContent: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +
+ ${chipSizes.map( + ({size, label}) => html` + + + ${label} + + + + + ` + )} +
`, }; -export const SuffixOnly: Story = { - args: {}, - render: (args) => html` - - - - - This is a chip - +/** + * `variant` sets the color group and `appearance` determines how those tokens + * are applied. See [Variants & Appearances](?path=/docs/tokens-variants-appearances--docs) + * for the underlying token mapping. + */ +export const Appearances: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +
+ ${['solid', 'fill', 'outline-fill', 'outline', 'plain'].map( + (appearance) => + html` + ${appearance} + ` + )} +
`, }; +/** + * A chip inherits the palette of any `data-color` ancestor, so chips can be + * tinted per row without overriding tokens. + */ export const Colors: Story = { + parameters: {controls: {disable: true}}, render: () => html` -
-
- ${Object.entries(Color).map( - ([name, value]) => - html` - ${name} - Button - ` - )} -
+
+ ${Object.entries(Color).map( + ([name, value]) => + html` + ${name} + Button + ` + )} +
+ `, +}; + +/** + * Every slot at once. The first chip fills the built-in prefix slots — + * `thumbnail`, `icon`, and `status` — each of which needs its own attribute + * before it renders. The second fills `prefix` instead, which replaces that + * whole region, so the built-in slots are ignored. Both fill the default slot + * and `suffix`. + */ +export const KitchenSink: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +
+ + + + + Built-in prefix slots + + + + + Action Item + + + + + Badge + Custom prefix + + + + + Action Item + +
`, }; diff --git a/packages/craftcms-ui/src/components/chip/chip.styles.ts b/packages/craftcms-ui/src/components/chip/chip.styles.ts index 6e25cc3733b..73f2c227c4b 100644 --- a/packages/craftcms-ui/src/components/chip/chip.styles.ts +++ b/packages/craftcms-ui/src/components/chip/chip.styles.ts @@ -10,26 +10,18 @@ export default css` } .cp-chip { - --_min-height: var(--c-chip-height, none); - --_thumb-size: calc(24rem / 16); - --_radius: var(--c-chip-radius, var(--c-radius-md)); - --_fill: var(--c-color-fill-quiet, var(--c-surface-raised)); + --_chip-spacing: 0.25em; + --_thumb-size: calc(30rem / 16); + --_radius: var(--c-radius-md); padding: 0; display: inline-flex; - min-width: auto; border-radius: var(--_radius); align-items: center; box-shadow: var(--c-chip-shadow, var(--c-shadow-sm)); + background-color: white; - /* colorable styles */ - color: var(--c-color-on-quiet, var(--c-color-neutral-on-quiet)); border-width: var(--c-chip-border-width, 1px); border-style: var(--c-chip-border-style, solid); - border-color: var( - --c-color-border-quiet, - var(--c-color-neutral-border-quiet) - ); - background-color: var(--c-color-fill-quiet, var(--c-surface-raised)); overflow: clip; } @@ -39,25 +31,60 @@ export default css` display: flex; } - .cp-chip[appearance='plain'], + /* + * Appearance tiers, mirroring craft-callout so the two read at the same + * intensity for a given variant. The variant remaps the generic + * --c-color-* tokens; each tier below picks which loudness of them to use. + */ + :host([appearance~='solid']) .cp-chip { + background-color: var(--c-color-fill-loud); + border-color: var(--c-color-border-loud); + color: var(--c-color-on-loud); + } + + :host([appearance~='fill']) .cp-chip { + background-color: var(--c-color-fill-normal); + border-color: transparent; + color: var(--c-color-on-normal); + } + + :host([appearance~='outline-fill']) .cp-chip { + background-color: var(--c-color-fill-normal); + border-color: var(--c-color-border-normal); + color: var(--c-color-on-normal); + } + + :host([appearance~='outline']) .cp-chip { + background-color: transparent; + border-color: var(--c-color-border-quiet); + color: var(--c-color-on-quiet); + } + + :host([appearance~='plain']) .cp-chip { + background-color: transparent; + border-color: transparent; + color: var(--c-color-on-quiet); + } + + /* Layout side of plain: no chrome, so no padding or shadow either. */ .cp-chip--plain { - --_min-height: none; padding-block: 0; padding-inline: 0; - border-color: transparent; - background-color: transparent; box-shadow: none; } - .cp-chip[size='small'], .cp-chip--small { - padding-block: calc(var(--c-spacing-xs) / 2); + --_chip-spacing: 0.25em; } - .cp-chip[size='medium'], .cp-chip--medium { - padding-block: 0; - min-height: var(--c-size-control-md); + --_chip-spacing: 0.5em; + --_thumb-size: calc(34rem / 16); + } + + .cp-chip--large { + --_chip-spacing: 1em; + --_thumb-size: calc(40rem / 16); } .cp-chip__prefix, @@ -65,10 +92,10 @@ export default css` .cp-chip__suffix { display: inline-flex; flex-direction: column; - min-height: var(--_min-height); } .cp-chip__body { + padding: calc(var(--_chip-spacing) / 2) var(--_chip-spacing); display: flex; gap: var(--c-spacing-sm); align-items: center; @@ -80,9 +107,9 @@ export default css` text-overflow: ellipsis; } + /* Prefix gets no padding on its own because each prefix item has different spacing needs */ .cp-chip__prefix { position: relative; - padding-inline-end: var(--c-spacing-sm); display: flex; align-items: center; flex-direction: row; @@ -90,18 +117,22 @@ export default css` } .cp-chip__suffix { + padding: calc(var(--_chip-spacing) / 2); + padding-inline-start: var(--_chip-spacing); display: flex; - padding-inline-start: var(--c-spacing-md); } - .cp-chip__status { + .cp-chip__status, + .cp-chip__icon { display: inline-flex; - padding-inline: var(--c-spacing-xs); + padding-inline: var(--_chip-spacing); } .cp-chip__thumbnail { + display: flex; position: relative; - padding: var(--c-spacing-sm); - /*border-radius: calc(var(--_radius) - var(--c-spacing-xs));*/ + width: var(--_thumb-size); + aspect-ratio: 1; + padding-inline-end: var(--_chip-spacing); } `; diff --git a/packages/craftcms-ui/src/components/chip/chip.ts b/packages/craftcms-ui/src/components/chip/chip.ts index 8d645913034..bf5a791bf50 100644 --- a/packages/craftcms-ui/src/components/chip/chip.ts +++ b/packages/craftcms-ui/src/components/chip/chip.ts @@ -7,28 +7,47 @@ import {Appearance, type AppearanceValue} from '@src/constants/appearances'; import {Variant, type VariantValue} from '@src/constants/variants'; import type {SizeValue} from '@src/constants/size'; import {ThumbnailLoader} from '@src/utilities/thumbnail-loader'; +import variantsStyles from '@src/styles/variants.styles.js'; /** - * @summary A compact, inline element that pairs a label with an optional - * prefix (icon, status indicator, thumbnail, …) and suffix (e.g. an action - * button). Used for element chips, status chips, and similar UI. + * @summary A container that pairs a label with an optional + * leading prefix — a thumbnail, an icon, or a status dot — and a trailing + * suffix, usually an action button. Chips represent a single entity in a + * list: an entry, an asset, a user, a category, etc. * - * The prefix is only rendered when the `prefix` or `icon` slot is filled or the - * `icon` attribute is set; the suffix is only rendered when the `suffix` slot is - * filled. + * The prefix and suffix regions are only rendered when there is content for + * them, so a chip with nothing but a label renders neither. The suffix is + * rendered when the `suffix` slot is filled. The prefix is rendered when the + * `prefix`, `icon`, `thumbnail`, or `status` slot is filled, or when the + * `icon` attribute or `show-status` is set. * - * @slot - The chip's body/label content. - * @slot prefix - Content shown before the body, e.g. a status indicator or - * thumbnail. Takes precedence over the `icon` slot/attribute. - * @slot icon - Custom icon content shown in the prefix, as an alternative to the - * `icon` attribute. - * @slot suffix - Content shown after the body, e.g. an action button. + * Filling the `prefix` slot replaces the entire prefix region. Use it to + * supply your own leading content; the built-in `thumbnail`, `icon`, and + * `status` slots are ignored when it is present. + * + * On connect the chip stamps `data-color="white"` on itself so it reads as a + * raised surface by default. Set `data-color` yourself to override it. Because + * the attribute lands on the chip, an ancestor's `data-color` no longer + * reaches it — colour the chip directly instead. + * + * @slot - The chip's label. + * @slot prefix - Leading content. Replaces the built-in prefix region, so the + * `thumbnail`, `icon`, and `status` slots are ignored when this is filled. + * @slot thumbnail - A thumbnail image for the prefix. Requires `show-thumb`. + * Without it, the slot is not rendered and its content does not appear. + * @slot icon - Icon content for the prefix, as an alternative to the `icon` + * attribute. Only rendered when `icon` is set. + * @slot status - A status indicator for the prefix. Rendered whenever this + * slot is filled, or when `show-status` is set. + * @slot suffix - Trailing content, shown after the label. Typically an action + * button or menu. * * @csspart chip - The outer chip wrapper. * @csspart prefix - The prefix container. * @csspart suffix - The suffix container. * - * @cssproperty --c-chip-height - Minimum height of the chip. Defaults to `--c-size-control-sm`. + * @cssproperty --c-chip-height - Minimum height of the chip's regions. Unset + * by default, so the chip is sized by its padding and content. * @cssproperty --c-chip-radius - Corner radius. Defaults to `--c-radius-md`. * @cssproperty --c-chip-spacing-inline - Inline (horizontal) padding. Defaults to `0`. * @cssproperty --c-chip-spacing-block - Block (vertical) padding. Defaults to `--c-spacing-sm`. @@ -37,27 +56,50 @@ import {ThumbnailLoader} from '@src/utilities/thumbnail-loader'; * @cssproperty --c-chip-border-style - Border style. Defaults to `solid`. */ export default class CraftChip extends LitElement { - static override styles: CSSResultGroup = [styles]; + static override styles: CSSResultGroup = [variantsStyles, styles]; - /** Size of the chip. */ - @property() size: SizeValue | '' = ''; + /** + * How much vertical space the chip takes. `small` adds a small amount of + * block padding, and `medium` applies a minimum height. `large` is accepted, + * but has no styles of its own and renders the same as an unset `size`. + * Leave it unset to size the chip from its content. + */ + @property() size: SizeValue = 'small'; - /** Variant of the chip. `plain` will render with no border or padding */ - @property({reflect: true}) variant: VariantValue = Variant.Neutral; + /** + * The semantic color group the chip draws its tokens from. It is combined + * with `appearance`, which determines how those tokens are applied. + */ + @property({reflect: true}) variant: VariantValue | null = null; - /** Appearance of the chip. Defaults to `outline-fill`. */ + /** + * How prominently the variant color is applied. `plain` removes the chip's + * border, background, padding, and shadow, leaving the label and prefix + * inline with the surrounding content. + */ @property({reflect: true}) appearance: AppearanceValue = Appearance.OutlineFill; - /** Shortcut for adding an icon as the prefix */ + /** + * The name of an icon to render in the prefix. This is a shorthand for + * filling the `icon` slot, and setting it is what causes that slot to be + * rendered. + */ @property() icon: string | null = null; - @property({attribute: 'show-indicators', type: Boolean}) - showIndicators: boolean = false; + /** Renders the `status` slot within the prefix. */ @property({attribute: 'show-status', type: Boolean}) showStatus: boolean = false; + + /** Renders the `thumbnail` slot within the prefix. */ @property({attribute: 'show-thumb', type: Boolean}) showThumb: boolean = false; + + /** + * Renders a checkbox before the prefix, for chips within a multi-select + * list. The checkbox has no label of its own, so the chip must provide an + * accessible name. + */ @property({type: Boolean}) selectable: boolean = false; #thumbLoader = new ThumbnailLoader(); @@ -78,6 +120,11 @@ export default class CraftChip extends LitElement { override connectedCallback(): void { super.connectedCallback(); + + if (!this.getAttribute('data-color')) { + this.setAttribute('data-color', 'white'); + } + // Attributes included: content moves between slots by having its `slot` // attribute set, not only by being added or removed. this.#observer.observe(this, { @@ -94,6 +141,9 @@ export default class CraftChip extends LitElement { } renderPrefix() { + const showStatus = + this.showStatus || !!this.querySelector('[slot="status"]'); + return html`
${this.showThumb @@ -104,7 +154,7 @@ export default class CraftChip extends LitElement { >` : nothing} - ${this.showStatus + ${showStatus ? html`` : nothing} @@ -124,8 +174,9 @@ export default class CraftChip extends LitElement { const renderPrefix = !!this.querySelector('[slot="prefix"]') || !!this.querySelector('[slot="icon"]') || + !!this.querySelector('[slot="status"]') || !!this.querySelector('[slot="thumbnail"]') || - !!this.querySelector('[slot="indicator"]') || + this.showStatus || this.icon; const renderSuffix = !!this.querySelector('[slot="suffix"]'); @@ -140,7 +191,6 @@ export default class CraftChip extends LitElement { 'cp-chip--plain': this.appearance === Appearance.Plain, 'cp-chip--selectable': this.selectable, 'cp-chip--show-thumb': this.showThumb, - 'cp-chip--show-indicators': this.showIndicators, 'cp-chip--show-status': this.showStatus, })}" > diff --git a/packages/craftcms-ui/src/components/truncate/truncate.styles.ts b/packages/craftcms-ui/src/components/truncate/truncate.styles.ts index ab475d3ea47..74187abd170 100644 --- a/packages/craftcms-ui/src/components/truncate/truncate.styles.ts +++ b/packages/craftcms-ui/src/components/truncate/truncate.styles.ts @@ -6,6 +6,7 @@ export default css` /* Allow the element to shrink below its content size in flex/grid layouts so the text actually truncates instead of forcing the container wider. */ min-width: 0; + max-width: 100%; } .truncate { diff --git a/src/Cp/Html/ElementHtml.php b/src/Cp/Html/ElementHtml.php index 4a2d087a28e..a9660ccfd7c 100644 --- a/src/Cp/Html/ElementHtml.php +++ b/src/Cp/Html/ElementHtml.php @@ -96,7 +96,6 @@ public function chipHtml(Chippable $component, array $config = []): string $config['size'], ...Html::explodeClass($config['class']), ], - 'show-indicators' => $config['showIndicators'], 'show-thumb' => $config['showThumb'], 'show-status' => $config['showStatus'], 'selectable' => $config['selectable'], From aa7a435fd02fa7751e3eabf467c7e6b0996ba46f Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 28 Aug 2026 10:02:30 -0500 Subject: [PATCH 02/90] Regenerate the custom elements manifest while Storybook runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.storybook/preview.ts` imports `dist/custom-elements.json` statically, so a running Storybook keeps whatever manifest it booted with. Editing a component's JSDoc then had no effect until the server was restarted — and because the helpers turn any arg without a matching argType into a literal attribute, a newly documented slot would render as an escaped `*-slot="<p>…"` string instead of slotted content. Run the analyzer's watch mode alongside the dev server so the manifest, the controls, and the generated API tables follow the source. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- packages/craftcms-ui/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/craftcms-ui/package.json b/packages/craftcms-ui/package.json index 9949c0c88e0..8c40a2472c2 100644 --- a/packages/craftcms-ui/package.json +++ b/packages/craftcms-ui/package.json @@ -26,7 +26,9 @@ "check:exports": "attw --pack .", "check:types": "tsc --noEmit", "prestorybook": "npm run build:manifest", - "storybook": "storybook dev -p 6006", + "watch:manifest": "npm run build:manifest -- --watch", + "storybook": "run-p -l watch:manifest storybook:dev", + "storybook:dev": "storybook dev -p 6006", "prebuild:storybook": "npm run build:manifest", "build:storybook": "storybook build", "build:manifest": "custom-elements-manifest analyze --litelement --outdir dist", From 637e3c1418f307c8a5649ae2ed5b3970175c03d7 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 28 Aug 2026 10:02:40 -0500 Subject: [PATCH 03/90] Give craft-chip's selectable checkbox an accessible name `selectable` rendered a bare ``, which axe flags as "Form elements must have labels" and which blocked any story exercising it from passing the a11y gate. Name it with the new `select-label` attribute, falling back to a translated "Select". A list of chips should set it to the entity each chip stands for, so the checkboxes do not all read alike. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../craftcms-ui/src/components/chip/Chip.mdx | 2 +- .../src/components/chip/chip.stories.ts | 15 +++++++++++++-- .../craftcms-ui/src/components/chip/chip.ts | 19 ++++++++++++++++--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/craftcms-ui/src/components/chip/Chip.mdx b/packages/craftcms-ui/src/components/chip/Chip.mdx index faa648e7dab..730af7aaf35 100644 --- a/packages/craftcms-ui/src/components/chip/Chip.mdx +++ b/packages/craftcms-ui/src/components/chip/Chip.mdx @@ -91,7 +91,7 @@ The content you place in it carries its own semantics, which makes two things yo - **Decorative prefixes must remain decorative.** A thumbnail that only repeats the label takes `alt=""`. A status indicator that conveys meaning the label does not should be given a `label` of its own. Setting `selectable` renders a checkbox before the prefix, for chips within a multi-select list. -That checkbox has no label of its own, so the chip must provide an accessible name. +Name it with `select-label` so a list of chips does not read as a run of identically labelled checkboxes; it falls back to a generic "Select". ## Properties diff --git a/packages/craftcms-ui/src/components/chip/chip.stories.ts b/packages/craftcms-ui/src/components/chip/chip.stories.ts index 91a37bb3ead..1ed0692329c 100644 --- a/packages/craftcms-ui/src/components/chip/chip.stories.ts +++ b/packages/craftcms-ui/src/components/chip/chip.stories.ts @@ -293,7 +293,13 @@ export const KitchenSink: Story = { parameters: {controls: {disable: true}}, render: () => html`
- + @@ -312,7 +318,12 @@ export const KitchenSink: Story = { - Badge + Badge Custom prefix - ${this.selectable ? html` ` : nothing} + ${this.selectable + ? html` ` + : nothing} ${renderPrefix ? this.renderPrefix() : nothing} ${renderSuffix From e9522b6e445fd1b35fe5341e19e99b77ac58e9db Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 28 Aug 2026 10:02:53 -0500 Subject: [PATCH 04/90] Document craft-callout, and limit padding to the spacing scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callout had three undescribed attributes, no documented slots despite having four, placeholder descriptions on the rest, and a `@TODO` sitting inside a public JSDoc block. Rewrite the annotations so the manifest carries the whole surface, convert the stories to derive their args and argTypes from it, and add a docs page. Fixes found while writing it: the `variant` control was populated with appearance values, the default args set a `variant` that does not exist, a dead `flash` arg had a control, and `craft-button` was never imported so the action button never upgraded. The `padding` attribute no longer accepts unitless numbers or arbitrary CSS lengths — only the `sm`/`md`/`lg`/`xl` steps and `0`/`none`. An off-scale value now writes nothing and leaves the component's own default standing; consumers who need one set the padding custom properties instead. This lands in the shared `Paddable` mixin, so craft-pane behaves the same way. Without a title, the callout grid is now a single row, rather than a two-row template with the body borrowing the title's area. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../src/components/callout/Callout.mdx | 126 ++++++ .../src/components/callout/callout.stories.ts | 359 +++++++++--------- .../src/components/callout/callout.styles.ts | 28 +- .../src/components/callout/callout.test.ts | 28 +- .../src/components/callout/callout.ts | 61 ++- .../src/components/pane/pane.stories.ts | 22 +- .../src/components/pane/pane.test.ts | 22 +- .../craftcms-ui/src/components/pane/pane.ts | 9 +- .../craftcms-ui/src/mixins/Paddable.test.ts | 20 +- packages/craftcms-ui/src/mixins/Paddable.ts | 39 +- 10 files changed, 466 insertions(+), 248 deletions(-) create mode 100644 packages/craftcms-ui/src/components/callout/Callout.mdx diff --git a/packages/craftcms-ui/src/components/callout/Callout.mdx b/packages/craftcms-ui/src/components/callout/Callout.mdx new file mode 100644 index 00000000000..4aa31a7378d --- /dev/null +++ b/packages/craftcms-ui/src/components/callout/Callout.mdx @@ -0,0 +1,126 @@ +import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs/blocks'; +import * as CalloutStories from './callout.stories'; + + + +# Callout + +`` is a boxed message that explains the state of the page, or the consequences of an action, in place. +It holds an optional icon, an optional title, body content, and an optional trailing action. + + + +Use a callout for something the person needs to know while they are looking at the surrounding content. +For a message about an action that has already completed, and that does not need to stay on screen, use a flash message instead. + +## Variant and Appearance + +`variant` carries the meaning, and `appearance` controls how loudly that meaning is stated. +The two are independent, and they behave the same way here as on every other component that accepts them. +See [Variants & Appearances](?path=/docs/tokens-variants-appearances--docs) for the underlying token mapping. + +Every variant except `neutral` also supplies a default icon, so a variant on its own is usually all a callout needs: + + + + + +Reach for `solid` when the callout must be seen before anything else on the page, and `plain` when it is a footnote to the content it sits beside. + +## Content + +A callout has four regions: the icon, the title, the body, and the action. +Each one collapses when it has nothing in it, so a callout with only body content is a plain box. + +The title has two forms. +The `title` attribute is the shorthand: + + + +Slot `title` instead when the title needs markup of its own. +The slot takes precedence over the attribute. + +The `action` slot holds a trailing button or link. +Give it `inherit` so it takes the callout's colour rather than the neutral palette: + + + +## The Icon + +The icon comes from the variant unless you say otherwise, and there are three ways to say otherwise. + +Set `icon` to a name to replace the variant's default: + + + +Slot `icon` to supply your own artwork. +Slotted content is honored even when no icon name resolves, so this works on a `neutral` callout that has no default of its own: + + + +Set `hide-icon` to suppress the region entirely, including a variant's default and anything slotted: + + + +## Layout + +`rounded` selects which corners are rounded. +Use `start` or `end` when the callout is flush against the top or bottom of another surface, and `none` when it spans a container edge to edge: + + + +`inline` renders the callout as a pill that flows with surrounding text rather than as a block-level box: + + + +`size` steps the type down to `--c-text-sm` and tightens the gap between the icon and the text. +The box padding is rem-based, so it does not scale with the type — pair `size` with `padding` when a small callout also wants a tighter box: + + + +## Padding + +Leaving `padding` off keeps the callout's own asymmetric pair: `--c-spacing-sm` on the block axis and `--c-spacing-md` on the inline one. +A value that is given applies to both axes, the way a one-value CSS `padding` shorthand does. + +It accepts `sm`, `md`, `lg`, and `xl` (mapped to `--c-spacing-*`), or `0`/`none`: + + + +The attribute is closed to that scale, so the design system stays the vocabulary for spacing. +A value off it is ignored, and the callout's own default stands. + +For a value off the scale, or an asymmetric pair of your own, set the custom properties instead: + + + +```css +craft-callout { + --c-callout-padding-block: var(--c-spacing-lg); + --c-callout-padding-inline: var(--c-spacing-sm); +} +``` + +An `inline` callout also carries a little padding on the host itself. +That is part of the pill treatment rather than of the box, and `padding` does not govern it. + +## Everything at Once + + + +## Accessibility + +A callout is a container, not a live region. +It has no implicit role, and adding one to the page does not announce it. +When a callout appears in response to something the person did — a failed save, a validation summary — the announcement is the responsibility of the code that inserts it. + +The icon is decorative. +It repeats what the variant and the text already say, and carries no accessible name of its own, so nothing is lost when it is not announced. +If you slot artwork that conveys something the text does not, give it its own label. + +Colour is not the only signal a callout carries, but it is the fastest one. +Keep the text meaningful on its own, so a `danger` callout still reads as a problem when the colour is not perceived. + +## Properties + + diff --git a/packages/craftcms-ui/src/components/callout/callout.stories.ts b/packages/craftcms-ui/src/components/callout/callout.stories.ts index 0a1eb0364b0..a05cd4b0409 100644 --- a/packages/craftcms-ui/src/components/callout/callout.stories.ts +++ b/packages/craftcms-ui/src/components/callout/callout.stories.ts @@ -1,164 +1,172 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; import {html, nothing} from 'lit'; - -import './callout.js'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; import {appearances} from '@src/constants/appearances.js'; import {variants} from '@src/constants/variants.js'; +import {SPACING_STEPS} from '@src/mixins/Paddable.js'; + +import './callout.js'; +import '../button/button.js'; +import '../icon/icon.js'; +import type CraftCallout from './callout.js'; + +/** + * `args` and `argTypes` are derived from the custom elements manifest, so the + * controls and the API tables follow the component's JSDoc. Adding a property + * to `callout.ts` surfaces it here without touching this file. + */ +const {args, argTypes, template} = + getStorybookHelpers('craft-callout'); + +/** + * `padding` is supplied by the `Paddable` mixin, so the analyzer records no + * resolvable type for it and the helpers cannot derive a control. Build one + * from the same constant the mixin resolves against, rather than restating + * the values by hand. + */ +const paddingArgType = { + control: {type: 'select'}, + options: [...SPACING_STEPS, 'none', '0'], +} as const; + +const MESSAGE = 'Entries in this section are disabled for the current site.'; + +const ACTION_BUTTON = `Action`; -// More on how to set up stories at: https://storybook.js.org/docs/writing-stories const meta = { title: 'Components/Callout', component: 'craft-callout', - args: { - appearance: 'outline-fill', - variant: 'default', - flash: false, - message: 'This is a callout message', - }, - argTypes: { - appearance: { - control: {type: 'select'}, - options: appearances, - }, - variant: { - control: {type: 'select'}, - options: appearances, - }, - message: { - control: {type: 'text'}, - }, - flash: { - control: {type: 'boolean'}, - }, - }, - render: ({appearance, variant, message}) => { - return html` - ${message} - `; - }, + args: {...args, 'default-slot': MESSAGE}, + argTypes: {...argTypes, padding: paddingArgType}, + // Render from args alone so every control — attributes and slots — drives + // the story. Stories below vary the args, not the template. + render: (args) => template(args), } satisfies Meta; export default meta; type Story = StoryObj; -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -export const Default: Story = { - args: {}, +/** + * A `neutral` callout has no default icon, so the icon region collapses and + * the box is body content alone. + */ +export const Default: Story = {}; + +/** + * Every variant except `neutral` supplies its own icon, so a variant alone is + * usually all a callout needs. + */ +export const Variants: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +
+ ${variants.map( + (variant) => + html`${variant}` + )} +
+ `, +}; + +/** + * `appearance` sets how loudly the variant is stated, from `solid` down to + * `plain`. See [Variants & Appearances](?path=/docs/tokens-variants-appearances--docs) + * for the underlying token mapping. + */ +export const Appearances: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +
+ ${appearances.map( + (appearance) => + html`${appearance}` + )} +
+ `, +}; + +/** The `title` attribute is the shorthand for the `title` slot. */ +export const WithTitle: Story = { + args: {variant: 'info', title: 'Site-specific content'}, }; +/** Setting `icon` replaces whatever icon the variant would have supplied. */ export const WithIcon: Story = { - args: { - variant: 'info', - }, - render: ({appearance, variant, message}) => { - return html` - - ${message} - - `; - }, + args: {variant: 'info', icon: 'circle-info'}, }; -export const WithCustomIcon: Story = { +/** + * Slotting `icon` replaces the icon region with your own artwork. Slotted + * content is honored even when no icon name resolves. + */ +export const CustomIcon: Story = { args: { variant: 'info', - }, - render: ({appearance, variant, message}) => { - return html` - - - - - - - - ${message} - - `; + 'icon-slot': ` + +`, }, }; -export const WithTitle: Story = { - args: { - variant: 'info', - }, - render: ({appearance, variant, message}) => { - return html` - - - This is a title - ${message} - - `; - }, +/** `hide-icon` suppresses the icon region, including a variant's default. */ +export const HideIcon: Story = { + args: {variant: 'warning', 'hide-icon': true}, }; -export const KitchenSink: Story = { - args: { - variant: 'danger', - }, - render: ({appearance, variant, message}) => { - return html` - - - Unable to save entry. - -

Please correct the errors and try again.

- - - Action -
- `; - }, +/** The `action` slot holds a trailing button or link. */ +export const WithAction: Story = { + args: {variant: 'warning', 'action-slot': ACTION_BUTTON}, }; -export const Variants: Story = { - args: { - appearance: 'outline-fill', - }, - render({appearance}) { - return html` -
- ${variants.map((variant) => { - return html` - ${variant} - `; - })} -
- `; - }, +/** + * `rounded` selects which corners are rounded, for callouts that sit flush + * against another surface. + */ +export const Rounded: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +
+ ${(['all', 'start', 'end', 'none'] as const).map( + (rounded) => + html`rounded="${rounded}"` + )} +
+ `, +}; + +/** `inline` renders the callout as a pill that flows with surrounding text. */ +export const Inline: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +

+ This entry has unsaved changes. + Draft + Publish it to make the changes live. +

+ `, +}; + +/** + * `size` steps the type down. The box padding is rem-based, so it does not + * scale with the size — pair `size` with `padding` for a tighter box. + */ +export const Sizes: Story = { + parameters: {controls: {disable: true}}, + render: () => html` +
+ ${(['auto', 'small'] as const).map( + (size) => + html`size="${size}" — ${MESSAGE}` + )} +
+ `, }; /** @@ -167,47 +175,52 @@ export const Variants: Story = { * applies to both axes, the way a one-value CSS `padding` shorthand does. */ export const Padding: Story = { - args: { - variant: 'info', - }, - argTypes: { - padding: { - control: {type: 'text'}, - }, - }, - render: ({appearance, variant, message}) => { - return html` -
- ${[undefined, 'none', 'sm', 'md', 'lg', 'xl', '24', '2rem'].map( - (padding) => html` - - ${padding ? `padding="${padding}"` : 'no padding attribute'} — - ${message} - - ` - )} -
- `; - }, + parameters: {controls: {disable: true}}, + render: () => html` +
+ ${[undefined, 'none', 'sm', 'md', 'lg', 'xl'].map( + (padding) => html` + + ${padding ? `padding="${padding}"` : 'no padding attribute'} — + ${MESSAGE} + + ` + )} +
+ `, }; -export const Appearances: Story = { - args: {}, - render: (args) => { - return html` -
- ${appearances.map((appearance) => { - return html` - ${appearance} - `; - })} -
- `; +/** + * The `padding` attribute is closed to the spacing scale. For a value off it, + * or an asymmetric pair of your own, set the custom properties instead. + */ +export const CustomPadding: Story = { + parameters: {controls: {disable: true}}, + render: () => html` + + ${MESSAGE} + + `, +}; + +/** + * Every region at once: a slotted title, a slotted icon, rich body content, + * and a trailing action. + */ +export const KitchenSink: Story = { + args: { + variant: 'danger', + 'title-slot': 'Unable to save entry.', + 'icon-slot': '', + 'action-slot': ACTION_BUTTON, + 'default-slot': `

Please correct the errors and try again.

+`, }, }; diff --git a/packages/craftcms-ui/src/components/callout/callout.styles.ts b/packages/craftcms-ui/src/components/callout/callout.styles.ts index 6789b5961e7..a7ebe2e18e1 100644 --- a/packages/craftcms-ui/src/components/callout/callout.styles.ts +++ b/packages/craftcms-ui/src/components/callout/callout.styles.ts @@ -19,19 +19,22 @@ export default css` ); --_callout-padding-inline: var( --c-callout-padding-inline, - var(--c-spacing-md) + var(--c-spacing-sm) ); display: grid; - grid-template-areas: 'icon title action' 'icon description action'; + grid-template-areas: 'icon description action'; grid-template-columns: auto 1fr minmax(0, max-content); - gap: 0 var(--c-spacing-sm); align-items: start; padding: var(--_callout-padding-block) var(--_callout-padding-inline); border: 1px solid transparent; } + .callout--title { + grid-template-areas: 'icon title action' 'icon description action'; + } + .callout--hide-icon { - grid-template-areas: 'title action' 'description action'; + grid-template-areas: 'description action'; grid-template-columns: 1fr minmax(0, max-content); .callout__icon { @@ -39,10 +42,19 @@ export default css` } } + .callout--hide-icon.callout--title { + grid-template-areas: 'title action' 'description action'; + } + .callout--small { font-size: var(--c-text-sm); gap: 0 var(--c-spacing-xs); } + + .callout__title, + .callout__description { + padding-inline: var(--c-spacing-sm); + } .callout__title { display: flex; @@ -51,12 +63,9 @@ export default css` } .callout__description { - grid-area: title; - align-self: center; - } - - .callout__title + .callout__description { grid-area: description; + align-self: center; + padding-inline: var(--c-spacing-sm); } .callout__action { @@ -72,6 +81,7 @@ export default css` justify-content: center; align-items: center; grid-area: icon; + padding-inline: var(--c-spacing-sm); } ::slotted(code) { diff --git a/packages/craftcms-ui/src/components/callout/callout.test.ts b/packages/craftcms-ui/src/components/callout/callout.test.ts index 1eb3047aa92..d7b18dab029 100644 --- a/packages/craftcms-ui/src/components/callout/callout.test.ts +++ b/packages/craftcms-ui/src/components/callout/callout.test.ts @@ -119,17 +119,27 @@ describe('craft-callout padding', () => { expect(spacing(element, 'inline')).toBe('0'); }); - it.each([ - ['0', '0'], - ['24', 'calc(24rem / 16)'], - ['2rem', '2rem'], - ['var(--my-spacing)', 'var(--my-spacing)'], - ])('resolves %s to %s', async (padding, expected) => { - const element = await createCallout({padding}); - - expect(spacing(element, 'block')).toBe(expected); + it('resolves 0 to zero', async () => { + const element = await createCallout({padding: '0'}); + + expect(spacing(element, 'block')).toBe('0'); }); + /** + * The attribute is closed to the spacing scale. An off-scale value writes + * nothing, so the callout's asymmetric default still applies — the custom + * properties are the escape hatch for arbitrary spacing. + */ + it.each(['24', '2rem', 'var(--my-spacing)'])( + 'ignores %s, which is off the spacing scale', + async (padding) => { + const element = await createCallout({padding}); + + expect(spacing(element, 'block')).toBe(''); + expect(spacing(element, 'inline')).toBe(''); + } + ); + it('re-renders when the padding property changes', async () => { const element = await createCallout(); diff --git a/packages/craftcms-ui/src/components/callout/callout.ts b/packages/craftcms-ui/src/components/callout/callout.ts index 3312734e943..d2a62353790 100644 --- a/packages/craftcms-ui/src/components/callout/callout.ts +++ b/packages/craftcms-ui/src/components/callout/callout.ts @@ -11,7 +11,21 @@ import {styleMap} from 'lit/directives/style-map.js'; /** * @summary A boxed message: an optional icon, an optional title, body content, - * and an optional trailing action. + * and an optional trailing action. Use a callout to explain the state of the + * page or the consequences of an action, in place rather than in a toast. + * + * `variant` carries the meaning and supplies a default icon. `appearance` + * controls how loudly that meaning is stated. Each region collapses when it + * has nothing in it, so a callout with only body content is a plain box. + * + * @slot - The callout's body content. + * @slot title - Title content, shown above the body. Takes precedence over the + * `title` attribute, which is the shorthand for the same region. + * @slot icon - Leading artwork, replacing the icon the variant would supply. + * Slotted content is honored even when no icon name resolves, and is + * suppressed entirely by `hide-icon`. + * @slot action - Trailing content, shown after the body. Typically a button or + * a link. * * @attr size - `small` steps the box down to `--c-text-sm` and tightens the gap * between the icon and the text. Defaults to `auto`, which leaves the callout @@ -19,17 +33,17 @@ import {styleMap} from 'lit/directives/style-map.js'; * `--c-spacing-*`, i.e. rem-based, so it does not scale with the size; set * `padding` alongside it if a small callout wants a tighter box too. * - * @attr padding - Spacing applied to the callout box. Accepts - * `sm`/`md`/`lg`/`xl` (mapped to `--c-spacing-*`), `0` or `none`, a unitless - * number (treated as pixels), or any CSS length. + * @attr {'sm'|'md'|'lg'|'xl'|'none'|'0'} padding - Spacing applied to the callout box. Accepts `sm`, `md`, + * `lg`, and `xl` (mapped to `--c-spacing-*`), or `0`/`none`. Values off that + * scale are ignored; set `--c-callout-padding-block` / + * `--c-callout-padding-inline` for anything else. * * The callout's own default is asymmetric — `--c-spacing-sm` on the block * axis, `--c-spacing-md` on the inline one — so leaving `padding` off keeps * exactly that pair. A value that *is* given applies to both axes, the way a * one-value CSS `padding` shorthand does; scaling the two axes apart from a * single value would make `padding="md"` mean something other than `md`. - * Reach for `--c-callout-padding-block` / `--c-callout-padding-inline` to - * keep an asymmetric pair of your own. + * Reach for the custom properties to keep an asymmetric pair of your own. * * Note that an `inline` callout also carries a little padding on the host * itself, which is part of that pill treatment rather than of the box, and @@ -47,30 +61,55 @@ export default class CraftCallout extends Paddable(LitElement, { }) { static override styles: CSSResultGroup = [variantsStyles, styles]; - /** Variant style of the callout */ + /** + * The semantic color group the callout draws its tokens from, and what the + * message means. Every variant except `neutral` also supplies a default + * icon. + */ @property({reflect: true}) variant: VariantValue = Variant.Neutral; /** - * Appearance style of the callout - * @TODO maybe drop "outline"? + * How prominently the variant color is applied. `solid` is the loudest and + * `plain` the quietest. */ + // @TODO maybe drop "outline"? @property({reflect: true}) appearance: AppearanceValue = Appearance.OutlineFill; - /** Title of the callout */ + /** + * Title text, shown above the body. A shorthand for the `title` slot; slot + * it instead when the title needs markup. + */ @property() override title: string = ''; - /** Icon to display in the callout */ + /** + * Name of the icon to show, replacing the one the variant would supply. + * Leave it unset to take the variant's default. + */ @property() icon: string | null = null; + /** + * Suppresses the icon region entirely, including a variant's default icon + * and anything slotted into `icon`. + */ @property({type: Boolean, attribute: 'hide-icon'}) hideIcon: boolean = false; + /** + * Which corners are rounded. Use `start` or `end` when the callout is + * flush against the top or bottom of another surface, and `none` when it + * spans a container edge to edge. + */ @property({reflect: true}) rounded: 'all' | 'start' | 'end' | 'none' = 'all'; + /** + * Renders the callout as an inline pill that flows with surrounding text, + * rather than as a block-level box. + */ @property({reflect: true, type: Boolean}) inline: boolean = false; + /** See the `size` attribute above. */ @property() size: 'small' | 'auto' = 'auto'; diff --git a/packages/craftcms-ui/src/components/pane/pane.stories.ts b/packages/craftcms-ui/src/components/pane/pane.stories.ts index 5e712c350c2..401dfadf03e 100644 --- a/packages/craftcms-ui/src/components/pane/pane.stories.ts +++ b/packages/craftcms-ui/src/components/pane/pane.stories.ts @@ -7,6 +7,18 @@ const {events, args, argTypes, template} = getStorybookHelpers('craft-pane'); import './pane.js'; import '../button/button.js'; import '../icon/icon.js'; +import {SPACING_STEPS} from '@src/mixins/Paddable.js'; + +/** + * `padding` is supplied by the `Paddable` mixin, so the analyzer records no + * resolvable type for it and the helpers cannot derive a control. Build one + * from the same constant the mixin resolves against, rather than restating + * the values by hand. + */ +const paddingArgType = { + control: {type: 'select'}, + options: [...SPACING_STEPS, 'none', '0'], +} as const; const bodyCopy = html` Panes are the primary CP content surface — a padded, rounded container with @@ -27,7 +39,7 @@ const meta: Meta = { ...args, label: '', }, - argTypes, + argTypes: {...argTypes, padding: paddingArgType}, render: (args) => template(args, bodyCopy), parameters: { actions: { @@ -140,8 +152,8 @@ export const ScrollableCode: Story = { }; /** - * `padding` takes the `sm`/`md`/`lg`/`xl` steps, `0`, a unitless number - * (pixels), or any CSS length. + * `padding` takes the `sm`/`md`/`lg`/`xl` steps, or `0`/`none`. For a value + * off that scale, set `--c-pane-padding` instead. */ export const Padding: Story = { render: () => html` @@ -150,7 +162,6 @@ export const Padding: Story = { padding="md" padding="lg" (default) padding="xl" - padding="24" (24px)
+ --c-pane-padding: 2.5rem — a value off the scale
`, }; diff --git a/packages/craftcms-ui/src/components/pane/pane.test.ts b/packages/craftcms-ui/src/components/pane/pane.test.ts index 074546051ad..6d8b4ebf756 100644 --- a/packages/craftcms-ui/src/components/pane/pane.test.ts +++ b/packages/craftcms-ui/src/components/pane/pane.test.ts @@ -241,17 +241,19 @@ describe('craft-pane padding', () => { expect(spacing(element)).toBe('0'); }); - it('treats numeric values as pixels', async () => { - const element = await createPane({padding: '24'}); - - expect(spacing(element)).toBe('calc(24rem / 16)'); - }); - - it('passes any other value through verbatim', async () => { - const element = await createPane({padding: 'var(--my-spacing)'}); + /** + * The attribute is closed to the spacing scale. An off-scale value writes + * nothing, so `--c-pane-padding` and the stylesheet default still apply — + * which is the escape hatch for arbitrary spacing. + */ + it.each(['24', '2rem', 'var(--my-spacing)'])( + 'ignores %s, which is off the spacing scale', + async (padding) => { + const element = await createPane({padding}); - expect(spacing(element)).toBe('var(--my-spacing)'); - }); + expect(spacing(element)).toBe(''); + } + ); it('re-renders when the padding property changes', async () => { const element = await createPane(); diff --git a/packages/craftcms-ui/src/components/pane/pane.ts b/packages/craftcms-ui/src/components/pane/pane.ts index 1b3e8bae6dc..2b038d99075 100644 --- a/packages/craftcms-ui/src/components/pane/pane.ts +++ b/packages/craftcms-ui/src/components/pane/pane.ts @@ -87,10 +87,11 @@ const OVERFLOW_TOLERANCE = 1; * @slot secondary-action - The footer's secondary action, e.g. a cancel button. * @slot primary-action - The footer's primary action, e.g. a submit button. * - * @attr padding - Spacing applied to the header, body, and footer regions. - * Accepts `sm`/`md`/`lg`/`xl` (mapped to `--c-spacing-*`), `0` or `none`, a - * unitless number (treated as pixels), or any CSS length. Defaults to `lg`. - * Supplied by the `Paddable` mixin, which writes it to `--_pane-spacing`. + * @attr {'sm'|'md'|'lg'|'xl'|'none'|'0'} padding - Spacing applied to the header, body, and footer regions. + * Accepts `sm`, `md`, `lg`, and `xl` (mapped to `--c-spacing-*`), or + * `0`/`none`. Defaults to `lg`. Values off that scale are ignored; set + * `--c-pane-padding` for anything else. Supplied by the `Paddable` mixin, + * which writes it to `--_pane-spacing`. * * @csspart base - The pane's outermost element, which carries the surface * treatment. Style it to override border/shadow/fill for a single pane. It's diff --git a/packages/craftcms-ui/src/mixins/Paddable.test.ts b/packages/craftcms-ui/src/mixins/Paddable.test.ts index 248fac7048b..9c10651806f 100644 --- a/packages/craftcms-ui/src/mixins/Paddable.test.ts +++ b/packages/craftcms-ui/src/mixins/Paddable.test.ts @@ -75,14 +75,16 @@ describe('resolvePadding', () => { expect(resolvePadding(value)).toBe('0'); }); - it.each([24, '24', '0.5'] as const)('reads %s as pixels', (value) => { - expect(resolvePadding(value)).toBe(`calc(${value}rem / 16)`); - }); - - it.each(['2rem', 'var(--my-spacing)', 'calc(1rem + 2px)'])( - 'passes %s through verbatim', + /** + * The attribute is closed to arbitrary lengths. Ignoring them leaves the + * stylesheet's own default in place, rather than writing a value the design + * system does not define — consumers who need one set the component's + * padding custom properties instead. + */ + it.each([24, '24', '0.5', '2rem', 'var(--my-spacing)', 'calc(1rem + 2px)'])( + 'ignores %s, which is off the spacing scale', (value) => { - expect(resolvePadding(value)).toBe(value); + expect(resolvePadding(value)).toBeUndefined(); } ); @@ -118,10 +120,10 @@ describe('Paddable with a default', () => { it('re-renders when the property changes', async () => { const element = await create('test-single-padding'); - element.padding = 24; + element.padding = 'xl'; await element.updateComplete; - expect(written(element, '--_test-spacing')).toBe('calc(24rem / 16)'); + expect(written(element, '--_test-spacing')).toBe('var(--c-spacing-xl)'); }); /** Removing the attribute has to clear the value, not strand the last one. */ diff --git a/packages/craftcms-ui/src/mixins/Paddable.ts b/packages/craftcms-ui/src/mixins/Paddable.ts index bf1f8a6e33b..025d4a59202 100644 --- a/packages/craftcms-ui/src/mixins/Paddable.ts +++ b/packages/craftcms-ui/src/mixins/Paddable.ts @@ -17,13 +17,12 @@ export const SPACING_STEPS = ['sm', 'md', 'lg', 'xl'] as const; export type SpacingStep = (typeof SPACING_STEPS)[number]; -/** Everything the `padding` property accepts. */ -export type PaddingValue = SpacingStep | 'none' | string | number; - -/** Whether a value is a plain number (or a numeric string like `"12"`). */ -function isNumeric(value: PaddingValue): boolean { - return !isNaN(parseFloat(String(value))) && isFinite(Number(value)); -} +/** + * Everything the `padding` property accepts: a step on the spacing scale, or + * zero. The attribute is deliberately closed to arbitrary lengths — reach for + * the component's own custom properties when you need a value off the scale. + */ +export type PaddingValue = SpacingStep | 'none' | '0' | 0; /** * Resolves a padding value to a CSS length. @@ -33,15 +32,20 @@ function isNumeric(value: PaddingValue): boolean { * bare `none` is not a valid length, so passing it through would drop the * declaration and silently leave the default padding in place. * - `sm`/`md`/`lg`/`xl` map onto `--c-spacing-*`. - * - A unitless number (or numeric string) is read as pixels. - * - Anything else is passed through verbatim, so a consumer can hand over a - * token, a `calc()`, or any other CSS length. * - Nothing at all (`undefined`, `null`, or an empty attribute) resolves to * `undefined`, which callers should treat as "write nothing and let the * stylesheet's own fallback stand". + * - Anything else resolves to `undefined` as well. An arbitrary length is not + * a spelling this attribute supports, and ignoring it leaves the + * stylesheet's default in place rather than writing a value the design + * system does not define. Set the component's padding custom properties + * instead. */ export function resolvePadding( - value: PaddingValue | null | undefined + // Wider than `PaddingValue` on purpose: this is the runtime boundary, and an + // attribute can carry any string at all. Rejecting what is off the scale is + // the function's job. + value: PaddingValue | string | number | null | undefined ): string | undefined { if (value === null || value === undefined || value === '') { return undefined; @@ -51,15 +55,11 @@ export function resolvePadding( return '0'; } - if (isNumeric(value)) { - return `calc(${value}rem / 16)`; - } - if (SPACING_STEPS.includes(value as SpacingStep)) { return `var(--c-spacing-${value})`; } - return String(value); + return undefined; } /** How a host wires the shared `padding` behavior onto its own CSS. */ @@ -102,9 +102,10 @@ export interface PaddableHost { /** * Adds a declarative `padding` attribute to any Lit element: the consumer - * writes `padding="md"` (or `0`, `none`, `24`, `2rem`, …) and the mixin - * resolves it to a CSS length and hands it back keyed by the custom - * properties the component nominated. + * writes `padding="md"` (or `0`/`none`) and the mixin resolves it to a CSS + * length and hands it back keyed by the custom properties the component + * nominated. Values off the spacing scale are ignored; a consumer who needs + * one sets the component's custom properties directly. * * The mixin deliberately carries only the behavior — the reactive property * and the value resolution. The custom property name, its fallback chain, and From 58758a9cfae2359bcea2c5f7da962c550c242faf Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 28 Aug 2026 10:18:19 -0500 Subject: [PATCH 05/90] Document craft-card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `thumbnail` slot was undocumented despite being rendered, and `thumb-alignment` had no description, so neither reached the manifest. Add both, and move the CP-specific note about spreading an element's card attributes out of the public summary into a comment beside the code. Convert the stories to derive their args and argTypes from the manifest, so the controls drive every story rather than only the ones with a matching custom render, and add a docs page. Two fixes found while writing it: the header action buttons set `appearance="plain"`, which craft-button has no such attribute for — the property is `variant`. And `craft-button`'s `accessible-name` reports the name it computed for its own error check rather than setting one, so an icon-only button still needs an `aria-label`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../craftcms-ui/src/components/card/Card.mdx | 100 ++++++++ .../src/components/card/card.stories.ts | 235 +++++++----------- .../craftcms-ui/src/components/card/card.ts | 19 +- 3 files changed, 208 insertions(+), 146 deletions(-) create mode 100644 packages/craftcms-ui/src/components/card/Card.mdx diff --git a/packages/craftcms-ui/src/components/card/Card.mdx b/packages/craftcms-ui/src/components/card/Card.mdx new file mode 100644 index 00000000000..23f5d8194db --- /dev/null +++ b/packages/craftcms-ui/src/components/card/Card.mdx @@ -0,0 +1,100 @@ +import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs/blocks'; +import * as CardStories from './card.stories'; + + + +# Card + +`` is a bordered, rounded surface that groups related content into a standalone block. +It holds an optional header, an optional thumbnail column, body content, and an optional footer. + + + +Use a card when the content is a block a person reads or acts on as a unit — an element in an index view, a settings group, a summary panel. +For a compact reference to a single entity in a list, use [Chip](?path=/docs/components-chip--docs) instead. + +## Regions + +A card has four regions, and each one is only rendered when there is something to put in it. +A card with body content alone is a plain bordered surface with no header or footer chrome. + +The header appears as soon as the `label` attribute is set, or the `header`, `label`, or `actions` slot is filled: + + + +The `actions` slot sits at the end of the header: + + + +The footer appears only when the `footer` slot is filled: + + + +## Replacing the Header + +There are two levels of override, depending on how much of the default layout you want to keep. + +Fill the `label` slot to replace the label text while keeping the header's layout, so slotted actions still sit at the end: + + + +Fill the `header` slot to replace the region outright. +The default `label`/`actions` layout is not rendered at all, so anything you slot is responsible for its own arrangement: + + + +## The Thumbnail + +Content in the `thumbnail` slot renders in a fixed column beside the body: + + + +Two attributes govern it. +`show-thumb` is on by default and gates the column entirely — set it to `false` to drop the thumbnail without removing the slotted content. +`thumb-alignment` puts the column at the `start` or the `end` of the body: + + + +## Active State + +`active` marks a card as selected, and is reflected to the host so it can be styled from outside the component. +Element index views bind it to row selection: + + + +`active` is presentational. +It does not make the card focusable or announce a selection, so a selectable list is responsible for its own control and its own semantics. + +## Styling + +The card exposes one CSS part, `label`, and four `--c-card-*` custom properties for radius, shadow, and the two padding axes. +Both are listed in full under [Properties](#properties). + + + +```css +craft-card { + --c-card-radius: 0; + --c-card-shadow: none; + --c-card-padding-inline: 2rem; +} +``` + +The card draws its own chrome but leaves attributes on the host alone, so an `id`, `style` custom properties, and `data-*` metadata can all be set on it directly. + +## Everything at Once + + + +## Accessibility + +A card is a container, not a control. +It has no implicit role and does not receive focus, so the content decides the semantics. + +- **Give the card a heading if it is one.** The `label` renders as plain text, not a heading element. When a card is a section of a page, slot a real heading into `label` so it lands in the document outline. +- **Name the header actions.** An icon-only button in the `actions` slot needs an `aria-label`; the card's label does not name it. +- **Keep a decorative thumbnail decorative.** Artwork that only repeats the label takes `alt=""` or `aria-hidden`. If it carries meaning the text does not, give it a name of its own. + +## Properties + + diff --git a/packages/craftcms-ui/src/components/card/card.stories.ts b/packages/craftcms-ui/src/components/card/card.stories.ts index 181ace4c982..153eba9b3a4 100644 --- a/packages/craftcms-ui/src/components/card/card.stories.ts +++ b/packages/craftcms-ui/src/components/card/card.stories.ts @@ -2,11 +2,31 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; import {html} from 'lit'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; + import type CraftCard from './card.js'; import './card.js'; import '../button/button.js'; import '../icon/icon.js'; +/** + * `args` and `argTypes` are derived from the custom elements manifest, so the + * controls and the API tables follow the component's JSDoc. Adding a property + * to `card.ts` surfaces it here without touching this file. + */ +const {args, argTypes, template} = getStorybookHelpers('craft-card'); + +const BODY = + 'Cards group related content into a bordered surface, with optional header, footer, and thumbnail regions.'; + +const ACTION_BUTTON = ``; + +const THUMB = ` + + + +`; + const placeholderThumb = html` html` - - Cards group related content into a bordered surface, with optional header, - footer, and thumbnail regions. - - `, -} satisfies Meta; + args: {...args, label: 'Card label', 'default-slot': BODY}, + argTypes, + // Render from args alone so every control — attributes and slots — drives + // the story. Stories below vary the args, not the template. + render: (args) => template(args), +} satisfies Meta; export default meta; -type Story = StoryObj; - -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +type Story = StoryObj; /** Body only — with no label or header/label/actions slot, no header renders. */ export const Default: Story = { - render: () => html` - - A bare card is just the bordered body surface — no header or footer chrome - until content asks for it. - - `, + args: { + label: '', + 'default-slot': + 'A bare card is just the bordered body surface — no header or footer chrome until content asks for it.', + }, }; /** The `label` attribute alone brings in the header region. */ @@ -73,94 +76,62 @@ export const WithLabel: Story = {}; /** Buttons in the `actions` slot sit at the end of the header. */ export const WithActions: Story = { - render: ({label}) => html` - - - - - Header actions are slotted content, so anything works — icon buttons, an - action menu, a switch. - - `, + args: { + 'actions-slot': ` +${ACTION_BUTTON}`, + 'default-slot': + 'Header actions are slotted content, so anything works — icon buttons, an action menu, a switch.', + }, }; /** The `label` slot replaces the label text while keeping the header layout. */ export const CustomLabel: Story = { - render: () => html` - - - - Article - article - - - A slotted label can carry richer content than plain text — icons, handles, - badges. - - `, + args: { + 'label-slot': ` + + Article + article +`, + 'default-slot': + 'A slotted label can carry richer content than plain text — icons, handles, badges.', + }, }; /** The `header` slot replaces the whole default label/actions header. */ export const CustomHeader: Story = { - render: () => html` - -
- Custom header - anything goes here -
- - When the header slot is filled, the default label/actions layout is - replaced entirely. -
- `, + args: { + 'header-slot': `
+ Custom header + anything goes here +
`, + 'default-slot': + 'When the header slot is filled, the default label/actions layout is replaced entirely.', + }, }; /** The footer region only renders when the `footer` slot is filled. */ export const WithFooter: Story = { - render: ({label}) => html` - - Body content. - -
- Updated 2 hours ago - Draft -
-
- `, + args: { + 'default-slot': 'Body content.', + 'footer-slot': `
+ Updated 2 hours ago + Draft +
`, + }, }; /** Thumbnail content renders in a fixed-width column beside the body. */ export const WithThumbnail: Story = { - render: ({label, thumbAlignment}) => html` - - ${placeholderThumb} The thumbnail slot gets a 120px column; - thumb-alignment puts it at the start or end of the body. - - `, + args: { + 'thumbnail-slot': THUMB, + 'default-slot': + 'The thumbnail slot gets a 120px column; thumb-alignment puts it at the start or end of the body.', + }, }; +/** `thumb-alignment` puts the thumbnail column before or after the body. */ export const ThumbnailAlignment: Story = { + parameters: {controls: {disable: true}}, render: () => html`
@@ -175,10 +146,13 @@ export const ThumbnailAlignment: Story = { /** The reflected `active` attribute marks selection (e.g. element index rows). */ export const Active: Story = { - render: ({label}) => html` + parameters: {controls: {disable: true}}, + render: () => html`
- An inactive card for comparison. - + An inactive card for comparison. + An active card — loud header/footer fill and border.
@@ -187,9 +161,10 @@ export const Active: Story = { /** Radius, shadow, and padding are themeable via custom properties. */ export const CustomProperties: Story = { - render: ({label}) => html` + parameters: {controls: {disable: true}}, + render: () => html` Square corners, no shadow, and roomier padding via @@ -198,36 +173,16 @@ export const CustomProperties: Story = { `, }; +/** Every region at once: header label and actions, thumbnail, body, footer. */ export const KitchenSink: Story = { - render: ({label, active, thumbAlignment}) => html` - - - - ${placeholderThumb} - - Autumn on the Coast -

- Header label and actions, a leading thumbnail, body content, and a - metadata footer — all regions at once. -

- -
- Posted Oct 14 - Live -
-
- `, + args: { + label: 'Autumn on the Coast', + 'actions-slot': ACTION_BUTTON, + 'thumbnail-slot': THUMB, + 'default-slot': `

Header label and actions, a leading thumbnail, body content, and a metadata footer — all regions at once.

`, + 'footer-slot': `
+ Posted Oct 14 + Live +
`, + }, }; diff --git a/packages/craftcms-ui/src/components/card/card.ts b/packages/craftcms-ui/src/components/card/card.ts index 06587130569..bd7b4ce77e9 100644 --- a/packages/craftcms-ui/src/components/card/card.ts +++ b/packages/craftcms-ui/src/components/card/card.ts @@ -15,12 +15,9 @@ import {classMap} from 'lit/directives/class-map.js'; * (with the `label` attribute as the default label content). The footer region * is only rendered when the `footer` slot is filled. * - * Host attributes are applied directly to the element, so the server-rendered - * wrapper attributes that accompany an element's card HTML (its `id`, `style` - * custom properties, and `data-*` metadata) can be spread onto it with the - * `attrs()` utility — e.g. `v-bind="attrs(element.cardAttributes)"`. `class` is - * usually excluded from that bind (`{exclude: ['class']}`), since the component - * renders its own card chrome rather than the server's `.card` classes. + * The card renders its own chrome, so attributes set on the host are left + * alone — an `id`, `style` custom properties, and `data-*` metadata can all be + * spread onto it without the component interfering. * * @slot - The card's body content. * @slot header - The full header region. Replaces the default @@ -30,6 +27,9 @@ import {classMap} from 'lit/directives/class-map.js'; * @slot actions - Action content shown at the end of the header, e.g. buttons. * @slot footer - Footer content. The footer is only rendered when this slot is * filled. + * @slot thumbnail - Artwork shown in a fixed column beside the body. Rendered + * only while `show-thumb` is set; `thumb-alignment` puts the column at the + * start or the end of the body. * * @csspart label - The label slot within the header. * @@ -42,6 +42,9 @@ import {classMap} from 'lit/directives/class-map.js'; * `--c-spacing-md` for the body. */ export default class CraftCard extends LitElement { + // In the CP, an element's server-rendered card attributes are spread onto + // the host with `attrs(element.cardAttributes)`, excluding `class` — the + // component draws its own chrome rather than the server's `.card` classes. static override styles: CSSResultGroup = [styles]; /** Label shown in the header when the `label` slot is not filled. */ @@ -57,6 +60,10 @@ export default class CraftCard extends LitElement { /** Whether the thumbnail region renders at all, even with slotted content. */ @property({attribute: 'show-thumb', type: Boolean}) showThumb: boolean = true; + /** + * Which side of the body the thumbnail column sits on. Has no effect unless + * the `thumbnail` slot is filled and `show-thumb` is set. + */ @property({attribute: 'thumb-alignment'}) thumbAlignment: 'start' | 'end' = 'start'; From b043d34b9014c2a7dcd9faa04041be30d809f73c Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 28 Aug 2026 10:36:53 -0500 Subject: [PATCH 06/90] Make craft-button's accessible name internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `accessible-name` looked like a way to name an icon-only button, but it only recorded the name the component computed for its own nameless-button warning. Setting it never put anything in the DOM — it just silenced the warning, so the Matrix "Remove" buttons that used it were nameless and unflagged. Make it `_accessibleName` state, and point the callers that were naming buttons with it at `aria-label`: the PHP Button builder (so the existing `accessibleName()` API keeps working and now emits a real name), the Matrix Vue control, and the matrix-input custom element. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../src/components/button/button.test.ts | 15 +++++++- .../src/components/button/button.ts | 37 +++++++++++-------- .../craftcms-ui/src/components/tabs/tabs.ts | 8 ++-- resources/js/modules/forms/MatrixControl.vue | 2 +- .../js/modules/matrix/matrix-input.ce.ts | 2 +- src/Cp/Components/Button.php | 4 +- tests/Unit/Cp/Components/ButtonTest.php | 2 +- .../Components/ComponentManifestDriftTest.php | 6 ++- 8 files changed, 49 insertions(+), 27 deletions(-) diff --git a/packages/craftcms-ui/src/components/button/button.test.ts b/packages/craftcms-ui/src/components/button/button.test.ts index 4b1fc925ce4..4b713d5cab0 100644 --- a/packages/craftcms-ui/src/components/button/button.test.ts +++ b/packages/craftcms-ui/src/components/button/button.test.ts @@ -20,6 +20,11 @@ function anchor(element: CraftButton): HTMLAnchorElement | null { return element.shadowRoot?.querySelector('a.link') ?? null; } +/** The inner content wrapper, which carries the nameless-button warning class. */ +function content(element: CraftButton): HTMLElement | null { + return element.shadowRoot?.querySelector('.button-content') ?? null; +} + beforeEach(() => { document.body.innerHTML = ''; }); @@ -75,7 +80,15 @@ describe('craft-button link mode', () => { const element = await createButton({href: '/x'}, 'Settings'); // Wait for firstUpdated's async accessible-name computation. await element.updateComplete; - expect(element.accessibleName).toBe('Settings'); + expect(content(element)!.classList.contains('a11y-error')).toBe(false); + }); + + /** An icon-only button with nothing to read from is the case worth catching. */ + it('flags an accessible-name error for a nameless button', async () => { + const element = await createButton({}, ''); + await element.updateComplete; + await new Promise((resolve) => setTimeout(resolve)); + expect(content(element)!.classList.contains('a11y-error')).toBe(true); }); }); diff --git a/packages/craftcms-ui/src/components/button/button.ts b/packages/craftcms-ui/src/components/button/button.ts index bb06657c71d..219f5a6eff1 100644 --- a/packages/craftcms-ui/src/components/button/button.ts +++ b/packages/craftcms-ui/src/components/button/button.ts @@ -159,23 +159,17 @@ export default class CraftButton extends LionButtonSubmit { Array.from(childComponents).map((child: any) => child.updateComplete) ); - if (!this.accessibleName) { - // In link mode the host is role="presentation" (name not computable on - // it); the real accessible element is the inner anchor. - const nameTarget = this.isLink - ? ((this.shadowRoot?.querySelector('a.link') as HTMLElement | null) ?? - this) - : this; - this.accessibleName = computeAccessibleName(nameTarget); - } - - this._hasAccessibilityError = - !this.accessibleName || this.accessibleName.trim() === ''; + // In link mode the host is role="presentation" (name not computable on + // it); the real accessible element is the inner anchor. + const nameTarget = this.isLink + ? ((this.shadowRoot?.querySelector('a.link') as HTMLElement | null) ?? + this) + : this; + this._accessibleName = computeAccessibleName(nameTarget); + + this._hasAccessibilityError = this._accessibleName.trim() === ''; } - /** The computed accessible name */ - @property({attribute: 'accessible-name'}) accessibleName: string; - /** * The button's visual style. Defaults to "fill" (neutral fill). */ @@ -234,6 +228,19 @@ export default class CraftButton extends LionButtonSubmit { @query('[data-live-region]') liveRegion: HTMLElement; + /** + * The name the button actually computes to, kept only so a button that ends + * up nameless can be flagged. + * + * Deliberately internal: it records a name, it does not apply one. Exposing + * it as an attribute invited consumers to "name" a button by setting it, + * which silenced the warning below while leaving nothing in the DOM for a + * screen reader. Name an icon-only button with `aria-label` on the host, or + * with a `label` on the icon it contains. + */ + @state() + private _accessibleName: string = ''; + @state() private _hasAccessibilityError: boolean = false; diff --git a/packages/craftcms-ui/src/components/tabs/tabs.ts b/packages/craftcms-ui/src/components/tabs/tabs.ts index 5d03f15670f..809d104f112 100644 --- a/packages/craftcms-ui/src/components/tabs/tabs.ts +++ b/packages/craftcms-ui/src/components/tabs/tabs.ts @@ -891,10 +891,10 @@ export default class CraftTabs extends LionTabs { size="small" > diff --git a/resources/js/modules/matrix/matrix-input.ce.ts b/resources/js/modules/matrix/matrix-input.ce.ts index ef1950961e1..bf38871ab41 100644 --- a/resources/js/modules/matrix/matrix-input.ce.ts +++ b/resources/js/modules/matrix/matrix-input.ce.ts @@ -165,7 +165,7 @@ export default class CraftMatrixInput extends ControllerElement { const remove = document.createElement('craft-button'); remove.dataset.formMatrixRemove = ''; remove.setAttribute('icon', 'trash'); - remove.setAttribute('accessible-name', t('Remove {type}', {type: label})); + remove.setAttribute('aria-label', t('Remove {type}', {type: label})); actions.append(reorder, remove); return actions; diff --git a/src/Cp/Components/Button.php b/src/Cp/Components/Button.php index 00281fc874f..00be6920969 100644 --- a/src/Cp/Components/Button.php +++ b/src/Cp/Components/Button.php @@ -153,7 +153,7 @@ public function active(bool $active = true): static return $this; } - /** Accessible name override, for icon-only buttons. */ + /** Accessible name for an icon-only button. Rendered as `aria-label`. */ public function accessibleName(?string $accessibleName): static { $this->accessibleName = $accessibleName; @@ -245,7 +245,7 @@ protected function hostAttributes(): array 'active' => $this->active ? 'true' : null, 'value' => $this->value, 'disabled' => $this->isDisabled(), - 'accessible-name' => $this->accessibleName, + 'aria-label' => $this->accessibleName, 'align' => $this->align, 'href' => $this->href, 'target' => $this->target, diff --git a/tests/Unit/Cp/Components/ButtonTest.php b/tests/Unit/Cp/Components/ButtonTest.php index 6d3068f43c6..822a0dbd28e 100644 --- a/tests/Unit/Cp/Components/ButtonTest.php +++ b/tests/Unit/Cp/Components/ButtonTest.php @@ -54,7 +54,7 @@ it('renders the accessible name for icon-only buttons', function () { expect(Button::make()->icon('plus')->accessibleName('Add row')->toHtml()) - ->toContain('accessible-name="Add row"'); + ->toContain('aria-label="Add row"'); }); }); diff --git a/tests/Unit/Cp/Components/ComponentManifestDriftTest.php b/tests/Unit/Cp/Components/ComponentManifestDriftTest.php index 45be2235580..359028a9df5 100644 --- a/tests/Unit/Cp/Components/ComponentManifestDriftTest.php +++ b/tests/Unit/Cp/Components/ComponentManifestDriftTest.php @@ -202,8 +202,10 @@ function cpDriftWcOnlyAllowlist(): array function cpDriftExpectedPhpOnly(): array { return [ - // Global / structural HTML attributes the WC does not declare in the manifest. - '*' => ['id', 'class', 'style', 'role', 'aria', 'data', 'slot'], + // Global / structural HTML attributes the WC does not declare in the + // manifest. `aria-label` is here because naming an element is the + // consumer's job — no component declares an attribute for it. + '*' => ['id', 'class', 'style', 'role', 'aria', 'aria-label', 'data', 'slot'], // craft-button: `type`/`disabled` are native/global; `active` and `command` // are PHP conveniences (pressed state, Invoker Commands API). From 55310991860413a0dfccca8b034b590281fa4afb Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 28 Aug 2026 10:42:29 -0500 Subject: [PATCH 07/90] Document craft-action-item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve of its thirteen attributes had no description, and none of its four slots or its `action:change-state` event were documented. Annotate the whole surface, convert the stories to derive their args and argTypes from the manifest, and add a docs page. Three fixes along the way. The `confirm` attribute was never read — the CP already passes confirmation inside the action descriptor, which `runAction` handles — so it is gone. `active` styled itself from `:host([active])` but did not reflect, so setting the property did nothing; it reflects now. And the component rendered `craft-icon` and `craft-spinner` without importing either, leaving them unupgraded anywhere the stories did not happen to pull them in. The stories had disabled the a11y gate wholesale (`a11y: {test: 'todo'}` on the meta). It is on now, scoped off for the two stories whose violations are inherent: an interactive `suffix` nests a control inside the item's button, and the full-palette grid necessarily includes low-contrast colors. The nested-interactive finding is a real structural issue, called out in the docs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../src/components/action-item/ActionItem.mdx | 109 +++++++++ .../action-item/action-item.stories.ts | 217 +++++++++--------- .../src/components/action-item/action-item.ts | 62 ++++- 3 files changed, 278 insertions(+), 110 deletions(-) create mode 100644 packages/craftcms-ui/src/components/action-item/ActionItem.mdx diff --git a/packages/craftcms-ui/src/components/action-item/ActionItem.mdx b/packages/craftcms-ui/src/components/action-item/ActionItem.mdx new file mode 100644 index 00000000000..3bbd872efdc --- /dev/null +++ b/packages/craftcms-ui/src/components/action-item/ActionItem.mdx @@ -0,0 +1,109 @@ +import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs/blocks'; +import * as ActionItemStories from './action-item.stories'; + + + +# Action Item + +`` is a single entry in a menu. +It renders as a button, or as a link when `href` is set, and carries an optional leading icon, a label, and an optional trailing shortcut or suffix. + + + +Action items are usually supplied to [Action Menu](?path=/docs/components-action-menu--docs) rather than placed by hand — in data-driven mode it builds them from an `actions` array. +Reach for the element directly when you are slotting your own menu content. + +An item has no width of its own; it fills whatever menu contains it. +The examples below sit in a menu-sized container so the layout reads correctly. + +## Content + +`icon` renders artwork before the label, and the `icon` slot replaces it when you need something the icon set does not cover: + + + +The `suffix` slot holds trailing content, before any shortcut: + + + +A `shortcut` is shown at the end of the item. +It is display only — the item renders the keys but does not bind them, so the shortcut has to be wired up separately: + + + +Pass an object to name the modifiers: + + + +## States + +`active` marks the entry a menu opens onto, or the option already in effect. +It is reflected, so it can be styled from outside the component: + + + +`disabled` dims the item and stops it being activated: + + + +`variant` colors the item. +Reach for `danger` on a destructive entry, so it reads as one before it is clicked: + + + +## Buttons, Links, and Checkboxes + +By default an item is a `
@@ -43,10 +35,10 @@ const meta = {
`, -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Basic: Story = { args: { diff --git a/packages/craftcms-ui/src/components/select-color/select-color.stories.ts b/packages/craftcms-ui/src/components/select-color/select-color.stories.ts index 2353d1ffa46..b8782ba6d81 100644 --- a/packages/craftcms-ui/src/components/select-color/select-color.stories.ts +++ b/packages/craftcms-ui/src/components/select-color/select-color.stories.ts @@ -1,76 +1,65 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; + import {html} from 'lit'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; + import './select-color.js'; +import type CraftSelectColor from './select-color.js'; + +/** + * `args` and `argTypes` are derived from the custom elements manifest, so the + * controls and the API tables follow the component's JSDoc. Adding a property + * to `select-color.ts` surfaces it here without touching this file. + * + * The template is written out rather than generated: the value is bound as the + * `modelValue` property so an empty selection can be `null`, which an attribute + * cannot express. + */ +const {args, argTypes} = + getStorybookHelpers('craft-select-color'); + +type SelectColorArgs = CraftSelectColor & typeof args; const meta = { title: 'Form Controls/Select Controls/Select Color', component: 'craft-select-color', - args: { - label: 'Color', - modelValue: 'red', - }, - render: function ({label, modelValue}) { - return html``; - }, -} satisfies Meta; + args: {...args, label: 'Color', name: 'color', 'model-value': 'red'}, + argTypes, + render: (args) => html` + + `, +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; -/** - * A color is selected on load, so the invoker shows its swatch. - */ -export const Default: Story = { - args: {}, -}; +/** A colour is selected on load, so the invoker shows its swatch. */ +export const Default: Story = {}; -/** - * No color selected — the invoker shows the placeholder with no swatch. - */ +/** Nothing selected — the invoker shows the placeholder with no swatch. */ export const Empty: Story = { - args: { - modelValue: null, - }, + args: {'model-value': null}, }; -/** - * Preselected blue — the blue swatch appears in the invoker. - */ +/** Preselected blue, so the blue swatch appears in the invoker. */ export const Preselected: Story = { - args: { - modelValue: 'blue', - }, + args: {'model-value': 'blue'}, }; /** - * With the transparent option enabled and preselected, the checkerboard swatch - * appears in the invoker. + * `allow-transparent` adds a "transparent" option, whose swatch is the + * chequerboard. */ export const AllowTransparent: Story = { - render: () => html` - - `, + args: {'allow-transparent': true, 'model-value': '__blank__'}, }; -/** - * The transparent option enabled but nothing selected, so the full list is - * available from the invoker. - */ +/** The same option offered but nothing selected yet. */ export const AllowTransparentEmpty: Story = { - render: () => html` - - `, + args: {'allow-transparent': true, 'model-value': null}, }; diff --git a/packages/craftcms-ui/src/components/tabs/tabs.stories.ts b/packages/craftcms-ui/src/components/tabs/tabs.stories.ts index 048dd051b41..fa5acfa73f8 100644 --- a/packages/craftcms-ui/src/components/tabs/tabs.stories.ts +++ b/packages/craftcms-ui/src/components/tabs/tabs.stories.ts @@ -1,4 +1,6 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; + +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; import {expect, waitFor} from 'storybook/test'; import {html} from 'lit'; @@ -7,39 +9,38 @@ import {sizes} from '@src/constants/size'; import '../tab/tab.js'; import './tabs.js'; +import type CraftTabs from './tabs.js'; import {tabsPlacements} from './tabs.js'; import '../icon/icon.js'; +/** + * `args` and `argTypes` are derived from the custom elements manifest, so the + * controls and the API tables follow the component's JSDoc. Adding a property + * to `tabs.ts` surfaces it here without touching this file. + */ +const {args, argTypes} = getStorybookHelpers('craft-tabs'); + +type CraftTabsArgs = CraftTabs & typeof args; + const meta = { title: 'Components/Tabs', component: 'craft-tabs', + // `selected-index` is Lion's, so it is not in the manifest and is declared + // alongside the generated set. argTypes: { - placement: { - control: {type: 'inline-radio'}, - options: tabsPlacements, - description: 'Where the strip sits relative to the panels.', - }, - size: { - control: {type: 'inline-radio'}, - options: sizes, - description: 'How large the strip is.', - }, + ...argTypes, selectedIndex: { name: 'selected-index', control: {type: 'number'}, description: 'Index of the selected tab. -1 selects nothing.', }, - collapsible: { - control: {type: 'boolean'}, - description: 'Whether clicking the selected tab deselects it.', - }, }, args: { + ...args, placement: 'block-start', size: 'medium', selectedIndex: 0, - collapsible: false, }, render: (args) => html` `, -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; /* * These play functions are the real test bed for the Lion-driven behavior: diff --git a/packages/craftcms-ui/src/components/text-expander/text-expander.stories.ts b/packages/craftcms-ui/src/components/text-expander/text-expander.stories.ts index 38dfaed335a..6bd587e8c52 100644 --- a/packages/craftcms-ui/src/components/text-expander/text-expander.stories.ts +++ b/packages/craftcms-ui/src/components/text-expander/text-expander.stories.ts @@ -1,4 +1,6 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; + +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; import {expect, waitFor} from 'storybook/test'; import {html} from 'lit'; import {ref} from 'lit/directives/ref.js'; @@ -9,6 +11,7 @@ import type { TextExpanderTriggers, } from './text-expander.js'; import './text-expander.js'; +import type CraftTextExpander from './text-expander.js'; const people = [ {label: 'Ada Lovelace', value: '@ada', keywords: ['lovelace']}, @@ -32,14 +35,27 @@ const triggers: TextExpanderTriggers = [ }, ]; +/** + * `args` and `argTypes` are derived from the custom elements manifest, so the + * controls and the API tables follow the component's JSDoc. Adding a property + * to `text-expander.ts` surfaces it here without touching this file. + */ +const {args, argTypes} = getStorybookHelpers( + 'craft-text-expander' +); + +type CraftTextExpanderArgs = CraftTextExpander & typeof args; + const meta = { title: 'Components/Text Expander', component: 'craft-text-expander', + args, + argTypes, parameters: {layout: 'centered'}, -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const TextInput: Story = { render: () => html` diff --git a/packages/craftcms-ui/src/components/tooltip/tooltip.stories.ts b/packages/craftcms-ui/src/components/tooltip/tooltip.stories.ts index 7fbec3585f7..ce10365aa63 100644 --- a/packages/craftcms-ui/src/components/tooltip/tooltip.stories.ts +++ b/packages/craftcms-ui/src/components/tooltip/tooltip.stories.ts @@ -1,18 +1,32 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; + import {html} from 'lit'; import './tooltip.js'; +import type CraftTooltip from './tooltip.js'; import '../button/button.js'; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories +/** + * `args` and `argTypes` are derived from the custom elements manifest, so the + * controls and the API tables follow the component's JSDoc. Adding a property + * to `tooltip.ts` surfaces it here without touching this file. + */ +const {args, argTypes} = getStorybookHelpers('craft-tooltip'); + +type CraftTooltipArgs = CraftTooltip & typeof args; + const meta = { title: 'Components/Tooltip', component: 'craft-tooltip', args: { + ...args, placement: 'top', content: 'This is some content within a tooltip', }, + argTypes, parameters: { layout: 'centered', }, @@ -32,10 +46,10 @@ const meta = { Hover me `; }, -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args export const Playground: Story = { From fc578f2534be49ca6a277d88f64d8288d3d440c4 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 2 Sep 2026 13:44:11 -0500 Subject: [PATCH 81/90] Test the presentational components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight components had no unit test: avatar, status, indicator, spinner, shortcut, visually-hidden, thumbnail, and empty. Each now has a small one covering what it renders, the attributes that change it, and — for the four that carry an accessible name — the rule they all follow, that an unlabelled decorative element is hidden rather than announced as an unnamed image. craft-thumbnail's cover the corrected `checkered` contract in both directions, since the server relies on an absent attribute meaning off. Also folds the test files added earlier in this pass onto the package's conventions: `nested-control.test.ts` becomes `action-item.test.ts`, matching every other component, and the six that imported from `vitest` now import from `vite-plus/test` like the other 47. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- ...ed-control.test.ts => action-item.test.ts} | 2 +- .../src/components/avatar/avatar.test.ts | 68 ++++++++++++ .../src/components/card/padding-parts.test.ts | 2 +- .../src/components/empty/empty.test.ts | 59 +++++++++++ .../components/indicator/indicator.test.ts | 78 ++++++++++++++ .../components/input-date-time/labels.test.ts | 2 +- .../src/components/pane/heading-level.test.ts | 2 +- .../src/components/shortcut/shortcut.test.ts | 61 +++++++++++ .../src/components/spinner/spinner.test.ts | 78 ++++++++++++++ .../src/components/status/status.test.ts | 71 +++++++++++++ .../components/thumbnail/thumbnail.test.ts | 100 ++++++++++++++++++ .../visually-hidden/visually-hidden.test.ts | 55 ++++++++++ .../controllers/LightDomController.test.ts | 2 +- .../src/utilities/converters.test.ts | 2 +- 14 files changed, 576 insertions(+), 6 deletions(-) rename packages/craftcms-ui/src/components/action-item/{nested-control.test.ts => action-item.test.ts} (97%) create mode 100644 packages/craftcms-ui/src/components/avatar/avatar.test.ts create mode 100644 packages/craftcms-ui/src/components/empty/empty.test.ts create mode 100644 packages/craftcms-ui/src/components/indicator/indicator.test.ts create mode 100644 packages/craftcms-ui/src/components/shortcut/shortcut.test.ts create mode 100644 packages/craftcms-ui/src/components/spinner/spinner.test.ts create mode 100644 packages/craftcms-ui/src/components/status/status.test.ts create mode 100644 packages/craftcms-ui/src/components/thumbnail/thumbnail.test.ts create mode 100644 packages/craftcms-ui/src/components/visually-hidden/visually-hidden.test.ts diff --git a/packages/craftcms-ui/src/components/action-item/nested-control.test.ts b/packages/craftcms-ui/src/components/action-item/action-item.test.ts similarity index 97% rename from packages/craftcms-ui/src/components/action-item/nested-control.test.ts rename to packages/craftcms-ui/src/components/action-item/action-item.test.ts index e553c95994b..5436bf94525 100644 --- a/packages/craftcms-ui/src/components/action-item/nested-control.test.ts +++ b/packages/craftcms-ui/src/components/action-item/action-item.test.ts @@ -1,4 +1,4 @@ -import {expect, test} from 'vitest'; +import {expect, test} from 'vite-plus/test'; import {html, render} from 'lit'; import './action-item.js'; diff --git a/packages/craftcms-ui/src/components/avatar/avatar.test.ts b/packages/craftcms-ui/src/components/avatar/avatar.test.ts new file mode 100644 index 00000000000..80313a317f5 --- /dev/null +++ b/packages/craftcms-ui/src/components/avatar/avatar.test.ts @@ -0,0 +1,68 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './avatar.js'; +import type CraftAvatar from './avatar.js'; + +async function createAvatar( + attrs: Record = {}, + innerHTML = 'BH' +): Promise { + const element = document.createElement('craft-avatar') as CraftAvatar; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +function svg(element: CraftAvatar): SVGElement { + return element.shadowRoot!.querySelector('svg')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-avatar', () => { + it('renders the gradient artwork', async () => { + const element = await createAvatar(); + + expect(svg(element)).toBeTruthy(); + expect(element.shadowRoot!.querySelector('linearGradient')).toBeTruthy(); + }); + + /** + * An avatar that names nothing is decoration beside the name it sits next to, + * so it is hidden rather than announced as an unnamed image. + */ + it('is hidden from assistive technology without a label', async () => { + const element = await createAvatar(); + + expect(svg(element).getAttribute('aria-hidden')).toBe('true'); + expect(svg(element).getAttribute('role')).toBeNull(); + expect(element.shadowRoot!.querySelector('title')).toBeNull(); + }); + + it('becomes a named image once it has a label', async () => { + const element = await createAvatar({label: 'Brian Hanson'}); + + expect(svg(element).getAttribute('role')).toBe('img'); + expect(svg(element).getAttribute('aria-hidden')).toBeNull(); + expect(element.shadowRoot!.querySelector('title')?.textContent).toBe( + 'Brian Hanson' + ); + }); + + it('gives each instance its own gradient id, so they do not collide', async () => { + const first = await createAvatar(); + const second = await createAvatar(); + + const id = (element: CraftAvatar) => + element.shadowRoot!.querySelector('linearGradient')!.id; + + expect(id(first)).not.toBe(''); + expect(id(first)).not.toBe(id(second)); + }); +}); diff --git a/packages/craftcms-ui/src/components/card/padding-parts.test.ts b/packages/craftcms-ui/src/components/card/padding-parts.test.ts index 4eb29acf4ad..62eb603f508 100644 --- a/packages/craftcms-ui/src/components/card/padding-parts.test.ts +++ b/packages/craftcms-ui/src/components/card/padding-parts.test.ts @@ -1,4 +1,4 @@ -import {expect, test} from 'vitest'; +import {expect, test} from 'vite-plus/test'; import {html, render} from 'lit'; import './card.js'; diff --git a/packages/craftcms-ui/src/components/empty/empty.test.ts b/packages/craftcms-ui/src/components/empty/empty.test.ts new file mode 100644 index 00000000000..68715947267 --- /dev/null +++ b/packages/craftcms-ui/src/components/empty/empty.test.ts @@ -0,0 +1,59 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './empty.js'; +import type CraftEmpty from './empty.js'; + +async function createEmpty( + attrs: Record = {}, + innerHTML = '' +): Promise { + const element = document.createElement('craft-empty') as CraftEmpty; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-empty', () => { + it('renders the label', async () => { + const element = await createEmpty({label: 'Nothing yet.'}); + + expect( + element.shadowRoot!.querySelector('.label')?.textContent?.trim() + ).toBe('Nothing yet.'); + }); + + it('renders an icon when one is named', async () => { + const element = await createEmpty({ + label: 'None', + icon: 'magnifying-glass', + }); + + expect( + element.shadowRoot!.querySelector('craft-icon')?.getAttribute('name') + ).toBe('magnifying-glass'); + }); + + it('renders no icon when none is named', async () => { + const element = await createEmpty({label: 'None'}); + + expect(element.shadowRoot!.querySelector('craft-icon')).toBeNull(); + }); + + /** Each slot is a fallback point, so slotting replaces what it defaults to. */ + it('offers graphic, content, and default slots', async () => { + const element = await createEmpty({label: 'None'}); + const names = [...element.shadowRoot!.querySelectorAll('slot')].map( + (slot) => slot.getAttribute('name') + ); + + expect(names).toEqual(expect.arrayContaining(['graphic', 'content', null])); + }); +}); diff --git a/packages/craftcms-ui/src/components/indicator/indicator.test.ts b/packages/craftcms-ui/src/components/indicator/indicator.test.ts new file mode 100644 index 00000000000..46dd40921a3 --- /dev/null +++ b/packages/craftcms-ui/src/components/indicator/indicator.test.ts @@ -0,0 +1,78 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './indicator.js'; +import type CraftIndicator from './indicator.js'; + +async function createIndicator( + attrs: Record = {} +): Promise { + const element = document.createElement('craft-indicator') as CraftIndicator; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + document.body.append(element); + await element.updateComplete; + return element; +} + +function dot(element: CraftIndicator): HTMLElement { + return element.shadowRoot!.querySelector('.indicator')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-indicator', () => { + it('renders a dot', async () => { + expect(dot(await createIndicator())).toBeTruthy(); + }); + + /** A recognised variant resolves to that variant's fill token. */ + it('resolves a status variant to its token', async () => { + const element = await createIndicator({fill: 'success'}); + + expect(dot(element).getAttribute('style')).toContain( + 'var(--c-color-success-fill-loud)' + ); + }); + + /** So does a palette swatch, which is the same lookup. */ + it('resolves a palette swatch to its token', async () => { + const element = await createIndicator({fill: 'red'}); + + expect(dot(element).getAttribute('style')).toContain( + 'var(--c-color-red-fill-loud)' + ); + }); + + /** Anything else is passed through as a CSS colour. */ + it('passes an arbitrary colour straight through', async () => { + const element = await createIndicator({fill: '#2c61de'}); + + expect(dot(element).getAttribute('style')).toContain('#2c61de'); + }); + + it('marks the outline appearance with a modifier class', async () => { + const element = await createIndicator({appearance: 'outline'}); + + expect(dot(element).classList.contains('indicator--outline')).toBe(true); + }); + + /** + * An unlabelled dot is decoration beside the thing it marks, so it is not + * announced as an unnamed image. + */ + it('is not an image without a label', async () => { + const element = await createIndicator(); + + expect(dot(element).getAttribute('role')).toBeNull(); + }); + + it('becomes a named image once it has a label', async () => { + const element = await createIndicator({label: 'Online'}); + + expect(dot(element).getAttribute('role')).toBe('img'); + expect(dot(element).getAttribute('aria-label')).toBe('Online'); + }); +}); diff --git a/packages/craftcms-ui/src/components/input-date-time/labels.test.ts b/packages/craftcms-ui/src/components/input-date-time/labels.test.ts index 8c2797fbcbb..02f49d476af 100644 --- a/packages/craftcms-ui/src/components/input-date-time/labels.test.ts +++ b/packages/craftcms-ui/src/components/input-date-time/labels.test.ts @@ -1,4 +1,4 @@ -import {expect, test} from 'vitest'; +import {expect, test} from 'vite-plus/test'; import {html, render} from 'lit'; import './input-date-time.js'; diff --git a/packages/craftcms-ui/src/components/pane/heading-level.test.ts b/packages/craftcms-ui/src/components/pane/heading-level.test.ts index 1795c578b4b..c85819ff1da 100644 --- a/packages/craftcms-ui/src/components/pane/heading-level.test.ts +++ b/packages/craftcms-ui/src/components/pane/heading-level.test.ts @@ -1,4 +1,4 @@ -import {expect, test} from 'vitest'; +import {expect, test} from 'vite-plus/test'; import {html, render} from 'lit'; import './pane.js'; diff --git a/packages/craftcms-ui/src/components/shortcut/shortcut.test.ts b/packages/craftcms-ui/src/components/shortcut/shortcut.test.ts new file mode 100644 index 00000000000..261c584ef93 --- /dev/null +++ b/packages/craftcms-ui/src/components/shortcut/shortcut.test.ts @@ -0,0 +1,61 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './shortcut.js'; +import type CraftShortcut from './shortcut.js'; + +async function createShortcut( + attrs: Record = {}, + innerHTML = 'S' +): Promise { + const element = document.createElement('craft-shortcut') as CraftShortcut; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +/** The prefix the component renders, without the slotted key. */ +function prefix(element: CraftShortcut): string { + return element.shadowRoot!.querySelector('.shortcut')!.textContent!.trim(); +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-shortcut', () => { + it('uses the platform symbols on a Mac', async () => { + expect(prefix(await createShortcut({os: 'Mac'}))).toBe('⌘'); + expect(prefix(await createShortcut({os: 'Mac', alt: ''}))).toBe('⌥⌘'); + expect(prefix(await createShortcut({os: 'Mac', shift: ''}))).toBe('⇧⌘'); + expect(prefix(await createShortcut({os: 'Mac', alt: '', shift: ''}))).toBe( + '⌥⇧⌘' + ); + }); + + it('spells the modifiers out elsewhere', async () => { + expect(prefix(await createShortcut({os: 'Windows'}))).toBe('Ctrl+'); + expect(prefix(await createShortcut({os: 'Windows', alt: ''}))).toBe( + 'Ctrl+Alt+' + ); + expect(prefix(await createShortcut({os: 'Linux'}))).toBe('Super+'); + expect(prefix(await createShortcut({os: 'Linux', shift: ''}))).toBe( + 'Super+Shift+' + ); + }); + + /** An unknown platform falls back to the Ctrl spelling rather than nothing. */ + it('falls back to Ctrl for an unknown platform', async () => { + expect(prefix(await createShortcut({os: 'Unknown'}))).toBe('Ctrl+'); + }); + + it('renders the key from the default slot', async () => { + const element = await createShortcut({os: 'Mac'}, 'K'); + + expect(element.textContent).toBe('K'); + expect(element.shadowRoot!.querySelector('slot')).toBeTruthy(); + }); +}); diff --git a/packages/craftcms-ui/src/components/spinner/spinner.test.ts b/packages/craftcms-ui/src/components/spinner/spinner.test.ts new file mode 100644 index 00000000000..35c22aedb38 --- /dev/null +++ b/packages/craftcms-ui/src/components/spinner/spinner.test.ts @@ -0,0 +1,78 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './spinner.js'; +import type CraftSpinner from './spinner.js'; + +async function createSpinner( + attrs: Record = {}, + innerHTML = 'Loading' +): Promise { + const element = document.createElement('craft-spinner') as CraftSpinner; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +function wrapper(element: CraftSpinner): HTMLElement { + return element.shadowRoot!.querySelector('.wrapper')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-spinner', () => { + it('is visible by default', async () => { + const element = await createSpinner(); + + expect(element.visible).toBe(true); + expect(wrapper(element).classList.contains('hidden')).toBe(false); + }); + + /** Hiding keeps it in the layout rather than collapsing the space. */ + it('hides without leaving the layout', async () => { + const element = await createSpinner(); + + element.hide(); + await element.updateComplete; + + expect(element.visible).toBe(false); + expect(wrapper(element).classList.contains('hidden')).toBe(true); + expect(wrapper(element).isConnected).toBe(true); + }); + + it('shows again', async () => { + const element = await createSpinner({visible: 'false'}); + + element.show(); + await element.updateComplete; + + expect(element.visible).toBe(true); + }); + + it('fires show and hide', async () => { + const element = await createSpinner(); + const seen: string[] = []; + element.addEventListener('show', () => seen.push('show')); + element.addEventListener('hide', () => seen.push('hide')); + + element.hide(); + element.show(); + + expect(seen).toEqual(['hide', 'show']); + }); + + /** The slotted text is the accessible name, and is visually hidden. */ + it('keeps its slotted label for assistive technology', async () => { + const element = await createSpinner({}, 'Saving entry'); + + expect( + element.shadowRoot!.querySelector('.cp-visually-hidden') + ).toBeTruthy(); + expect(element.textContent).toBe('Saving entry'); + }); +}); diff --git a/packages/craftcms-ui/src/components/status/status.test.ts b/packages/craftcms-ui/src/components/status/status.test.ts new file mode 100644 index 00000000000..886225e7861 --- /dev/null +++ b/packages/craftcms-ui/src/components/status/status.test.ts @@ -0,0 +1,71 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './status.js'; +import type CraftStatus from './status.js'; + +async function createStatus( + attrs: Record = {} +): Promise { + const element = document.createElement('craft-status') as CraftStatus; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + document.body.append(element); + await element.updateComplete; + return element; +} + +function dot(element: CraftStatus): HTMLElement { + return element.shadowRoot!.querySelector('.status')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-status', () => { + it('renders a dot', async () => { + const element = await createStatus(); + + expect(dot(element)).toBeTruthy(); + }); + + it('carries a modifier class for each status', async () => { + for (const status of [ + 'live', + 'pending', + 'expired', + 'disabled', + 'enabled', + ]) { + const element = await createStatus({status}); + + expect(dot(element).classList.contains(`status--${status}`)).toBe(true); + } + }); + + /** + * A dot with nothing to say is decoration beside the label it sits next to. + * An unnamed `role="img"` would be announced as an image and tell a screen + * reader user nothing. + */ + it('is not an image when it has no status and no label', async () => { + const element = await createStatus(); + + expect(dot(element).getAttribute('role')).toBeNull(); + expect(dot(element).getAttribute('aria-label')).toBeNull(); + }); + + it('announces the status when one is set', async () => { + const element = await createStatus({status: 'live'}); + + expect(dot(element).getAttribute('role')).toBe('img'); + expect(dot(element).getAttribute('aria-label')).toBe('Status: live'); + }); + + it('prefers an explicit label over the status name', async () => { + const element = await createStatus({status: 'live', label: 'Published'}); + + expect(dot(element).getAttribute('aria-label')).toBe('Published'); + }); +}); diff --git a/packages/craftcms-ui/src/components/thumbnail/thumbnail.test.ts b/packages/craftcms-ui/src/components/thumbnail/thumbnail.test.ts new file mode 100644 index 00000000000..e8fe45853aa --- /dev/null +++ b/packages/craftcms-ui/src/components/thumbnail/thumbnail.test.ts @@ -0,0 +1,100 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './thumbnail.js'; +import type CraftThumbnail from './thumbnail.js'; + +async function createThumbnail( + attrs: Record = {}, + innerHTML = '' +): Promise { + const element = document.createElement('craft-thumbnail') as CraftThumbnail; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +function box(element: CraftThumbnail): HTMLElement { + return element.shadowRoot!.querySelector('.thumbnail')!; +} + +function image(element: CraftThumbnail): HTMLImageElement | null { + return element.shadowRoot!.querySelector('img'); +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-thumbnail', () => { + it('renders an image from src', async () => { + const element = await createThumbnail({src: 'a.png', alt: 'An asset'}); + + expect(image(element)?.getAttribute('src')).toBe('a.png'); + expect(image(element)?.getAttribute('alt')).toBe('An asset'); + }); + + it('lazy-loads by default, and defers decoding', async () => { + const element = await createThumbnail({src: 'a.png'}); + + expect(image(element)?.getAttribute('loading')).toBe('lazy'); + expect(image(element)?.getAttribute('decoding')).toBe('async'); + }); + + it('takes an eager loading strategy when asked', async () => { + const element = await createThumbnail({src: 'a.png', loading: 'eager'}); + + expect(image(element)?.getAttribute('loading')).toBe('eager'); + }); + + it('falls back to the default slot with no src', async () => { + const element = await createThumbnail({}, ''); + + expect(image(element)).toBeNull(); + expect(element.shadowRoot!.querySelector('slot')).toBeTruthy(); + }); + + /** + * `checkered` is an ordinary boolean attribute: absent means off. The server + * renders it only for images that can be transparent, and omits it + * otherwise, so a defaulted-on property would checker every other thumbnail. + */ + it('is not checkered unless asked', async () => { + const element = await createThumbnail({src: 'a.png'}); + + expect(element.checkered).toBe(false); + expect(box(element).classList.contains('thumbnail--checkered')).toBe(false); + }); + + it('checkers when the attribute is present', async () => { + const element = await createThumbnail({src: 'a.png', checkered: ''}); + + expect(element.checkered).toBe(true); + expect(box(element).classList.contains('thumbnail--checkered')).toBe(true); + }); + + it('rounds when the attribute is present', async () => { + const element = await createThumbnail({src: 'a.png', rounded: ''}); + + expect(box(element).classList.contains('thumbnail--rounded')).toBe(true); + }); + + it('passes the responsive image attributes through', async () => { + const element = await createThumbnail({ + src: 'a.png', + srcset: 'a.png 1x, a@2x.png 2x', + sizes: '2rem', + width: '32', + height: '32', + }); + + expect(image(element)?.getAttribute('srcset')).toBe( + 'a.png 1x, a@2x.png 2x' + ); + expect(image(element)?.getAttribute('sizes')).toBe('2rem'); + expect(image(element)?.getAttribute('width')).toBe('32'); + }); +}); diff --git a/packages/craftcms-ui/src/components/visually-hidden/visually-hidden.test.ts b/packages/craftcms-ui/src/components/visually-hidden/visually-hidden.test.ts new file mode 100644 index 00000000000..792da9bdccc --- /dev/null +++ b/packages/craftcms-ui/src/components/visually-hidden/visually-hidden.test.ts @@ -0,0 +1,55 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './visually-hidden.js'; +import type CraftVisuallyHidden from './visually-hidden.js'; + +async function createVisuallyHidden( + attrs: Record = {}, + innerHTML = 'Skip to content' +): Promise { + const element = document.createElement( + 'craft-visually-hidden' + ) as CraftVisuallyHidden; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-visually-hidden', () => { + /** + * The content has to stay in the accessibility tree — hiding it with + * `display: none` or `hidden` would take it out, which is the opposite of + * what this component is for. + */ + it('keeps its content in the DOM', async () => { + const element = await createVisuallyHidden(); + + expect(element.textContent).toBe('Skip to content'); + expect(element.shadowRoot!.querySelector('slot')).toBeTruthy(); + expect(element.hasAttribute('hidden')).toBe(false); + }); + + it('is not in debug mode by default', async () => { + const element = await createVisuallyHidden(); + + expect(element.debug).toBe(false); + }); + + /** `debug` reveals the content, for checking what a screen reader gets. */ + it('reflects debug so the stylesheet can reveal it', async () => { + const element = await createVisuallyHidden(); + + element.debug = true; + await element.updateComplete; + + expect(element.hasAttribute('debug')).toBe(true); + }); +}); diff --git a/packages/craftcms-ui/src/controllers/LightDomController.test.ts b/packages/craftcms-ui/src/controllers/LightDomController.test.ts index 1e75e890412..088bc1b0d39 100644 --- a/packages/craftcms-ui/src/controllers/LightDomController.test.ts +++ b/packages/craftcms-ui/src/controllers/LightDomController.test.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, test} from 'vitest'; +import {beforeEach, describe, expect, test} from 'vite-plus/test'; import {html, LitElement} from 'lit'; import {customElement} from 'lit/decorators.js'; diff --git a/packages/craftcms-ui/src/utilities/converters.test.ts b/packages/craftcms-ui/src/utilities/converters.test.ts index 6ec59916b92..bd86b0d6ed1 100644 --- a/packages/craftcms-ui/src/utilities/converters.test.ts +++ b/packages/craftcms-ui/src/utilities/converters.test.ts @@ -1,4 +1,4 @@ -import {describe, expect, test} from 'vitest'; +import {describe, expect, test} from 'vite-plus/test'; import {defaultTrueBoolean, jsonAttribute} from './converters'; From d5440b63a83e8d943243ae0277b593aceadb5d3b Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 2 Sep 2026 13:46:51 -0500 Subject: [PATCH 82/90] Test the progress, badge, and navigation components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six more components have unit tests: progress, progress-bar, badge, badge-indicator, nav-list, and breadcrumbs. The progress pair are tested through what they report rather than what they draw. Both are canvas or CSS fills with nothing a screen reader can read, so the tests hold the ARIA range, the accessible name, and — for craft-progress — the visually hidden text that carries the percentage, the loading state, and the failure. The badges cover the state that is easy to get wrong: a zero count stays a dot, a count past 99 becomes `99+`, and `data-color` follows `fill`, since that is what scopes the tokens the badge's own surface reads. nav-list and breadcrumbs cover their landmark semantics — the list element that makes a run of items countable, the named navigation landmark, and the separator kept out of the accessibility tree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../badge-indicator/badge-indicator.test.ts | 109 ++++++++++++++++++ .../src/components/badge/badge.test.ts | 68 +++++++++++ .../breadcrumbs/breadcrumbs.test.ts | 73 ++++++++++++ .../src/components/nav-list/nav-list.test.ts | 52 +++++++++ .../progress-bar/progress-bar.test.ts | 74 ++++++++++++ .../src/components/progress/progress.test.ts | 72 ++++++++++++ 6 files changed, 448 insertions(+) create mode 100644 packages/craftcms-ui/src/components/badge-indicator/badge-indicator.test.ts create mode 100644 packages/craftcms-ui/src/components/badge/badge.test.ts create mode 100644 packages/craftcms-ui/src/components/breadcrumbs/breadcrumbs.test.ts create mode 100644 packages/craftcms-ui/src/components/nav-list/nav-list.test.ts create mode 100644 packages/craftcms-ui/src/components/progress-bar/progress-bar.test.ts create mode 100644 packages/craftcms-ui/src/components/progress/progress.test.ts diff --git a/packages/craftcms-ui/src/components/badge-indicator/badge-indicator.test.ts b/packages/craftcms-ui/src/components/badge-indicator/badge-indicator.test.ts new file mode 100644 index 00000000000..5e802c3add6 --- /dev/null +++ b/packages/craftcms-ui/src/components/badge-indicator/badge-indicator.test.ts @@ -0,0 +1,109 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './badge-indicator.js'; +import type CraftBadgeIndicator from './badge-indicator.js'; + +async function createBadgeIndicator( + attrs: Record = {} +): Promise { + const element = document.createElement( + 'craft-badge-indicator' + ) as CraftBadgeIndicator; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + document.body.append(element); + await element.updateComplete; + return element; +} + +function badge(element: CraftBadgeIndicator): HTMLElement { + return element.shadowRoot!.querySelector('[part="badge"]')!; +} + +function number(element: CraftBadgeIndicator): string | null { + return ( + element.shadowRoot!.querySelector('.number')?.textContent?.trim() ?? null + ); +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-badge-indicator', () => { + it('is a bare dot with no count', async () => { + const element = await createBadgeIndicator(); + + expect(number(element)).toBeNull(); + expect( + badge(element).classList.contains('badge-indicator--with-number') + ).toBe(false); + }); + + it('shows a count once there is one', async () => { + const element = await createBadgeIndicator({'badge-count': '5'}); + + expect(number(element)).toBe('5'); + expect( + badge(element).classList.contains('badge-indicator--with-number') + ).toBe(true); + }); + + /** A zero count is nothing to report, so it stays a dot. */ + it('stays a dot at zero', async () => { + const element = await createBadgeIndicator({'badge-count': '0'}); + + expect(number(element)).toBeNull(); + }); + + /** Past 99 the exact number stops being useful and would not fit. */ + it('caps the count at 99+', async () => { + expect(number(await createBadgeIndicator({'badge-count': '99'}))).toBe( + '99' + ); + expect(number(await createBadgeIndicator({'badge-count': '100'}))).toBe( + '99+' + ); + }); + + it('carries a modifier class for each variant', async () => { + for (const variant of ['secondary', 'inverse']) { + const element = await createBadgeIndicator({variant}); + + expect( + badge(element).classList.contains(`badge-indicator--${variant}`) + ).toBe(true); + } + }); + + /** + * Without alt text the indicator is decoration beside whatever it marks, so + * it is not announced as an unnamed image. + */ + it('is not an image without alt text', async () => { + const element = await createBadgeIndicator({'badge-count': '5'}); + + expect(badge(element).getAttribute('role')).toBeNull(); + }); + + it('becomes a named image once it has alt text', async () => { + const element = await createBadgeIndicator({'alt-text': 'Has updates'}); + + expect(badge(element).getAttribute('role')).toBe('img'); + expect(badge(element).getAttribute('aria-labelledby')).toBe( + `${element.id}-label` + ); + expect( + element.shadowRoot!.querySelector(`#${element.id}-label`)?.textContent + ).toBe('Has updates'); + }); + + it('gives each instance an id, so its label can be referenced', async () => { + const first = await createBadgeIndicator(); + const second = await createBadgeIndicator(); + + expect(first.id).not.toBe(''); + expect(first.id).not.toBe(second.id); + }); +}); diff --git a/packages/craftcms-ui/src/components/badge/badge.test.ts b/packages/craftcms-ui/src/components/badge/badge.test.ts new file mode 100644 index 00000000000..68277cf6126 --- /dev/null +++ b/packages/craftcms-ui/src/components/badge/badge.test.ts @@ -0,0 +1,68 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './badge.js'; +import type CraftBadge from './badge.js'; + +async function createBadge( + attrs: Record = {}, + innerHTML = 'Live' +): Promise { + const element = document.createElement('craft-badge') as CraftBadge; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-badge', () => { + it('defaults to gray', async () => { + const element = await createBadge(); + + expect(element.fill).toBe('gray'); + }); + + /** + * The host's `data-color` is what scopes the `--c-color-*` tokens the badge's + * own surface, border, and text read, so it has to follow `fill`. + */ + it('reflects the fill onto data-color for the token scope', async () => { + const element = await createBadge({fill: 'emerald'}); + + expect(element.dataset.color).toBe('emerald'); + }); + + it('keeps data-color in step when the fill changes', async () => { + const element = await createBadge({fill: 'emerald'}); + + element.fill = 'red'; + await element.updateComplete; + + expect(element.dataset.color).toBe('red'); + }); + + /** The default prefix is an indicator tinted to match. */ + it('renders an indicator carrying the same fill', async () => { + const element = await createBadge({fill: 'red'}); + const indicator = element.shadowRoot!.querySelector('craft-indicator'); + + expect(indicator?.getAttribute('fill')).toBe('red'); + }); + + it('exposes its regions as parts', async () => { + const element = await createBadge(); + const parts = [...element.shadowRoot!.querySelectorAll('[part]')].map( + (el) => el.getAttribute('part') + ); + + expect(parts).toEqual( + expect.arrayContaining(['badge', 'prefix', 'indicator', 'suffix']) + ); + }); +}); diff --git a/packages/craftcms-ui/src/components/breadcrumbs/breadcrumbs.test.ts b/packages/craftcms-ui/src/components/breadcrumbs/breadcrumbs.test.ts new file mode 100644 index 00000000000..6f669e9a2e6 --- /dev/null +++ b/packages/craftcms-ui/src/components/breadcrumbs/breadcrumbs.test.ts @@ -0,0 +1,73 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './breadcrumbs.js'; +import '../breadcrumb-item/breadcrumb-item.js'; +import type CraftBreadcrumbs from './breadcrumbs.js'; + +async function createBreadcrumbs( + attrs: Record = {}, + innerHTML = ` + Site + Entries + News + ` +): Promise { + const element = document.createElement( + 'craft-breadcrumbs' + ) as CraftBreadcrumbs; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +function nav(element: CraftBreadcrumbs): HTMLElement { + return element.shadowRoot!.querySelector('nav')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-breadcrumbs', () => { + /** + * A page can hold more than one navigation landmark, so the trail names + * itself rather than being announced as an unlabelled "navigation". + */ + it('is a named navigation landmark', async () => { + const element = await createBreadcrumbs(); + + expect(nav(element)).toBeTruthy(); + expect(nav(element).getAttribute('aria-label')).toBeTruthy(); + }); + + it('takes a label of its own', async () => { + const element = await createBreadcrumbs({label: 'You are here'}); + + expect(nav(element).getAttribute('aria-label')).toBe('You are here'); + }); + + it('renders its items from the default slot', async () => { + const element = await createBreadcrumbs(); + const slot = element.shadowRoot!.querySelector('slot:not([name])')!; + + expect(slot.assignedElements()).toHaveLength(3); + }); + + /** + * The separator is drawn between items and carries no meaning of its own, so + * the template it is cloned from is hidden from assistive technology. + */ + it('keeps the separator out of the accessibility tree', async () => { + const element = await createBreadcrumbs(); + const separator = element.shadowRoot!.querySelector( + 'slot[name="separator"]' + )!.parentElement!; + + expect(separator.getAttribute('aria-hidden')).toBe('true'); + expect(separator.hasAttribute('hidden')).toBe(true); + }); +}); diff --git a/packages/craftcms-ui/src/components/nav-list/nav-list.test.ts b/packages/craftcms-ui/src/components/nav-list/nav-list.test.ts new file mode 100644 index 00000000000..347dfaf5c8b --- /dev/null +++ b/packages/craftcms-ui/src/components/nav-list/nav-list.test.ts @@ -0,0 +1,52 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './nav-list.js'; +import '../nav-item/nav-item.js'; +import type CraftNavList from './nav-list.js'; + +async function createNavList(innerHTML = ''): Promise { + const element = document.createElement('craft-nav-list') as CraftNavList; + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-nav-list', () => { + /** + * The list element is what makes a run of nav items a list to a screen + * reader, which is how it announces how many there are and where you are in + * them. + */ + it('renders a list around its items', async () => { + const element = await createNavList( + 'Entries' + ); + + expect(element.shadowRoot!.querySelector('ul')).toBeTruthy(); + expect(element.shadowRoot!.querySelector('slot')).toBeTruthy(); + }); + + it('takes its items from the default slot', async () => { + const element = await createNavList(` + Entries + Assets + `); + + const slot = element.shadowRoot!.querySelector('slot')!; + + expect( + slot.assignedElements().map((item) => item.tagName.toLowerCase()) + ).toEqual(['craft-nav-item', 'craft-nav-item']); + }); + + it('renders an empty list rather than nothing', async () => { + const element = await createNavList(); + + expect(element.shadowRoot!.querySelector('ul')).toBeTruthy(); + }); +}); diff --git a/packages/craftcms-ui/src/components/progress-bar/progress-bar.test.ts b/packages/craftcms-ui/src/components/progress-bar/progress-bar.test.ts new file mode 100644 index 00000000000..1a23db639ad --- /dev/null +++ b/packages/craftcms-ui/src/components/progress-bar/progress-bar.test.ts @@ -0,0 +1,74 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './progress-bar.js'; +import type CraftProgressBar from './progress-bar.js'; + +async function createProgressBar( + attrs: Record = {} +): Promise { + const element = document.createElement( + 'craft-progress-bar' + ) as CraftProgressBar; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + document.body.append(element); + await element.updateComplete; + return element; +} + +function track(element: CraftProgressBar): HTMLElement { + return element.shadowRoot!.querySelector('[part="track"]')!; +} + +function fill(element: CraftProgressBar): HTMLElement { + return element.shadowRoot!.querySelector('[part="fill"]')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-progress-bar', () => { + it('is a progressbar with the full range declared', async () => { + const element = await createProgressBar({progress: '40'}); + + expect(track(element).getAttribute('role')).toBe('progressbar'); + expect(track(element).getAttribute('aria-valuemin')).toBe('0'); + expect(track(element).getAttribute('aria-valuemax')).toBe('100'); + expect(track(element).getAttribute('aria-valuenow')).toBe('40'); + }); + + it('names itself, so a bar on its own is not anonymous', async () => { + const element = await createProgressBar({label: 'Uploading assets'}); + + expect(track(element).getAttribute('aria-label')).toBe('Uploading assets'); + }); + + it('fills to the progress value', async () => { + const element = await createProgressBar({progress: '40'}); + + expect(fill(element).style.width).toBe('40%'); + }); + + /** A total and a processed count are the other way to express the same thing. */ + it('derives progress from processed and total', async () => { + const element = await createProgressBar({total: '50', processed: '25'}); + + expect(fill(element).style.width).toBe('50%'); + }); + + /** + * A pending bar has no value to report, so it fills the track and drops + * `aria-valuenow` rather than claiming a number it does not have. + */ + it('reports no value while pending', async () => { + const element = await createProgressBar({pending: ''}); + + expect(track(element).getAttribute('aria-valuenow')).toBeNull(); + expect(fill(element).style.width).toBe('100%'); + expect(track(element).classList.contains('progress-bar--pending')).toBe( + true + ); + }); +}); diff --git a/packages/craftcms-ui/src/components/progress/progress.test.ts b/packages/craftcms-ui/src/components/progress/progress.test.ts new file mode 100644 index 00000000000..b1dd110db3a --- /dev/null +++ b/packages/craftcms-ui/src/components/progress/progress.test.ts @@ -0,0 +1,72 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './progress.js'; +import type CraftProgress from './progress.js'; + +async function createProgress( + attrs: Record = {} +): Promise { + const element = document.createElement('craft-progress') as CraftProgress; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + document.body.append(element); + await element.updateComplete; + return element; +} + +function canvas(element: CraftProgress): HTMLElement { + return element.shadowRoot!.querySelector('[part="canvas"]')!; +} + +/** The visually hidden text, which is what a screen reader actually gets. */ +function announced(element: CraftProgress): string { + return element + .shadowRoot!.querySelector('.visually-hidden')! + .textContent!.trim(); +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-progress', () => { + it('is a progressbar with the full range declared', async () => { + const element = await createProgress({progress: '60'}); + + expect(canvas(element).getAttribute('role')).toBe('progressbar'); + expect(canvas(element).getAttribute('aria-valuemin')).toBe('0'); + expect(canvas(element).getAttribute('aria-valuemax')).toBe('100'); + expect(canvas(element).getAttribute('aria-valuenow')).toBe('60'); + }); + + it('names itself', async () => { + const element = await createProgress({label: 'Generating transforms'}); + + expect(canvas(element).getAttribute('aria-label')).toBe( + 'Generating transforms' + ); + }); + + /** + * The canvas draws the ring, so the state has to be announced in text + * alongside it — a canvas has nothing a screen reader can read. + */ + it('announces the percentage in text', async () => { + expect(announced(await createProgress({progress: '60'}))).toBe('60%'); + }); + + /** A negative value means indeterminate rather than a number to report. */ + it('announces loading when there is no value yet', async () => { + const element = await createProgress({progress: '-1'}); + + expect(announced(element)).toBe('Loading'); + expect(canvas(element).getAttribute('aria-valuenow')).toBe(''); + }); + + it('announces a failure', async () => { + expect(announced(await createProgress({progress: '60', failed: ''}))).toBe( + 'Failed' + ); + }); +}); From daee6d811395b830e30a7b5ad403923e38742586 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 2 Sep 2026 13:49:20 -0500 Subject: [PATCH 83/90] Test the text controls and the copy components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five more: craft-input, craft-textarea, craft-select, craft-copy-button, and craft-copy-attribute. craft-input matters most of the five — every other `craft-input-*` extends it, and it was the largest component in the package with no test at all. Its tests cover what the subclasses inherit: the native input Lion renders, the label wiring that makes a click focus the field, `maxlength` reaching the control, the reflected presentation flags, and the properties that are synced onto the native input after render rather than rendered with it. The label assertions check the `for`/`id` pairing rather than `aria-labelledby`, which Lion fills in later than happy-dom settles — the `for` attribute is the mechanism that actually makes the label clickable. The copy components stub `navigator.clipboard`, which happy-dom does not provide, and cover what a caller depends on: the value written, the events fired on success and failure, and the two cases that must not copy — disabled, and a second press while a copy is still running. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../copy-attribute/copy-attribute.test.ts | 53 ++++++++ .../copy-button/copy-button.test.ts | 94 ++++++++++++++ .../src/components/input/input.test.ts | 119 ++++++++++++++++++ .../src/components/select/select.test.ts | 81 ++++++++++++ .../src/components/textarea/textarea.test.ts | 68 ++++++++++ 5 files changed, 415 insertions(+) create mode 100644 packages/craftcms-ui/src/components/copy-attribute/copy-attribute.test.ts create mode 100644 packages/craftcms-ui/src/components/copy-button/copy-button.test.ts create mode 100644 packages/craftcms-ui/src/components/input/input.test.ts create mode 100644 packages/craftcms-ui/src/components/select/select.test.ts create mode 100644 packages/craftcms-ui/src/components/textarea/textarea.test.ts diff --git a/packages/craftcms-ui/src/components/copy-attribute/copy-attribute.test.ts b/packages/craftcms-ui/src/components/copy-attribute/copy-attribute.test.ts new file mode 100644 index 00000000000..0482c4c5292 --- /dev/null +++ b/packages/craftcms-ui/src/components/copy-attribute/copy-attribute.test.ts @@ -0,0 +1,53 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './copy-attribute.js'; +import type CraftCopyAttribute from './copy-attribute.js'; + +async function createCopyAttribute( + attrs: Record = {} +): Promise { + const element = document.createElement( + 'craft-copy-attribute' + ) as CraftCopyAttribute; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + document.body.append(element); + await element.updateComplete; + return element; +} + +function inner(element: CraftCopyAttribute): HTMLElement { + return element.shadowRoot!.querySelector('craft-copy-button')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-copy-attribute', () => { + /** It is a copy button that shows the value it copies. */ + it('shows the value and hands it to the copy button', async () => { + const element = await createCopyAttribute({value: 'fieldHandle'}); + + expect(inner(element).getAttribute('value')).toBe('fieldHandle'); + expect(inner(element).textContent?.trim()).toBe('fieldHandle'); + }); + + it('follows a changed value', async () => { + const element = await createCopyAttribute({value: 'one'}); + + element.value = 'two'; + await element.updateComplete; + + expect(inner(element).getAttribute('value')).toBe('two'); + }); + + it('gives each instance an id, so its tooltip can be referenced', async () => { + const first = await createCopyAttribute({value: 'a'}); + const second = await createCopyAttribute({value: 'b'}); + + expect(inner(first).id).not.toBe(''); + expect(inner(first).id).not.toBe(inner(second).id); + }); +}); diff --git a/packages/craftcms-ui/src/components/copy-button/copy-button.test.ts b/packages/craftcms-ui/src/components/copy-button/copy-button.test.ts new file mode 100644 index 00000000000..5b277e4f102 --- /dev/null +++ b/packages/craftcms-ui/src/components/copy-button/copy-button.test.ts @@ -0,0 +1,94 @@ +import {beforeEach, describe, expect, it, vi} from 'vite-plus/test'; + +import './copy-button.js'; +import type CraftCopyButton from './copy-button.js'; + +async function createCopyButton( + attrs: Record = {}, + innerHTML = 'Copy' +): Promise { + const element = document.createElement( + 'craft-copy-button' + ) as CraftCopyButton; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + return element; +} + +function button(element: CraftCopyButton): HTMLButtonElement { + return element.shadowRoot!.querySelector('button')!; +} + +/** happy-dom has no clipboard, so it is stubbed for what the component awaits. */ +function stubClipboard(writeText = vi.fn().mockResolvedValue(undefined)) { + Object.defineProperty(navigator, 'clipboard', { + value: {writeText}, + configurable: true, + writable: true, + }); + return writeText; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-copy-button', () => { + it('writes its value to the clipboard when pressed', async () => { + const writeText = stubClipboard(); + const element = await createCopyButton({value: 'https://craftcms.com'}); + + await element.copyValue(); + + expect(writeText).toHaveBeenCalledWith('https://craftcms.com'); + }); + + it('announces the copy so a page can react to it', async () => { + stubClipboard(); + const element = await createCopyButton({value: 'handle'}); + const copied = vi.fn(); + element.addEventListener('craft-copy', copied); + + await element.copyValue(); + + expect(copied).toHaveBeenCalled(); + expect(copied.mock.calls[0][0].detail.value).toBe('handle'); + }); + + it('announces a failure rather than swallowing it', async () => { + stubClipboard(vi.fn().mockRejectedValue(new Error('denied'))); + const element = await createCopyButton({value: 'handle'}); + const failed = vi.fn(); + element.addEventListener('craft-error', failed); + + await element.copyValue(); + + expect(failed).toHaveBeenCalled(); + }); + + it('does nothing while disabled', async () => { + const writeText = stubClipboard(); + const element = await createCopyButton({value: 'handle', disabled: ''}); + + await element.copyValue(); + + expect(writeText).not.toHaveBeenCalled(); + expect(button(element).disabled).toBe(true); + }); + + /** A second press mid-copy would double-fire the event and the feedback. */ + it('ignores a press while a copy is already running', async () => { + const writeText = stubClipboard(); + const element = await createCopyButton({value: 'handle'}); + + const first = element.copyValue(); + await element.copyValue(); + await first; + + expect(writeText).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/craftcms-ui/src/components/input/input.test.ts b/packages/craftcms-ui/src/components/input/input.test.ts new file mode 100644 index 00000000000..5f89f2fed23 --- /dev/null +++ b/packages/craftcms-ui/src/components/input/input.test.ts @@ -0,0 +1,119 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './input.js'; +import type CraftInput from './input.js'; + +async function createInput( + attrs: Record = {}, + innerHTML = '' +): Promise { + const element = document.createElement('craft-input') as CraftInput; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + // Lion wires the label and feedback relations after its own first update, + // so give it a turn before reading them. + await new Promise((resolve) => setTimeout(resolve, 0)); + await element.updateComplete; + return element; +} + +/** Lion's native input, which is the element the component drives. */ +function native(element: CraftInput): HTMLInputElement { + return element.querySelector('input')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-input', () => { + it('renders a native input with the label wired to it', async () => { + const element = await createInput(); + const label = element.querySelector('label')!; + + expect(native(element)).toBeTruthy(); + // Lion rewrites the label's `for` to the id it generated for the input, + // which is what makes clicking the label focus the field. + expect(label.getAttribute('for')).toBe(native(element).id); + expect(native(element).id).not.toBe(''); + }); + + it('defaults to a medium control', async () => { + expect((await createInput()).size).toBe('medium'); + }); + + it('carries the value as Lion s modelValue', async () => { + const element = await createInput(); + + element.modelValue = 'entryType'; + await element.updateComplete; + + expect(native(element).value).toBe('entryType'); + }); + + /** + * `maxlength` caps the value and is also the width hint: a four-character + * field should not stretch across the page. + */ + it('applies maxlength to the native input', async () => { + const element = await createInput({maxlength: '12'}); + await element.updateComplete; + + expect(native(element).maxLength).toBe(12); + }); + + it('reflects the width override so the stylesheet can act on it', async () => { + const element = await createInput({maxlength: '4', width: 'full'}); + + expect(element.getAttribute('width')).toBe('full'); + }); + + /** These are presentation flags the stylesheet keys off, so they reflect. */ + it('reflects its presentation flags', async () => { + const element = await createInput({ + monospace: '', + center: '', + small: '', + }); + + expect(element.monospace).toBe(true); + expect(element.center).toBe(true); + expect(element.hasAttribute('monospace')).toBe(true); + expect(element.hasAttribute('center')).toBe(true); + }); + + it('passes a type through to the native input', async () => { + const element = await createInput({type: 'email'}); + await element.updateComplete; + + expect(native(element).type).toBe('email'); + }); + + it('disables the native input', async () => { + const element = await createInput({disabled: ''}); + await element.updateComplete; + + expect(native(element).disabled).toBe(true); + }); + + /** + * `inputSize`, `min`, `max`, and `step` are properties rather than + * attributes, and are synced onto the native input after render. + */ + it('syncs the native-only properties onto the input', async () => { + const element = await createInput({type: 'number'}); + + element.min = 1; + element.max = 10; + element.step = 2; + await element.updateComplete; + + expect(native(element).getAttribute('min')).toBe('1'); + expect(native(element).getAttribute('max')).toBe('10'); + expect(native(element).getAttribute('step')).toBe('2'); + }); +}); diff --git a/packages/craftcms-ui/src/components/select/select.test.ts b/packages/craftcms-ui/src/components/select/select.test.ts new file mode 100644 index 00000000000..a6bfe4a67bf --- /dev/null +++ b/packages/craftcms-ui/src/components/select/select.test.ts @@ -0,0 +1,81 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; + +import './select.js'; +import type CraftSelect from './select.js'; + +const OPTIONS = ` + + +`; + +async function createSelect( + attrs: Record = {}, + innerHTML = OPTIONS +): Promise { + const element = document.createElement('craft-select') as CraftSelect; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 0)); + await element.updateComplete; + return element; +} + +function native(element: CraftSelect): HTMLSelectElement { + return element.querySelector('select')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-select', () => { + /** + * The native `