From 130b1a42dda8fe100ea0624baf0d0190d70f6fe5 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 9 Jul 2026 14:31:37 -0500 Subject: [PATCH 01/56] Add craft-field web component A generic form-field shell on Lion's FormControlMixin: renders the standard CP field chrome (label with required/translation indicators, instructions, tip/warning callouts, error feedback, status badge, read-only badge) around any slotted control, without owning the control's value or validation. Mirrors the server-side field wrapper, including its describedBy wiring and heading structure (heading-prefix/suffix slots included). The wrapper generator gains a "chrome components" category for shells like this one: no v-model; just an error prop bridged into the feedback slot. Co-Authored-By: Claude Fable 5 --- .../scripts/generate-vue-wrappers.js | 71 +++ .../src/components/field/field.stories.ts | 155 +++++++ .../src/components/field/field.styles.ts | 126 ++++++ .../src/components/field/field.test.ts | 402 +++++++++++++++++ .../craftcms-cp/src/components/field/field.ts | 412 ++++++++++++++++++ packages/craftcms-cp/src/index.ts | 1 + .../craftcms-cp/src/styles/form.styles.ts | 1 + 7 files changed, 1168 insertions(+) create mode 100644 packages/craftcms-cp/src/components/field/field.stories.ts create mode 100644 packages/craftcms-cp/src/components/field/field.styles.ts create mode 100644 packages/craftcms-cp/src/components/field/field.test.ts create mode 100644 packages/craftcms-cp/src/components/field/field.ts diff --git a/packages/craftcms-cp/scripts/generate-vue-wrappers.js b/packages/craftcms-cp/scripts/generate-vue-wrappers.js index 7dfe170ef6d..5923b1d7d42 100644 --- a/packages/craftcms-cp/scripts/generate-vue-wrappers.js +++ b/packages/craftcms-cp/scripts/generate-vue-wrappers.js @@ -241,6 +241,20 @@ const GROUP_COMPONENTS = [ }, ]; +/** + * Chrome-only components with no model value of their own. The wrapper only + * bridges an `error` prop into the feedback slot; everything else (including + * the wrapped control) passes through via $attrs and the default slot. + */ +const CHROME_COMPONENTS = [ + { + tagName: 'craft-field', + className: 'CraftField', + fileName: 'CraftField', + importPath: '../components/field/field', + }, +]; + // ─── Template Generators ──────────────────────────────────────────────────── function generateSlotForwards(slots) { @@ -382,6 +396,36 @@ function generateGroupWrapper(component) { `; } +function generateChromeWrapper(component) { + return ` + + + +`; +} + // ─── Declaration File Generators ──────────────────────────────────────────── /** @@ -419,6 +463,19 @@ export default _default; `; } +function generateChromeDeclaration(component) { + return `/** + * Auto-generated type declaration for ${component.fileName}.vue + * Generated by: scripts/generate-vue-wrappers.js + */ +import type {DefineComponent} from 'vue'; +declare const _default: DefineComponent<{ + error?: string | null; +}>; +export default _default; +`; +} + function generateGroupDeclaration(component) { return `/** * Auto-generated type declaration for ${component.fileName}.vue @@ -447,6 +504,8 @@ const ALL_COMPONENTS = [ ...CHECKED_COMPONENTS, // Form components (groups) ...GROUP_COMPONENTS, + // Field chrome shell + ...CHROME_COMPONENTS, // Choice inputs used inside groups (not typically needing v-model wrappers) { tagName: 'craft-radio', @@ -699,6 +758,18 @@ export default function main() { count++; } + // Generate chrome-only wrappers + for (const component of CHROME_COMPONENTS) { + const content = generateChromeWrapper(component); + const filePath = resolve(VUE_DIR, `${component.fileName}.vue`); + writeFileSync(filePath, content); + const declContent = generateChromeDeclaration(component); + const declPath = resolve(VUE_DIR, `${component.fileName}.vue.d.ts`); + writeFileSync(declPath, declContent); + console.log(` Generated: ${VUE_DIR}/${component.fileName}.vue`); + count++; + } + console.log(`\n ${count} Vue wrappers generated in ${VUE_DIR}/`); // Generate type augmentations diff --git a/packages/craftcms-cp/src/components/field/field.stories.ts b/packages/craftcms-cp/src/components/field/field.stories.ts new file mode 100644 index 00000000000..5037efaec5b --- /dev/null +++ b/packages/craftcms-cp/src/components/field/field.stories.ts @@ -0,0 +1,155 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; + +import {html} from 'lit'; +import {ifDefined} from 'lit/directives/if-defined.js'; + +import './field.js'; +import '../input/input.js'; + +const meta = { + title: 'Controls/Field', + component: 'craft-field', + args: { + label: 'Field label', + helpText: '', + required: false, + translatable: false, + fieldset: false, + readOnly: false, + disabled: false, + status: undefined, + statusLabel: undefined, + orientation: undefined, + }, + argTypes: { + orientation: { + control: 'select', + options: ['ltr', 'rtl'], + }, + status: { + control: 'select', + options: ['modified', 'outdated'], + }, + }, + render: ({ + label, + helpText, + required, + translatable, + fieldset, + readOnly, + disabled, + status, + statusLabel, + orientation, + }) => { + return html` + + `; + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const RequiredWithInstructions: Story = { + args: { + required: true, + helpText: 'Enter a value. This one is required.', + }, +}; + +export const TipAndWarning: Story = { + render: () => html` + + + You can use environment variables here. + Changing this may break existing content. + + `, +}; + +export const Errors: Story = { + render: () => html` + + +
    +
  • This field is required.
  • +
  • That doesn't look right.
  • +
