diff --git a/package-lock.json b/package-lock.json index 90950245c7a..dcb82006e4a 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, @@ -12042,6 +12049,13 @@ "version": "1.5.250", "license": "ISC" }, + "node_modules/element-internals-polyfill": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/element-internals-polyfill/-/element-internals-polyfill-3.0.2.tgz", + "integrity": "sha512-uB0/Qube3lkwh8SmkTnGIyUgJ9YdqVSzIoHMRCEQjAbD4Y5UzsVbch1tIxjTgUe5k3gy1U0ZMKMJ90A81lqwig==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "license": "MIT" @@ -25283,8 +25297,10 @@ "@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", + "element-internals-polyfill": "^3.0.2", "esbuild": "^0.28.0", "globby": "^16.2.0", "happy-dom": "^20.9.0", diff --git a/packages/craftcms-legacy/cp/src/js/AssetImageEditor.js b/packages/craftcms-legacy/cp/src/js/AssetImageEditor.js index e2f505e5d48..fd4b4e5a58d 100644 --- a/packages/craftcms-legacy/cp/src/js/AssetImageEditor.js +++ b/packages/craftcms-legacy/cp/src/js/AssetImageEditor.js @@ -930,15 +930,17 @@ Craft.AssetImageEditor = Garnish.Modal.extend( }); // Straighten slider — the web component (replaces the - // legacy Craft.SlideRuleInput), driven via its start/change/end events. + // legacy Craft.SlideRuleInput). The grid follows the drag gesture, and + // the image follows `input`, which fires continuously through the drag + // (`change` only fires once, on release). this.straighteningInput = document.getElementById('slide-rule'); - this.straighteningInput.addEventListener('start', () => { + this.straighteningInput.addEventListener('craft-drag-start', () => { this._showGrid(); }); - this.straighteningInput.addEventListener('change', () => { + this.straighteningInput.addEventListener('input', () => { this.straighten(this.straighteningInput); }); - this.straighteningInput.addEventListener('end', () => { + this.straighteningInput.addEventListener('craft-drag-end', () => { this._hideGrid(); this._cleanupFocalPointAfterStraighten(); }); diff --git a/packages/craftcms-legacy/cp/src/js/CraftGlobalSidebar.js b/packages/craftcms-legacy/cp/src/js/CraftGlobalSidebar.js index 509c6d745b6..372d0fbd012 100644 --- a/packages/craftcms-legacy/cp/src/js/CraftGlobalSidebar.js +++ b/packages/craftcms-legacy/cp/src/js/CraftGlobalSidebar.js @@ -7,8 +7,8 @@ class CraftGlobalSidebar extends HTMLElement { this.trigger = this.querySelector('#sidebar-trigger'); if (this.trigger) { - this.trigger.addEventListener('open', this.expand.bind(this)); - this.trigger.addEventListener('close', this.collapse.bind(this)); + this.trigger.addEventListener('craft-show', this.expand.bind(this)); + this.trigger.addEventListener('craft-hide', this.collapse.bind(this)); } } diff --git a/packages/craftcms-legacy/cpcompat/src/cp-compat.js b/packages/craftcms-legacy/cpcompat/src/cp-compat.js index e7192a873c0..71f9ec45e05 100644 --- a/packages/craftcms-legacy/cpcompat/src/cp-compat.js +++ b/packages/craftcms-legacy/cpcompat/src/cp-compat.js @@ -566,15 +566,8 @@ this.setValue(value, false); var self = this; - this.$container.on('value-change', function (event) { - if ( - event && - event.originalEvent && - event.originalEvent.detail && - typeof event.originalEvent.detail.value === 'number' - ) { - self.setValue(event.originalEvent.detail.value); - } + this.$container.on('change', function () { + self.setValue(self.$container[0].value); }); } @@ -604,11 +597,7 @@ el.removeAttribute('described-by'); } - if (this.settings.readOnly) { - el.setAttribute('read-only', ''); - } else { - el.removeAttribute('read-only'); - } + el.readonly = !!this.settings.readOnly; this.$buttons = this.$container.find('.slide-picker__segment'); diff --git a/packages/craftcms-legacy/dashboard/src/Dashboard.js b/packages/craftcms-legacy/dashboard/src/Dashboard.js index 15021687402..199329bb45c 100644 --- a/packages/craftcms-legacy/dashboard/src/Dashboard.js +++ b/packages/craftcms-legacy/dashboard/src/Dashboard.js @@ -774,8 +774,8 @@ import './dashboard.scss'; refreshColspanPicker(); - this.colspanPicker.addEventListener('value-change', ({detail}) => { - const colspan = detail.value; + this.colspanPicker.addEventListener('change', () => { + const colspan = this.colspanPicker.value; // Update the widget and grid this.setColspan(colspan); diff --git a/packages/craftcms-ui/.storybook/preview.ts b/packages/craftcms-ui/.storybook/preview.ts index 253dd644efa..0558650355a 100644 --- a/packages/craftcms-ui/.storybook/preview.ts +++ b/packages/craftcms-ui/.storybook/preview.ts @@ -50,9 +50,29 @@ 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', + // Everything else sorts alphabetically beneath these. Getting Started + // leads because a first-time reader lands on the sidebar, and Tokens + // trails because it explains what the component pages reference. + order: [ + 'Getting Started', + ['Introduction', 'Installation', 'Theming'], + 'Components', + 'Form Controls', + ['Choice Controls', 'Select Controls', 'Text Controls'], + 'JavaScript API', + ['Factory', 'Utilities', 'Services', 'Reactive Controllers'], + 'Tokens', + ], }, }, diff --git a/packages/craftcms-ui/custom-elements-manifest.config.mjs b/packages/craftcms-ui/custom-elements-manifest.config.mjs index e2767bf8ccc..b1c577e5a8d 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', @@ -73,6 +110,62 @@ export default { }); }, }, + // The manifest is the package's public API description: Storybook's + // property tables and editors' IntelliSense are generated from it. The + // analyzer records every class member it finds, so without this the tables + // list internals — `craft-permission-tree` advertised a `#treeId`, and 45 + // elements between them exposed 408 members no consumer can call. + // + // Dropped: TypeScript `private`/`protected`, JS `#private` fields, and the + // `_`/`__` prefixes Lion and this package use for the same thing. Anything + // meant to be public and prefixed should be renamed rather than exempted. + { + name: 'drop-internal-members', + packageLinkPhase({customElementsManifest}) { + const isInternal = (member) => + member.privacy === 'private' || + member.privacy === 'protected' || + member.name?.startsWith('#') || + member.name?.startsWith('_'); + + customElementsManifest?.modules?.forEach((module) => { + module?.declarations?.forEach((declaration) => { + if (declaration.members) { + declaration.members = declaration.members.filter( + (member) => !isInternal(member) + ); + } + }); + }); + }, + }, + + // The analyzer also infers events from `dispatchEvent()` calls, reading the + // first argument literally — so a helper that dispatches + // `new CustomEvent(type, …)` is recorded as an event named `type`. That is + // an artifact of the call site, not API, and it reaches the docs tables. + // + // Once a class declares its events with `@fires`, that list is taken as the + // whole story and inferred extras are dropped. A class with no `@fires` is + // left alone, so nothing is hidden from the guard that requires every + // remaining event to be described. + { + name: 'declared-events-win', + packageLinkPhase({customElementsManifest}) { + customElementsManifest?.modules?.forEach((module) => { + module?.declarations?.forEach((declaration) => { + const events = declaration.events; + + if (!events?.some((event) => event.description)) { + return; + } + + declaration.events = events.filter((event) => event.description); + }); + }); + }, + }, + // Strip any leaked TS AST nodes so the analyzer can serialize the manifest. // analyzer@0.11.0 (under Node 24) leaks `SourceFile` nodes into the manifest, // and their circular `parent` pointers crash the final `JSON.stringify`. Runs diff --git a/packages/craftcms-ui/package.json b/packages/craftcms-ui/package.json index e20b513aa60..d2cd520ce98 100644 --- a/packages/craftcms-ui/package.json +++ b/packages/craftcms-ui/package.json @@ -15,6 +15,7 @@ "web-components" ], "scripts": { + "pretest": "npm run build:manifest", "test": "vp test run", "test:dev": "vp test", "test:coverage": "vp test run --coverage", @@ -26,7 +27,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", @@ -99,8 +102,10 @@ "@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", + "element-internals-polyfill": "^3.0.2", "esbuild": "^0.28.0", "globby": "^16.2.0", "happy-dom": "^20.9.0", 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..9ad5e2dec74 --- /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 `'; + + // The target has to be in the document before the disclosure connects: the + // component looks it up by id while setting external mode up. + document.body.append(target); + document.body.append(element); + await element.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 0)); + await element.updateComplete; + return {element, target}; +} + +function trigger(element: CraftDisclosure): HTMLButtonElement { + return element.querySelector('button')!; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-disclosure', () => { + /** + * With no `state` in the markup the disclosure opens, whatever the target's + * own `data-state` said — the component owns the state rather than adopting + * what it finds. + */ + it('defaults to expanded', async () => { + const {element, target} = await createExternal(); + + expect(element.state).toBe('expanded'); + expect(target.dataset.state).toBe('expanded'); + expect(trigger(element).getAttribute('aria-expanded')).toBe('true'); + }); + + /** `state` is reflected, so a consumer can start it closed from markup. */ + it('honours a collapsed state set on the host', async () => { + const {element, target} = await createExternal({state: 'collapsed'}); + + expect(target.dataset.state).toBe('collapsed'); + expect(trigger(element).getAttribute('aria-expanded')).toBe('false'); + }); + + /** The consumer supplies the CSS; the component only flips `data-state`. */ + it('collapses the target it controls', async () => { + const {element, target} = await createExternal(); + + trigger(element).click(); + await element.updateComplete; + + expect(target.dataset.state).toBe('collapsed'); + expect(trigger(element).getAttribute('aria-expanded')).toBe('false'); + }); + + it('expands again', async () => { + const {element, target} = await createExternal(); + + trigger(element).click(); + await element.updateComplete; + trigger(element).click(); + await element.updateComplete; + + expect(target.dataset.state).toBe('expanded'); + expect(trigger(element).getAttribute('aria-expanded')).toBe('true'); + }); + + it('fires close and open as it toggles', async () => { + const {element} = await createExternal(); + const seen: string[] = []; + element.addEventListener('craft-show', () => seen.push('open')); + element.addEventListener('craft-hide', () => seen.push('close')); + + trigger(element).click(); + await element.updateComplete; + trigger(element).click(); + await element.updateComplete; + + expect(seen).toEqual(['close', 'open']); + }); +}); diff --git a/packages/craftcms-ui/src/components/disclosure/disclosure.ts b/packages/craftcms-ui/src/components/disclosure/disclosure.ts index 2e2aa95af73..52fe4c6a312 100644 --- a/packages/craftcms-ui/src/components/disclosure/disclosure.ts +++ b/packages/craftcms-ui/src/components/disclosure/disclosure.ts @@ -5,7 +5,7 @@ import type {WindowWithCraft} from '@src/types/globals'; import '../button/button.js'; /** - * A disclosure trigger supporting two modes: + * @summary A trigger that shows and hides content, in either of two modes: * * **Slotted (collapsible) mode** — Lion's collapsible convention: a * `slot="invoker"` trigger and `slot="content"` collapsible content. Without a @@ -20,6 +20,9 @@ import '../button/button.js'; * persists the state to a cookie (`cookie-name`). This is the contract of the * legacy `CraftDisclosure` element and `_includes/disclosure-toggle.twig`. * The mode is chosen automatically when such a button is present. + * + * @fires craft-show - The content was expanded. + * @fires craft-hide - The content was collapsed. */ export default class CraftDisclosure extends LionCollapsible { static override get styles() { @@ -157,7 +160,7 @@ export default class CraftDisclosure extends LionCollapsible { private __handleExternalOpen() { this.__externalExpanded = true; this.__externalTrigger?.setAttribute('aria-expanded', 'true'); - this.dispatchEvent(new CustomEvent('open')); + this.dispatchEvent(new CustomEvent('craft-show')); const target = this.__externalTarget; if (target) { @@ -170,7 +173,7 @@ export default class CraftDisclosure extends LionCollapsible { private __handleExternalClose() { this.__externalExpanded = false; this.__externalTrigger?.setAttribute('aria-expanded', 'false'); - this.dispatchEvent(new CustomEvent('close')); + this.dispatchEvent(new CustomEvent('craft-hide')); const target = this.__externalTarget; if (target) { diff --git a/packages/craftcms-ui/src/components/docs-references.test.ts b/packages/craftcms-ui/src/components/docs-references.test.ts new file mode 100644 index 00000000000..9e28b1c6e58 --- /dev/null +++ b/packages/craftcms-ui/src/components/docs-references.test.ts @@ -0,0 +1,152 @@ +import {existsSync, globSync, readFileSync, readdirSync} from 'node:fs'; +import {join} from 'node:path'; +import {describe, expect, it} from 'vite-plus/test'; + +/** + * A docs page references its stories by name — ``. + * Rename or drop the story and MDX resolves `of` to `undefined`, which does not + * fail the build: Storybook renders the whole page as an error instead, so the + * component silently loses its documentation until someone opens it. + * + * Cross-check every reference against the story file's actual exports. + */ + +const COMPONENTS = join(import.meta.dirname, '.'); +const SRC = join(import.meta.dirname, '..'); + +/** `?path=/docs/--docs` — how the pages link to each other. */ +const CROSS_LINK = /\?path=\/docs\/([a-z0-9-]+)--docs/g; + +/** `Stories.Foo` — how the MDX pages address their stories. */ +const REFERENCE = /\b[A-Za-z]+Stories\.([A-Za-z][A-Za-z0-9]*)/g; +const EXPORT = /^export const ([A-Za-z][A-Za-z0-9]*)\s*:/gm; + +function matchAll(source: string, pattern: RegExp): string[] { + return [...source.matchAll(pattern)].map((match) => match[1]!); +} + +/** Every `[mdx, stories]` pair sitting in a component directory. */ +function docPages(): Array<{component: string; mdx: string; stories: string}> { + const pages: Array<{component: string; mdx: string; stories: string}> = []; + + for (const component of readdirSync(COMPONENTS, {withFileTypes: true})) { + if (!component.isDirectory()) { + continue; + } + + const dir = join(COMPONENTS, component.name); + const files = readdirSync(dir); + const mdx = files.find((file) => file.endsWith('.mdx')); + const stories = files.find((file) => file.endsWith('.stories.ts')); + + if (mdx && stories) { + pages.push({ + component: component.name, + mdx: join(dir, mdx), + stories: join(dir, stories), + }); + } + } + + return pages; +} + +const pages = docPages(); + +/** + * Storybook's own id derivation: lowercase, non-alphanumerics collapsed to + * dashes. Renaming a story group changes every id under it, so a cross-link + * written before the move silently points at nothing. + */ +function docsId(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Every docs id the package declares — from story titles anywhere under `src`, + * plus MDX pages that stand alone with their own ``. + */ +const knownIds = new Set( + [ + ...globSync(join(SRC, '**/*.stories.ts')).flatMap((file) => { + const title = /title:\s*'([^']+)'/.exec(readFileSync(file, 'utf8'))?.[1]; + return title ? [docsId(title)] : []; + }), + ...globSync(join(SRC, '**/*.mdx')).flatMap((file) => { + const title = / { + /** A guard that silently matches nothing would be worse than none at all. */ + it('finds the documented components', () => { + expect(pages.length).toBeGreaterThan(0); + }); + + it.each(pages)('$component', ({mdx, stories}) => { + const referenced = new Set(matchAll(readFileSync(mdx, 'utf8'), REFERENCE)); + const exported = new Set(matchAll(readFileSync(stories, 'utf8'), EXPORT)); + + const missing = [...referenced].filter((name) => !exported.has(name)); + + expect(missing).toEqual([]); + }); +}); + +describe('docs pages import stories that exist', () => { + /** + * An MDX page importing a stories file that is not there fails the Storybook + * build rather than degrading — but the build takes a minute and this takes + * milliseconds. + */ + it.each( + globSync(join(COMPONENTS, '*', '*.mdx')).map((mdx) => ({ + component: mdx.split('/').at(-2)!, + mdx, + })) + )('$component', ({mdx}) => { + const imported = /from '\.\/([a-z-]+\.stories)'/.exec( + readFileSync(mdx, 'utf8') + )?.[1]; + + if (!imported) { + return; + } + + expect( + existsSync(join(mdx, '..', `${imported}.ts`)), + `${mdx} imports ${imported}, which does not exist` + ).toBe(true); + }); +}); + +/** + * Every MDX page in the package, not just the ones sitting beside a story + * file — the Getting Started and Tokens pages carry cross-links too, and a + * dead one there is the first thing a new reader would hit. + */ +const allPages = globSync(join(SRC, '**/*.mdx')).map((mdx) => ({ + page: mdx.slice(SRC.length + 1), + mdx, +})); + +describe('docs pages link to pages that exist', () => { + it('finds every page in the package', () => { + expect(allPages.length).toBeGreaterThan(pages.length); + }); + + it.each(allPages)('$page', ({mdx}) => { + const linked = matchAll(readFileSync(mdx, 'utf8'), CROSS_LINK); + const dead = linked.filter((id) => !knownIds.has(id)); + + expect(dead).toEqual([]); + }); +}); diff --git a/packages/craftcms-ui/src/components/empty/Empty.mdx b/packages/craftcms-ui/src/components/empty/Empty.mdx new file mode 100644 index 00000000000..26ab3de1151 --- /dev/null +++ b/packages/craftcms-ui/src/components/empty/Empty.mdx @@ -0,0 +1,52 @@ +import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs/blocks'; +import * as EmptyStories from './empty.stories'; + + + +# Empty + +`` fills a space that has nothing in it yet. + + + +Use it wherever a list, index, or panel can come up with no results — a place that would otherwise be blank, which reads as broken rather than as empty. + +## Writing the Message + +An empty state has one job: tell a person what is missing and what to do about it. + + + +Say what is absent rather than that something is. "No entries yet" is a fact about the section; "Nothing to show" is a fact about the screen, and a person cannot act on it. + +Then give them the action: + + + +The default slot sits below the message and is where that action goes. + +## Distinguishing Empty From Filtered + +The two look the same and mean opposite things. A section with no entries needs "Create one"; a search with no matches needs "Clear the filters". + +Getting this wrong sends someone to create something they already have. + +## Graphics and Custom Content + +`icon` puts artwork above the message, and the `graphic` slot replaces it with your own: + + + +The `content` slot replaces the message region entirely, for a state needing more than a line: + + + +## Accessibility + +The graphic is decoration — the message carries the meaning, so artwork that repeats it should not be announced twice. + +When an empty state appears in response to something a person did, such as a filter returning nothing, the change needs announcing. The component does not do that: it renders in place and says nothing about arriving, so the code that swaps it in is responsible. + +## Properties + + diff --git a/packages/craftcms-ui/src/components/empty/empty.stories.ts b/packages/craftcms-ui/src/components/empty/empty.stories.ts index d3b4e234b6a..648b2d6d718 100644 --- a/packages/craftcms-ui/src/components/empty/empty.stories.ts +++ b/packages/craftcms-ui/src/components/empty/empty.stories.ts @@ -1,96 +1,75 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; -import {html, nothing} from 'lit'; +import {html} from 'lit'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; -import type CraftEmpty from './empty.js'; import './empty.js'; +import type CraftEmpty from './empty.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 `empty.ts` surfaces it here without touching this file. + */ +const {args, argTypes, template} = + getStorybookHelpers('craft-empty'); + +type EmptyArgs = CraftEmpty & typeof args; + const meta = { title: 'Components/Empty', component: 'craft-empty', - args: { - label: 'Nothing yet.', - icon: 'magnifying-glass', - }, - argTypes: { - label: { - control: {type: 'text'}, - }, - icon: { - control: {type: 'text'}, - description: 'Icon name rendered above the label', - }, - }, - render: ({label, icon}) => { - return html` - - `; - }, -} satisfies Meta; + args: {...args, label: 'Nothing yet.', icon: 'magnifying-glass'}, + 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; +type Story = StoryObj; -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -export const Default: Story = { - args: {}, -}; +/** An icon above a label, which is the whole component. */ +export const Default: Story = {}; +/** Drop the `icon` for a quieter state. */ export const LabelOnly: Story = { - args: { - icon: '', - }, + args: {icon: ''}, }; +/** The default slot holds whatever gets the reader out of the empty state. */ export const WithAction: Story = { - render: ({label, icon}) => { - return html` - - New entry - - `; + args: { + 'default-slot': 'New entry', }, }; +/** The `content` slot replaces the label when it needs more than one line. */ export const CustomContent: Story = { - render: ({icon}) => { - return html` - -
-