+
+ `, +}; + +export const StatusBadge: Story = { + args: { + status: 'modified', + statusLabel: 'This field has been modified.', + }, +}; + +export const FieldsetMode: Story = { + render: () => html` + +
+ + +
+
+ `, +}; + +export const Translatable: Story = { + args: { + translatable: true, + }, +}; + +export const ReadOnly: Story = { + args: { + readOnly: true, + }, +}; + +export const Rtl: Story = { + args: { + orientation: 'rtl', + label: 'RTL field', + }, +}; + +export const LabelExtra: Story = { + render: () => html` + + + myFieldHandle + + `, +}; + +export const WithCraftInput: Story = { + render: () => html` + + + + `, +}; diff --git a/packages/craftcms-cp/src/components/field/field.styles.ts b/packages/craftcms-cp/src/components/field/field.styles.ts new file mode 100644 index 00000000000..baf72ad11d4 --- /dev/null +++ b/packages/craftcms-cp/src/components/field/field.styles.ts @@ -0,0 +1,126 @@ +import {css} from 'lit'; + +export default css` + :host { + position: relative; + display: block; + } + + :host([hidden]) { + display: none; + } + + /* Status badge (.field > .status-badge in the CP) */ + .status-badge { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + width: 2px; + height: 100%; + cursor: help; + z-index: 1; + border-radius: 1px; + } + + .status-badge.modified { + background-color: var(--blue-600, #2563eb); + box-shadow: 0 0 5px hsl(221deg 83% 53% / 15%); + } + + .status-badge.outdated { + background-color: var(--bg-pending, #c96a11); + box-shadow: 0 0 5px hsl(27deg 96% 61% / 15%); + } + + /* Heading (.field > .heading in the CP) */ + .heading { + position: relative; + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; + font-weight: bold; + margin-block-end: var(--c-spacing-xs, 0.25rem); + } + + .heading .flex-grow { + flex: 1 0 0; + } + + ::slotted([slot='label']) { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; + } + + .read-only-badge { + font-size: calc(11rem / 16); + font-weight: normal; + line-height: 1.45; + padding-block: 0; + padding-inline: 0.25em; + background-color: var(--gray-100, #e9ebec); + color: var(--gray-700, #3f4d5a); + border: 1px solid var(--border-hairline, hsl(211deg 20% 44% / 25%)); + border-radius: var(--c-radius-sm, 3px); + } + + /* Instructions (.field > .instructions in the CP) */ + .form-field__help-text { + display: block; + margin-block-end: var(--c-spacing-xs, 0.3125rem); + } + + .form-field__group-two .form-field__help-text { + margin-block: var(--c-spacing-xs, 0.3125rem) 0; + } + + /* Input container (.field > .input in the CP) */ + .input-group { + position: relative; + } + + .input-group.ltr { + direction: ltr; + } + + .input-group.rtl { + direction: rtl; + } + + .input-group.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .input-group__container { + display: flex; + } + + .input-group__input { + flex: 1; + display: flex; + } + + .input-group__container > .input-group__input ::slotted(.form-control) { + flex: 1 1 auto; + margin: 0; + font-size: 100%; + } + + /* Tip/warning notices (field-notice.blade.php renders these with p-0 mt-1) */ + .field-notice { + padding: 0; + margin-block-start: var(--c-spacing-xs, 0.25rem); + } + + /* Error styling hook, mirroring .field.has-errors / .input.errors */ + :host([has-errors]) ::slotted([slot='input']) { + border-color: var(--c-color-danger-border-loud); + } + + :host([has-errors]) .form-field__feedback { + color: var(--c-color-danger-on-normal); + } +`; diff --git a/packages/craftcms-cp/src/components/field/field.test.ts b/packages/craftcms-cp/src/components/field/field.test.ts new file mode 100644 index 00000000000..1896b00077f --- /dev/null +++ b/packages/craftcms-cp/src/components/field/field.test.ts @@ -0,0 +1,402 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type CraftField from './field.js'; +import './field.js'; + +async function createField( + attrs: Record = {}, + innerHTML = '' +): Promise { + const element = document.createElement('craft-field') as CraftField; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.innerHTML = innerHTML; + document.body.append(element); + await element.updateComplete; + // Wait one more cycle for aria attribute reflection onto the input. + await element.updateComplete; + return element; +} + +function input(element: CraftField): HTMLElement | null { + return element.querySelector('[slot="input"]'); +} + +function labelNode(element: CraftField): HTMLElement | null { + return element.querySelector('[slot="label"]'); +} + +function describedBy(element: CraftField): string[] { + return (input(element)?.getAttribute('aria-describedby') ?? '') + .split(/\s+/) + .filter(Boolean); +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-field label association', () => { + it('associates the generated label with the slotted input', async () => { + const element = await createField({label: 'My field'}); + + const label = labelNode(element); + const control = input(element); + expect(label).not.toBeNull(); + expect(label!.textContent).toContain('My field'); + expect(control!.id).toBeTruthy(); + expect(label!.getAttribute('for')).toBe(control!.id); + }); + + it('links the label into the input aria-labelledby', async () => { + const element = await createField({label: 'My field'}); + + const labelledBy = input(element)?.getAttribute('aria-labelledby') ?? ''; + expect(labelNode(element)!.id).toBeTruthy(); + expect(labelledBy.split(/\s+/)).toContain(labelNode(element)!.id); + }); + + it('adopts a slotted label element', async () => { + const element = await createField( + {}, + '' + ); + + expect(labelNode(element)!.getAttribute('for')).toBe(input(element)!.id); + }); +}); + +describe('craft-field required indicator', () => { + it('renders the required spans inside the label', async () => { + const element = await createField({label: 'My field', required: ''}); + + const label = labelNode(element)!; + const srOnly = label.querySelector('span.visually-hidden'); + const indicator = label.querySelector('span.required'); + expect(srOnly?.textContent).toBe('Required'); + expect(indicator).not.toBeNull(); + expect(indicator!.getAttribute('aria-hidden')).toBe('true'); + }); + + it('removes the indicator when required is unset', async () => { + const element = await createField({label: 'My field', required: ''}); + + element.required = false; + await element.updateComplete; + + expect(labelNode(element)!.querySelector('span.required')).toBeNull(); + }); + + it('does not render an indicator without a label', async () => { + const element = await createField({required: ''}); + + expect(element.querySelector('span.required')).toBeNull(); + }); +}); + +describe('craft-field translatable indicator', () => { + it('renders the translation icon button inside the label', async () => { + const element = await createField({ + label: 'My field', + translatable: '', + 'translation-description': 'Translated per site.', + }); + + const tooltip = labelNode(element)!.querySelector('craft-tooltip'); + expect(tooltip).not.toBeNull(); + expect(tooltip!.getAttribute('text')).toBe('Translated per site.'); + + const button = tooltip!.querySelector('button.t9n-indicator'); + expect(button).not.toBeNull(); + expect(button!.getAttribute('data-icon')).toBe('language'); + expect(button!.getAttribute('aria-label')).toBe('Translated per site.'); + }); +}); + +describe('craft-field fieldset mode', () => { + it('exposes group semantics on the host', async () => { + const element = await createField({label: 'My group', fieldset: ''}); + + expect(element.getAttribute('role')).toBe('group'); + expect(element.getAttribute('aria-labelledby')).toBe( + labelNode(element)!.id + ); + expect(labelNode(element)!.hasAttribute('for')).toBe(false); + }); + + it('restores the label association when fieldset is unset', async () => { + const element = await createField({label: 'My group', fieldset: ''}); + + element.fieldset = false; + await element.updateComplete; + + expect(element.hasAttribute('role')).toBe(false); + expect(element.hasAttribute('aria-labelledby')).toBe(false); + expect(labelNode(element)!.getAttribute('for')).toBe(input(element)!.id); + }); +}); + +describe('craft-field aria-describedby wiring', () => { + it('links help text, tip, warning and feedback to the input', async () => { + const element = await createField( + {label: 'My field', 'help-text': 'Some instructions'}, + '' + + 'A helpful tip' + + 'A dire warning' + + '
  • Bad value
' + ); + + const ids = describedBy(element); + const helpText = element.querySelector('[slot="help-text"]'); + const tip = element.querySelector('[slot="tip"]'); + const warning = element.querySelector('[slot="warning"]'); + const feedback = element.querySelector('[slot="feedback"]'); + + for (const node of [helpText, tip, warning, feedback]) { + expect(node?.id).toBeTruthy(); + expect(ids).toContain(node!.id); + } + }); + + it('renders tip and warning content inside callouts', async () => { + const element = await createField( + {label: 'My field'}, + '' + + 'A helpful tip' + + 'A dire warning' + ); + + const callouts = Array.from( + element.shadowRoot!.querySelectorAll('.field-notice') + ); + expect(callouts).toHaveLength(2); + + const [tip, warning] = callouts; + expect(tip!.getAttribute('variant')).toBe('info'); + expect(tip!.getAttribute('icon')).toBe('lightbulb'); + expect(tip!.querySelector('.cp-visually-hidden')?.textContent).toContain( + 'Tip:' + ); + expect(tip!.querySelector('slot[name="tip"]')).not.toBeNull(); + + expect(warning!.getAttribute('variant')).toBe('warning'); + expect(warning!.hasAttribute('icon')).toBe(false); + expect( + warning!.querySelector('.cp-visually-hidden')?.textContent + ).toContain('Warning:'); + expect(warning!.querySelector('slot[name="warning"]')).not.toBeNull(); + }); + + it('renders no callouts without tip or warning content', async () => { + const element = await createField({label: 'My field'}); + expect(element.shadowRoot!.querySelectorAll('.field-notice')).toHaveLength( + 0 + ); + }); +}); + +describe('craft-field status badge', () => { + it('renders the status badge with a visually hidden label', async () => { + const element = await createField({ + label: 'My field', + status: 'modified', + 'status-label': 'This field has been modified.', + }); + + const badge = element.shadowRoot!.querySelector('.status-badge'); + expect(badge).not.toBeNull(); + expect(badge!.classList.contains('modified')).toBe(true); + expect(badge!.getAttribute('title')).toBe('This field has been modified.'); + expect(badge!.getAttribute('aria-hidden')).toBe('true'); + expect(badge!.querySelector('.cp-visually-hidden')?.textContent).toBe( + 'This field has been modified.' + ); + }); + + it('renders no badge without a status', async () => { + const element = await createField({label: 'My field'}); + expect(element.shadowRoot!.querySelector('.status-badge')).toBeNull(); + }); +}); + +describe('craft-field error state', () => { + it('reflects has-errors when feedback content is slotted', async () => { + const element = await createField( + {label: 'My field'}, + '' + + '
  • Bad value
' + ); + + expect(element.hasAttribute('has-errors')).toBe(true); + const inputGroup = element.shadowRoot!.querySelector('.input-group'); + expect(inputGroup!.classList.contains('errors')).toBe(true); + }); + + it('supports setting has-errors manually', async () => { + const element = await createField({label: 'My field', 'has-errors': ''}); + + expect(element.hasErrors).toBe(true); + expect( + element + .shadowRoot!.querySelector('.input-group')! + .classList.contains('errors') + ).toBe(true); + }); + + it('has no error hooks by default', async () => { + const element = await createField({label: 'My field'}); + + expect(element.hasAttribute('has-errors')).toBe(false); + expect( + element + .shadowRoot!.querySelector('.input-group')! + .classList.contains('errors') + ).toBe(false); + }); +}); + +describe('craft-field orientation', () => { + it('defaults to ltr', async () => { + const element = await createField({label: 'My field'}); + const inputGroup = element.shadowRoot!.querySelector('.input-group')!; + expect(inputGroup.classList.contains('ltr')).toBe(true); + expect(inputGroup.classList.contains('rtl')).toBe(false); + }); + + it('applies the orientation attribute to the input container', async () => { + const element = await createField({label: 'My field', orientation: 'rtl'}); + expect( + element + .shadowRoot!.querySelector('.input-group')! + .classList.contains('rtl') + ).toBe(true); + }); + + it('inherits the direction from the closest dir attribute', async () => { + const wrapper = document.createElement('div'); + wrapper.setAttribute('dir', 'rtl'); + document.body.append(wrapper); + + const element = document.createElement('craft-field') as CraftField; + element.setAttribute('label', 'My field'); + element.innerHTML = ''; + wrapper.append(element); + await element.updateComplete; + + expect( + element + .shadowRoot!.querySelector('.input-group')! + .classList.contains('rtl') + ).toBe(true); + }); +}); + +describe('craft-field read-only badge', () => { + it('renders the badge in the heading when read-only', async () => { + const element = await createField({label: 'My field', readonly: ''}); + + const badge = element.shadowRoot!.querySelector( + '.heading .read-only-badge' + ); + expect(badge?.textContent).toBe('Read Only'); + }); + + it('renders no badge otherwise', async () => { + const element = await createField({label: 'My field'}); + expect(element.shadowRoot!.querySelector('.read-only-badge')).toBeNull(); + }); +}); + +describe('craft-field label extras', () => { + it('renders a flex-grow spacer before slotted label extras', async () => { + const element = await createField( + {label: 'My field'}, + 'handle' + ); + + const heading = element.shadowRoot!.querySelector('.heading')!; + const spacer = heading.querySelector('.flex-grow'); + const slot = heading.querySelector('slot[name="label-extra"]'); + expect(spacer).not.toBeNull(); + expect(slot).not.toBeNull(); + expect( + spacer!.compareDocumentPosition(slot!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('renders no spacer without label extras', async () => { + const element = await createField({label: 'My field'}); + expect(element.shadowRoot!.querySelector('.flex-grow')).toBeNull(); + }); +}); + +describe('craft-field disabled state', () => { + it('adds the disabled class to the input container only', async () => { + const element = await createField({label: 'My field', disabled: ''}); + + expect( + element + .shadowRoot!.querySelector('.input-group')! + .classList.contains('disabled') + ).toBe(true); + // The shell must not touch the slotted input's state. + expect(input(element)!.hasAttribute('disabled')).toBe(false); + expect(input(element)!.hasAttribute('aria-disabled')).toBe(false); + }); +}); + +describe('craft-field instructions position', () => { + it('renders instructions before the input by default', async () => { + const element = await createField({ + label: 'My field', + 'help-text': 'Some instructions', + }); + + expect( + element.shadowRoot!.querySelector( + '.form-field__group-one .form-field__help-text' + ) + ).not.toBeNull(); + }); + + it('renders instructions after the input when requested', async () => { + const element = await createField({ + label: 'My field', + 'help-text': 'Some instructions', + 'instructions-position': 'after', + }); + + expect( + element.shadowRoot!.querySelector( + '.form-field__group-one .form-field__help-text' + ) + ).toBeNull(); + expect( + element.shadowRoot!.querySelector( + '.form-field__group-two .form-field__help-text' + ) + ).not.toBeNull(); + }); +}); + +describe('craft-field heading prefix/suffix', () => { + it('renders heading-prefix and heading-suffix slots around the label', async () => { + const element = document.createElement('craft-field') as CraftField; + element.setAttribute('label', 'My field'); + element.innerHTML = ` + + pre + post + `; + document.body.append(element); + await element.updateComplete; + await element.updateComplete; + + const heading = element.shadowRoot!.querySelector('.heading')!; + const slotNames = Array.from(heading.querySelectorAll('slot')).map((s) => + s.getAttribute('name') + ); + expect(slotNames[0]).toBe('heading-prefix'); + expect(slotNames[slotNames.length - 1]).toBe('heading-suffix'); + }); +}); diff --git a/packages/craftcms-cp/src/components/field/field.ts b/packages/craftcms-cp/src/components/field/field.ts new file mode 100644 index 00000000000..80c667d0869 --- /dev/null +++ b/packages/craftcms-cp/src/components/field/field.ts @@ -0,0 +1,412 @@ +import {LitElement, html, nothing, type PropertyValues} from 'lit'; +import {property} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import {ifDefined} from 'lit/directives/if-defined.js'; +import {FormControlMixin} from '@lion/ui/form-core.js'; +// Registers globally for the tip/warning notices. +import '../callout/callout.js'; +import {baseFieldStyles} from '@src/styles/form.styles'; +import visuallyHiddenStyles from '@src/styles/visually-hidden.styles.js'; +import styles from './field.styles.js'; +import {t} from '@src/utilities/translate'; + +/** + * A generic form-field shell that renders the standard CP field chrome + * (label, instructions, tip/warning notices, errors and status badge) around + * any slotted control, mirroring the server-side field wrapper + * (`FormFields::fieldHtml()` / `forms/field.blade.php`). + * + * The shell never touches the slotted input's value, validation or events — + * it only provides the surrounding chrome and the label/description aria + * wiring supplied by Lion's `FormControlMixin`. + * + * @slot input - The wrapped control. Required. + * @slot label - The field label. Alternative to the `label` attribute. + * @slot help-text - The field instructions. Alternative to the `help-text` + * attribute. + * @slot feedback - Validation errors (e.g. an error list). + * @slot tip - Tip notice content, rendered inside an info callout. + * @slot warning - Warning notice content, rendered inside a warning callout. + * @slot label-extra - Extra heading content (handle-copy buttons, action + * menus), rendered after a flex-grow spacer. + */ +export default class CraftField extends FormControlMixin(LitElement) { + static override get styles() { + return [visuallyHiddenStyles, baseFieldStyles, styles]; + } + + /** Renders the required indicator inside the label. */ + @property({type: Boolean, reflect: true}) required = false; + + /** Renders the translation icon inside the label. */ + @property({type: Boolean, reflect: true}) translatable = false; + + /** Accessible description for the translation icon. */ + @property({attribute: 'translation-description'}) + translationDescription: string = t('This field is translatable.'); + + /** + * Group semantics: sets `role="group"` + `aria-labelledby` on the host + * instead of a `label[for]` association, like the server-side `fieldset` + * config option. + */ + @property({type: Boolean, reflect: true}) fieldset = false; + + /** Status badge modifier (e.g. `modified`, `outdated`). */ + @property({reflect: true}) status?: string; + + /** Human-facing label for the status badge. */ + @property({attribute: 'status-label'}) statusLabel?: string; + + /** + * Writing direction of the input container. Defaults to the closest `dir` + * attribute, falling back to the document direction. + */ + @property({reflect: true}) orientation?: 'ltr' | 'rtl'; + + /** + * Style hook mirroring the server-side `.field.has-errors` class. Kept in + * sync automatically with the presence of `feedback` slot content; can + * also be set manually when no feedback node is slotted. + */ + @property({type: Boolean, reflect: true, attribute: 'has-errors'}) + hasErrors = false; + + /** Where the instructions render relative to the input. */ + @property({attribute: 'instructions-position'}) + instructionsPosition: 'before' | 'after' = 'before'; + + private __lightDomObserver = new MutationObserver(() => + this.__onLightDomChanged() + ); + + override connectedCallback(): void { + super.connectedCallback(); + this.__lightDomObserver.observe(this, {childList: true}); + this.__syncHasErrors(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.__lightDomObserver.disconnect(); + } + + override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has('disabled')) { + // FormControlMixin reflects `aria-disabled` onto the slotted input, but + // the shell doesn't own the input (its disabled state is the input's + // own business), so undo that. + this._inputNode?.removeAttribute('aria-disabled'); + } + + if (changedProperties.has('fieldset')) { + this.__syncFieldsetSemantics(); + } + + if ( + changedProperties.has('label') || + changedProperties.has('required') || + changedProperties.has('translatable') || + changedProperties.has('translationDescription') + ) { + // FormControlMixin rewrites the label node's textContent when the label + // property changes, wiping any decorations, so re-sync afterwards. + this.__syncLabelDecorations(); + } + } + + /** + * The wrapped control. Exposed for consumers; the shell never mutates its + * value or listens to its events. + */ + get control(): HTMLElement | undefined { + return this._inputNode ?? undefined; + } + + protected get _resolvedOrientation(): 'ltr' | 'rtl' { + if (this.orientation) { + return this.orientation; + } + const dir = + (this.closest('[dir]') as HTMLElement | null)?.getAttribute('dir') ?? + document.documentElement.getAttribute('dir'); + return dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'; + } + + /** + * Links tip/warning notices (and any late-registered instructions or + * feedback nodes) into the input's `aria-describedby`, mirroring the + * server-side `describedBy` computation. + */ + protected override _enhanceLightDomA11y(): void { + super._enhanceLightDomA11y(); + this.__wireDescribedBy(); + } + + /** + * Registers aria references in insertion order instead of Lion's + * DOM-order reordering. The reorder implementation depends on + * `Element.assignedSlot`, which happy-dom doesn't populate, and insertion + * order already matches the template order here (label; help-text, + * feedback, tip, warning). + */ + override addToAriaLabelledBy( + element: HTMLElement, + customConfig: {idPrefix?: string; reorder?: boolean} = {} + ): void { + super.addToAriaLabelledBy(element, {...customConfig, reorder: false}); + } + + override addToAriaDescribedBy( + element: HTMLElement, + customConfig: {idPrefix?: string; reorder?: boolean} = {} + ): void { + super.addToAriaDescribedBy(element, {...customConfig, reorder: false}); + } + + // `readOnly` (attribute: `readonly`) is inherited from FormControlMixin + // and renders the read-only badge in the heading. + + override render() { + return html` + ${this._statusBadgeTemplate()} +
${this._groupOneTemplate()}
+
${this._groupTwoTemplate()}
+ `; + } + + protected override _groupOneTemplate() { + return html` + ${this._labelTemplate()} + ${this.instructionsPosition === 'before' + ? this._helpTextTemplate() + : nothing} + `; + } + + protected override _groupTwoTemplate() { + return html` + ${this._inputGroupTemplate()} + ${this.instructionsPosition === 'after' + ? this._helpTextTemplate() + : nothing} + ${this._noticeTemplate('tip')} ${this._noticeTemplate('warning')} + ${this._feedbackTemplate()} + `; + } + + /** + * The field heading: label, read-only badge, flex-grow spacer and label + * extras, mirroring `.field > .heading` in the Blade wrapper. + */ + protected override _labelTemplate() { + return html` +
+ + + ${this.readOnly + ? html`${t('Read Only')}` + : nothing} + ${this.__hasLightChild('label-extra') + ? html`
` + : nothing} + + +
+ `; + } + + /** + * The input container, mirroring the server-side + * `.input.{orientation}.errors.disabled` classes while keeping Lion's + * `input-group` structure. + */ + protected override _inputGroupTemplate() { + const classes = { + 'input-group': true, + input: true, + [this._resolvedOrientation]: true, + errors: this.hasErrors, + disabled: this.disabled, + }; + return html` +
+ ${this._inputGroupBeforeTemplate()} +
+ ${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()} + ${this._inputGroupSuffixTemplate()} +
+ ${this._inputGroupAfterTemplate()} +
+ `; + } + + protected _statusBadgeTemplate() { + if (!this.status) { + return nothing; + } + return html` + + `; + } + + /** + * Tip/warning notices, mirroring `forms/field-notice.blade.php`: an info + * callout with a lightbulb icon for tips, a warning callout (default icon) + * for warnings, each with a visually hidden prefix. + */ + protected _noticeTemplate(kind: 'tip' | 'warning') { + if (!this.__hasLightChild(kind)) { + return nothing; + } + const isTip = kind === 'tip'; + return html` + + ${isTip ? t('Tip:') : t('Warning:')} + + + + `; + } + + private __hasLightChild(slotName: string): boolean { + return this.__lightChild(slotName) !== undefined; + } + + private __lightChild(slotName: string): HTMLElement | undefined { + return (Array.from(this.children) as HTMLElement[]).find( + (el) => el.slot === slotName + ); + } + + private __onLightDomChanged(): void { + this.__wireDescribedBy(); + this.__syncHasErrors(); + this.__syncLabelDecorations(); + // Conditional templates (tip/warning callouts, label-extra spacer) depend + // on light DOM children. + this.requestUpdate(); + } + + private __wireDescribedBy(): void { + for (const slotName of ['help-text', 'feedback', 'tip', 'warning']) { + const node = this.__lightChild(slotName); + if (node) { + // Already-registered nodes are ignored by addToAriaDescribedBy. + this.addToAriaDescribedBy(node, {idPrefix: slotName}); + } + } + } + + private __syncHasErrors(): void { + const feedback = this.__lightChild('feedback'); + if (!feedback) { + // No feedback node: leave manual control of `has-errors` alone. + return; + } + this.hasErrors = Boolean( + feedback.childElementCount || feedback.textContent?.trim() + ); + } + + private __syncFieldsetSemantics(): void { + const labelNode = this._labelNode; + if (this.fieldset) { + this.setAttribute('role', 'group'); + if (labelNode) { + if (!labelNode.id) { + labelNode.id = `label-${this._inputId}`; + } + this.setAttribute('aria-labelledby', labelNode.id); + labelNode.removeAttribute('for'); + } + } else { + if (this.getAttribute('role') === 'group') { + this.removeAttribute('role'); + } + this.removeAttribute('aria-labelledby'); + if (labelNode && this._inputNode) { + labelNode.setAttribute('for', this._inputNode.id || this._inputId); + } + } + } + + /** + * Renders the required indicator and translation icon inside the label + * node, matching `FormFields::fieldHtml()`'s label internals. These live in + * the light DOM (like the server-rendered markup), so they pick up the CP's + * global `.visually-hidden`, `.required` and `.t9n-indicator` styles. + */ + private __syncLabelDecorations(): void { + const labelNode = this._labelNode; + if (!labelNode) { + return; + } + + for (const el of labelNode.querySelectorAll( + '[data-craft-field-decoration]' + )) { + el.remove(); + } + + if (!this.label) { + return; + } + + if (this.required) { + const srLabel = document.createElement('span'); + srLabel.className = 'visually-hidden'; + srLabel.textContent = t('Required'); + srLabel.setAttribute('data-craft-field-decoration', ''); + + const indicator = document.createElement('span'); + indicator.className = 'required'; + indicator.setAttribute('aria-hidden', 'true'); + indicator.setAttribute('data-craft-field-decoration', ''); + + labelNode.append(srLabel, indicator); + } + + if (this.translatable) { + const tooltip = document.createElement('craft-tooltip'); + tooltip.setAttribute('placement', 'bottom'); + tooltip.setAttribute('max-width', '200px'); + tooltip.setAttribute('text', this.translationDescription); + tooltip.setAttribute('delay', '1000'); + tooltip.setAttribute('data-craft-field-decoration', ''); + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 't9n-indicator prevent-autofocus'; + button.setAttribute('data-icon', 'language'); + button.setAttribute('aria-label', this.translationDescription); + + tooltip.append(button); + labelNode.append(tooltip); + } + } +} + +if (!customElements.get('craft-field')) { + customElements.define('craft-field', CraftField); +} + +declare global { + interface HTMLElementTagNameMap { + 'craft-field': CraftField; + } +} diff --git a/packages/craftcms-cp/src/index.ts b/packages/craftcms-cp/src/index.ts index 01965ccd815..73071ef272d 100644 --- a/packages/craftcms-cp/src/index.ts +++ b/packages/craftcms-cp/src/index.ts @@ -28,6 +28,7 @@ export {default as CraftDialog} from './components/dialog/dialog.js'; export {default as CraftDisclosure} from './components/disclosure/disclosure.js'; export {default as CraftDrawer} from './components/drawer/drawer.js'; export {default as CraftDropdown} from './components/dropdown/dropdown.js'; +export {default as CraftField} from './components/field/field.js'; export {default as CraftFieldGroup} from './components/field-group/field-group.js'; export {default as CraftIcon} from './components/icon/icon.js'; export {default as CraftIndicator} from './components/indicator/indicator.js'; diff --git a/packages/craftcms-cp/src/styles/form.styles.ts b/packages/craftcms-cp/src/styles/form.styles.ts index 1808c719561..695c957fab4 100644 --- a/packages/craftcms-cp/src/styles/form.styles.ts +++ b/packages/craftcms-cp/src/styles/form.styles.ts @@ -75,6 +75,7 @@ export const inputStyles = css` appearance: none; padding-inline: var(--c-input-spacing-inline); background-color: transparent; + width: 100%; } .input-group__container { From f530b26caf35eb228a3635f91e340fabdea3adb9 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 9 Jul 2026 14:31:58 -0500 Subject: [PATCH 02/56] Add CP UI component system Fluent PHP renderers for the @craftcms/cp web components, following the shape explored in #19032 so hand-written and (eventually) manifest-generated components share one API: - ViewComponent: make(), config-array configure() (with a guard rejecting getter/rendering method names), always-merge attributes(), a slot pipeline with lazy resolution, nested-component auto-slotting, default-slot child lists, encode-by-default strings (Htmlable/Twig Markup pass through), and root-element slot attachment. Components render markup directly by default; a Blade view can be opted into per component. - Filament-style callback properties: every setter accepts a Closure, resolved at render time with name/type/container injection. - Concerns (HasVariant/HasAppearance/HasSize/HasId/HasDisabled) with get*/is* evaluated getters, and CP-scoped Variant/Appearance/Size enums validating string input from templates. - Components: Field, Callout, FieldGroup, Lightswitch. - ui() helper (PHP/Blade) and CP-mode `ui` Twig function over a plugin-extensible ComponentRegistry. Co-Authored-By: Claude Fable 5 --- src/Cp/Components/Callout.php | 93 ++++++ src/Cp/Components/ComponentRegistry.php | 48 +++ src/Cp/Components/Field.php | 316 +++++++++++++++++++ src/Cp/Components/FieldGroup.php | 59 ++++ src/Cp/Components/Lightswitch.php | 275 ++++++++++++++++ src/Cp/Components/ViewComponent.php | 269 ++++++++++++++++ src/Cp/Concerns/EvaluatesClosures.php | 100 ++++++ src/Cp/Concerns/HasAppearance.php | 36 +++ src/Cp/Concerns/HasDisabled.php | 25 ++ src/Cp/Concerns/HasId.php | 30 ++ src/Cp/Concerns/HasSize.php | 36 +++ src/Cp/Concerns/HasVariant.php | 36 +++ src/Cp/Enums/Appearance.php | 18 ++ src/Cp/Enums/Size.php | 18 ++ src/Cp/Enums/Variant.php | 18 ++ src/Twig/Extensions/CpExtension.php | 1 + src/helpers.php | 11 + tests/Unit/Cp/Components/CalloutTest.php | 80 +++++ tests/Unit/Cp/Components/FieldGroupTest.php | 50 +++ tests/Unit/Cp/Components/FieldTest.php | 168 ++++++++++ tests/Unit/Cp/Components/LightswitchTest.php | 118 +++++++ tests/Unit/Cp/Components/UiTest.php | 106 +++++++ 22 files changed, 1911 insertions(+) create mode 100644 src/Cp/Components/Callout.php create mode 100644 src/Cp/Components/ComponentRegistry.php create mode 100644 src/Cp/Components/Field.php create mode 100644 src/Cp/Components/FieldGroup.php create mode 100644 src/Cp/Components/Lightswitch.php create mode 100644 src/Cp/Components/ViewComponent.php create mode 100644 src/Cp/Concerns/EvaluatesClosures.php create mode 100644 src/Cp/Concerns/HasAppearance.php create mode 100644 src/Cp/Concerns/HasDisabled.php create mode 100644 src/Cp/Concerns/HasId.php create mode 100644 src/Cp/Concerns/HasSize.php create mode 100644 src/Cp/Concerns/HasVariant.php create mode 100644 src/Cp/Enums/Appearance.php create mode 100644 src/Cp/Enums/Size.php create mode 100644 src/Cp/Enums/Variant.php create mode 100644 tests/Unit/Cp/Components/CalloutTest.php create mode 100644 tests/Unit/Cp/Components/FieldGroupTest.php create mode 100644 tests/Unit/Cp/Components/FieldTest.php create mode 100644 tests/Unit/Cp/Components/LightswitchTest.php create mode 100644 tests/Unit/Cp/Components/UiTest.php diff --git a/src/Cp/Components/Callout.php b/src/Cp/Components/Callout.php new file mode 100644 index 00000000000..04a25c60e58 --- /dev/null +++ b/src/Cp/Components/Callout.php @@ -0,0 +1,93 @@ +` web component. + * + * Callout::make() + * ->variant('warning') + * ->title(t('Heads up')) + * ->content(t('Changing this may result in data loss.')); + * + * Renders directly (no Blade view) — the component is a single element whose + * chrome lives in the web component. Content strings are HTML-encoded; pass + * an `Htmlable` for trusted markup. + */ +class Callout extends ViewComponent +{ + use HasAppearance; + use HasVariant; + + protected string|Closure|null $title = null; + + protected string|Closure|null $icon = null; + + protected string|Closure|null $rounded = null; + + protected bool|Closure $inline = false; + + protected function tagName(): string + { + return 'craft-callout'; + } + + public function title(string|Closure|null $title): static + { + $this->title = $title; + + return $this; + } + + /** Icon name; the web component falls back to a variant-specific default. */ + public function icon(string|Closure|null $icon): static + { + $this->icon = $icon; + + return $this; + } + + /** @param 'all'|'start'|'end'|'none'|Closure|null $rounded */ + public function rounded(string|Closure|null $rounded): static + { + $this->rounded = $rounded; + + return $this; + } + + public function inline(bool|Closure $inline = true): static + { + $this->inline = $inline; + + return $this; + } + + /** The callout body (default slot). */ + public function content(string|Htmlable|Stringable|ViewComponent|Closure|null $content): static + { + $this->slots[static::DEFAULT_SLOT] = $content; + + return $this; + } + + #[\Override] + protected function hostAttributes(): array + { + return [ + 'variant' => $this->getVariant(), + 'appearance' => $this->getAppearance(), + 'title' => $this->evaluate($this->title), + 'icon' => $this->evaluate($this->icon), + 'rounded' => $this->evaluate($this->rounded), + 'inline' => (bool) $this->evaluate($this->inline), + ]; + } +} diff --git a/src/Cp/Components/ComponentRegistry.php b/src/Cp/Components/ComponentRegistry.php new file mode 100644 index 00000000000..8242ee05387 --- /dev/null +++ b/src/Cp/Components/ComponentRegistry.php @@ -0,0 +1,48 @@ +> */ + private array $components = [ + 'callout' => Callout::class, + 'field' => Field::class, + 'field-group' => FieldGroup::class, + 'lightswitch' => Lightswitch::class, + ]; + + /** + * @param class-string $class + */ + public function register(string $name, string $class): void + { + if (! is_subclass_of($class, ViewComponent::class)) { + throw new InvalidArgumentException(sprintf('%s is not a %s.', $class, ViewComponent::class)); + } + + $this->components[$name] = $class; + } + + public function make(string $name, array $config = []): ViewComponent + { + $class = $this->components[$name] ?? throw new InvalidArgumentException(sprintf( + 'Unknown UI component "%s". Available: %s', + $name, + implode(', ', array_keys($this->components)), + )); + + return $class::make()->configure($config); + } +} diff --git a/src/Cp/Components/Field.php b/src/Cp/Components/Field.php new file mode 100644 index 00000000000..8817e4d1d96 --- /dev/null +++ b/src/Cp/Components/Field.php @@ -0,0 +1,316 @@ +` web component — the generic form + * field shell (label, instructions, tip/warning, errors, status) around any + * control. + * + * Field::make() + * ->label(t('Handle')) + * ->required() + * ->instructions(t('How you’ll refer to this field in the templates.')) + * ->input(fn () => FormFields::textHtml(['name' => 'handle'])) + * ->errors($model->errors()->get('handle')); + * + * Every setter accepts a literal value or a Closure evaluated at render time + * (with dependency injection — see {@see EvaluatesClosures}). + */ +class Field extends ViewComponent +{ + use HasDisabled; + use HasId; + + protected string|Htmlable|ViewComponent|Closure|null $label = null; + + protected string|Htmlable|Stringable|ViewComponent|Closure|null $input = null; + + protected string|Stringable|Closure|null $instructions = null; + + protected string|Closure $instructionsPosition = 'before'; + + protected bool|Closure $required = false; + + protected bool|Closure $translatable = false; + + protected string|Closure|null $translationDescription = null; + + protected bool|Closure $fieldset = false; + + protected string|Closure|null $status = null; + + protected string|Closure|null $statusLabel = null; + + protected string|Closure|null $orientation = null; + + protected bool|Closure $readOnly = false; + + protected string|Htmlable|ViewComponent|Closure|null $headingPrefix = null; + + protected string|Htmlable|ViewComponent|Closure|null $headingSuffix = null; + + /** @var array|Closure */ + protected array|Closure $errors = []; + + protected string|Stringable|Closure|null $tip = null; + + protected string|Stringable|Closure|null $warning = null; + + protected string|Htmlable|ViewComponent|Closure|null $labelExtra = null; + + protected function tagName(): string + { + return 'craft-field'; + } + + /** A string renders as the `label` attribute; markup or a component renders into the label slot. */ + public function label(string|Htmlable|ViewComponent|Closure|null $label): static + { + $this->label = $label; + + return $this; + } + + /** + * The wrapped control. Strings are treated as trusted HTML (matching + * `FormFields`), and should have a single root element so the `slot` + * attribute lands on the control itself rather than a wrapper. + */ + public function input(string|Htmlable|Stringable|ViewComponent|Closure|null $input): static + { + $this->input = $input; + + return $this; + } + + /** Field instructions; supports the same markdown as the Twig field macro. */ + public function instructions(string|Stringable|Closure|null $instructions): static + { + $this->instructions = $instructions; + + return $this; + } + + /** @param 'before'|'after'|Closure $position Relative to the input. */ + public function instructionsPosition(string|Closure $position): static + { + $this->instructionsPosition = $position; + + return $this; + } + + public function required(bool|Closure $required = true): static + { + $this->required = $required; + + return $this; + } + + public function translatable(bool|Closure $translatable = true, string|Closure|null $description = null): static + { + $this->translatable = $translatable; + + if ($description !== null) { + $this->translationDescription = $description; + } + + return $this; + } + + /** Renders the label as a group legend (`role="group"`) instead of a `