- No entries match your search. -

-

Try a different keyword, or clear the filters.

-
- Clear filters -
- `; + args: { + label: '', + 'content-slot': ` +

+ No entries match your search. +

+

Try a different keyword, or clear the filters.

+ `, + 'default-slot': + 'Clear filters', }, }; +/** The `graphic` slot replaces the icon with artwork of your own. */ export const CustomGraphic: Story = { - render: ({label}) => { - return html` - - - - - - `; + args: { + icon: '', + 'graphic-slot': ` + + + + `, }, }; 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/empty/empty.ts b/packages/craftcms-ui/src/components/empty/empty.ts index 352ec9eb578..0653adba15b 100644 --- a/packages/craftcms-ui/src/components/empty/empty.ts +++ b/packages/craftcms-ui/src/components/empty/empty.ts @@ -3,6 +3,17 @@ import {property} from 'lit/decorators.js'; import '../icon/icon'; +/** + * @summary An empty state: a centred message, an optional graphic, and room + * for whatever the person should do next. + * + * Shown where a list, an index, or a panel has nothing in it — a place that + * would otherwise be blank, which reads as broken rather than as empty. + * + * @slot - Content shown after the message, usually the action to take. + * @slot graphic - Artwork shown above the message, replacing the `icon`. + * @slot content - The message region, replacing the `label`. + */ export default class CraftEmpty extends LitElement { static override styles = [ css` @@ -31,7 +42,10 @@ export default class CraftEmpty extends LitElement { `, ]; + /** The message. Say what is missing, not that something is missing. */ @property() label: string = ''; + + /** Name of an icon shown above the message. */ @property() icon: string = ''; protected override render() { diff --git a/packages/craftcms-ui/src/components/field-group/FieldGroup.mdx b/packages/craftcms-ui/src/components/field-group/FieldGroup.mdx new file mode 100644 index 00000000000..4aadbb88dcd --- /dev/null +++ b/packages/craftcms-ui/src/components/field-group/FieldGroup.mdx @@ -0,0 +1,36 @@ +import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs/blocks'; +import * as FieldGroupStories from './field-group.stories'; + + + +# Field Group + +`` lays [Field](?path=/docs/form-controls-field--docs)s out on a twelve-column grid. + + + +Children take a full row by default, which is the right default for a form: full-width fields are the easiest to scan down. + +## Widths + +A `width-*` class on a child gives it a fraction of the row: + + + +The available fractions are `width-25`, `width-33`, `width-50`, `width-66`, and `width-75` — quarters, thirds, and halves of the twelve columns. + +## Container Queries, Not Media Queries + +The breakpoints respond to the width of the group itself, not the viewport. + +That means a group inside a narrow pane stacks even on a wide screen: + + + +This is what you want for a component that has no idea where it will be placed — a form in a slideout, a modal, or a narrow sidebar gets a sensible layout without anyone configuring it. + +Between roughly 25rem and 50rem, `width-25` widens to a half rather than collapsing straight to full, so a row of four short fields becomes two rows of two. + +## Properties + + diff --git a/packages/craftcms-ui/src/components/field-group/field-group.stories.ts b/packages/craftcms-ui/src/components/field-group/field-group.stories.ts new file mode 100644 index 00000000000..2c287f97a85 --- /dev/null +++ b/packages/craftcms-ui/src/components/field-group/field-group.stories.ts @@ -0,0 +1,69 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; + +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; + +import {html} from 'lit'; + +import './field-group.js'; +import type CraftFieldGroup from './field-group.js'; +import '../field/field.js'; +import '../input/input.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 `field-group.ts` surfaces it here without touching this file. + */ +const {args, argTypes} = + getStorybookHelpers('craft-field-group'); + +type CraftFieldGroupArgs = CraftFieldGroup & typeof args; + +const meta = { + title: 'Form Controls/Field Group', + component: 'craft-field-group', + args, + argTypes, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const field = (label: string, widthClass = '') => html` + + + + +`; + +/** Fields take a full row unless told otherwise. */ +export const Default: Story = { + render: () => html` + ${field('Title')} ${field('Slug')} + `, +}; + +/** A `width-*` class gives a field a fraction of the row. */ +export const Widths: Story = { + render: () => html` + + ${field('Title', 'width-50')} ${field('Slug', 'width-50')} + ${field('Author', 'width-33')} ${field('Post Date', 'width-33')} + ${field('Expiry Date', 'width-33')} + + `, +}; + +/** + * The breakpoints are container queries, so the same group stacks inside a + * narrow container even on a wide screen. + */ +export const NarrowContainer: Story = { + render: () => html` +
+ + ${field('Title', 'width-50')} ${field('Slug', 'width-50')} + +
+ `, +}; diff --git a/packages/craftcms-ui/src/components/field-group/field-group.ts b/packages/craftcms-ui/src/components/field-group/field-group.ts index ac6480aa885..9a6ea04d9c0 100644 --- a/packages/craftcms-ui/src/components/field-group/field-group.ts +++ b/packages/craftcms-ui/src/components/field-group/field-group.ts @@ -1,5 +1,17 @@ import {html, LitElement} from 'lit'; +/** + * @summary Lays fields out on a twelve-column grid that collapses as its + * container narrows. Children take a full row by default, and a `width-*` + * class gives them a fraction of one. + * + * The breakpoints are container queries rather than media queries, so a group + * inside a narrow pane stacks even on a wide screen — the layout follows the + * space the fields actually have. + * + * @slot - The fields to lay out. Add `width-25`, `width-33`, `width-50`, + * `width-66`, or `width-75` to a child to give it a fraction of the row. + */ export default class CraftFieldGroup extends LitElement { protected override render() { return html` diff --git a/packages/craftcms-ui/src/components/field/Field.mdx b/packages/craftcms-ui/src/components/field/Field.mdx new file mode 100644 index 00000000000..0710acfed21 --- /dev/null +++ b/packages/craftcms-ui/src/components/field/Field.mdx @@ -0,0 +1,80 @@ +import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs/blocks'; +import * as FieldStories from './field.stories'; + + + +# Field + +`` is the shell that puts Craft's standard field chrome around a control. + + + +Label, instructions, tips, warnings, errors, and the status badge all come from the field. The control it wraps supplies only itself. + +## What It Does Not Do + +The shell never touches the slotted control's value, validation, or events. It provides the chrome and the label/description ARIA wiring, and nothing else. + +That is what makes it general: any control can go in the `input` slot — a Craft component, a Lion control, or a plain `` — and get the same field treatment without the field knowing anything about it. + + + +## Instructions, Tips, and Warnings + +Three different things, rendered differently on purpose: + +- **`help-text`** — how to fill the field in. Always visible, below the label. +- **`tip`** — supplementary advice, in an info callout. +- **`warning`** — a consequence worth knowing before changing the value, in a warning callout. + + + + + +Reach for the plainest one that fits. A page where every field carries a warning has no warnings. + +## Errors + + + +Errors render in the `feedback` slot and are wired to the control with `aria-describedby`, so they are announced when the control takes focus rather than only being visible beside it. + +## Status and Translation + + + + + +The status badge marks a field whose value is outdated or overridden. The translation indicator marks a field whose value is per-site, with `translation-description` explaining the scope. + +## Fieldsets + + + +`fieldset` renders the shell as a `
` with a `` instead of a label bound to one control. + +Use it when the field holds several controls that share one question — a group of checkboxes, a date and time pair. A `