From ac655d5fd447b343b8574db344cba91364ac4c20 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 10 Sep 2026 14:32:45 -0500 Subject: [PATCH 01/13] Add HasSlotController for tracking slot content A reactive controller, modeled on Web Awesome's, that reports whether a host's slots have content so it can skip rendering empty slot wrappers. It watches the light DOM rather than relying on slotchange, since a slot that isn't rendered never fires one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bBBVYBkMG1tj5rwTSPmX7 --- .../craftcms-ui/src/utilities/slot.test.ts | 118 ++++++++++++++++++ packages/craftcms-ui/src/utilities/slot.ts | 104 +++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 packages/craftcms-ui/src/utilities/slot.test.ts create mode 100644 packages/craftcms-ui/src/utilities/slot.ts diff --git a/packages/craftcms-ui/src/utilities/slot.test.ts b/packages/craftcms-ui/src/utilities/slot.test.ts new file mode 100644 index 00000000000..97551f5e58b --- /dev/null +++ b/packages/craftcms-ui/src/utilities/slot.test.ts @@ -0,0 +1,118 @@ +import {LitElement, html} from 'lit'; +import {afterEach, describe, expect, it} from 'vite-plus/test'; +import {HasSlotController} from './slot'; + +class SlotHost extends LitElement { + readonly hasSlot = new HasSlotController( + this, + HasSlotController.DEFAULT_SLOT, + 'footer' + ); + + renderCount = 0; + + override render() { + this.renderCount++; + return html``; + } +} + +customElements.define('test-slot-host', SlotHost); + +async function createHost(innerHTML = ''): Promise { + const host = document.createElement('test-slot-host') as SlotHost; + host.innerHTML = innerHTML; + document.body.append(host); + await host.updateComplete; + return host; +} + +/** Lets the `MutationObserver` deliver, then the host re-render. */ +async function settle(host: SlotHost): Promise { + await new Promise((resolve) => setTimeout(resolve)); + await host.updateComplete; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('HasSlotController', () => { + it('detects a named slot', async () => { + const host = await createHost('
Footer
'); + expect(host.hasSlot.test('footer')).toBe(true); + }); + + it('reports an empty named slot', async () => { + const host = await createHost('
Other
'); + expect(host.hasSlot.test('footer')).toBe(false); + }); + + it('only counts direct children', async () => { + const host = await createHost( + '
Nested
' + ); + expect(host.hasSlot.test('footer')).toBe(false); + }); + + it('detects default slot text and elements', async () => { + expect((await createHost('Text')).hasSlot.test('[default]')).toBe(true); + expect((await createHost('

Text

')).hasSlot.test('[default]')).toBe( + true + ); + }); + + it('ignores whitespace and named slot content for the default slot', async () => { + const host = await createHost('
Footer
'); + expect(host.hasSlot.test(HasSlotController.DEFAULT_SLOT)).toBe(false); + }); + + it('re-renders when a tracked slot gains or loses content', async () => { + const host = await createHost(); + const rendersBefore = host.renderCount; + + const footer = document.createElement('div'); + footer.slot = 'footer'; + host.append(footer); + await settle(host); + expect(host.hasSlot.test('footer')).toBe(true); + expect(host.renderCount).toBe(rendersBefore + 1); + + footer.remove(); + await settle(host); + expect(host.hasSlot.test('footer')).toBe(false); + expect(host.renderCount).toBe(rendersBefore + 2); + }); + + it('re-renders when a child is re-slotted in place', async () => { + const host = await createHost('
Footer
'); + const rendersBefore = host.renderCount; + + host.querySelector('div')!.slot = 'footer'; + await settle(host); + + expect(host.renderCount).toBe(rendersBefore + 1); + }); + + it('does not re-render when presence is unchanged', async () => { + const host = await createHost('
Footer
'); + const rendersBefore = host.renderCount; + + host.querySelector('div')!.textContent = 'Updated'; + host.append(Object.assign(document.createElement('div'), {slot: 'other'})); + await settle(host); + + expect(host.renderCount).toBe(rendersBefore); + }); + + it('stops observing once disconnected', async () => { + const host = await createHost(); + host.remove(); + const rendersBefore = host.renderCount; + + host.append(Object.assign(document.createElement('div'), {slot: 'footer'})); + await settle(host); + + expect(host.renderCount).toBe(rendersBefore); + }); +}); diff --git a/packages/craftcms-ui/src/utilities/slot.ts b/packages/craftcms-ui/src/utilities/slot.ts new file mode 100644 index 00000000000..cee0165126c --- /dev/null +++ b/packages/craftcms-ui/src/utilities/slot.ts @@ -0,0 +1,104 @@ +import type {ReactiveController, ReactiveControllerHost} from 'lit'; + +/** + * A reactive controller that tracks whether a host's slots have content, so + * the host can skip rendering a slot's wrapper when nothing is slotted into + * it. Modeled on Web Awesome's `HasSlotController`. + * + * Presence is read from the host's direct light-DOM children, and a light-DOM + * `MutationObserver` re-renders the host when a tracked slot gains or loses + * content. `slotchange` alone isn't enough: a slot that isn't rendered never + * fires one, so it could never come back. + * + * @example + * ```ts + * private readonly hasSlot = new HasSlotController(this, 'footer'); + * + * render() { + * return this.hasSlot.test('footer') + * ? html`
` + * : nothing; + * } + * ``` + */ +export class HasSlotController implements ReactiveController { + /** Pass as a slot name to track the default (unnamed) slot. */ + static readonly DEFAULT_SLOT = '[default]'; + + private readonly slotNames: string[]; + + private observer?: MutationObserver; + + private presence: boolean[] = []; + + constructor( + private readonly host: ReactiveControllerHost & HTMLElement, + ...slotNames: string[] + ) { + this.slotNames = slotNames; + host.addController(this); + } + + /** + * Whether anything is assigned to the given slot. Pass + * `HasSlotController.DEFAULT_SLOT` to test the default slot. + */ + test(slotName: string): boolean { + return slotName === HasSlotController.DEFAULT_SLOT + ? this.hasDefaultSlot() + : this.hasNamedSlot(slotName); + } + + hostConnected(): void { + this.presence = this.snapshot(); + // Created here rather than in the constructor: hosts are also + // constructed during SSR, where `MutationObserver` doesn't exist. + this.observer ??= new MutationObserver(this.handleMutations); + this.observer.observe(this.host, { + childList: true, + // Children re-slotted in place (a changed `slot` attribute). + subtree: true, + attributes: true, + attributeFilter: ['slot'], + // Default-slot presence depends on direct text nodes' content. + characterData: this.slotNames.includes(HasSlotController.DEFAULT_SLOT), + }); + } + + hostDisconnected(): void { + this.observer?.disconnect(); + } + + private hasDefaultSlot(): boolean { + return Array.from(this.host.childNodes).some((node) => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent!.trim() !== ''; + } + + return node.nodeType === Node.ELEMENT_NODE && !(node as Element).slot; + }); + } + + private hasNamedSlot(slotName: string): boolean { + return Array.from(this.host.children).some( + (child) => child.slot === slotName + ); + } + + private snapshot(): boolean[] { + return this.slotNames.map((slotName) => this.test(slotName)); + } + + /** + * Re-renders only when a tracked slot's presence actually flips, so + * unrelated light-DOM churn (including deep subtree mutations) is cheap. + */ + private handleMutations = (): void => { + const presence = this.snapshot(); + + if (presence.some((hasSlot, index) => hasSlot !== this.presence[index])) { + this.presence = presence; + this.host.requestUpdate(); + } + }; +} From b483760dbf4411a85c710e9f48f60b58c3d15ad2 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 10 Sep 2026 14:32:46 -0500 Subject: [PATCH 02/13] Skip empty label and help text in craft-field Don't render .form-field__label when the heading has nothing to show, or .form-field__help-text without instructions. Lion always generates the light-DOM label and help-text nodes, so presence is judged by their text. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bBBVYBkMG1tj5rwTSPmX7 --- .../src/components/field/field.test.ts | 87 +++++++++++++++++++ .../craftcms-ui/src/components/field/field.ts | 59 +++++++++++-- 2 files changed, 137 insertions(+), 9 deletions(-) diff --git a/packages/craftcms-ui/src/components/field/field.test.ts b/packages/craftcms-ui/src/components/field/field.test.ts index d92de8422c8..337a8bc77ff 100644 --- a/packages/craftcms-ui/src/components/field/field.test.ts +++ b/packages/craftcms-ui/src/components/field/field.test.ts @@ -471,6 +471,93 @@ describe('craft-field instructions position', () => { }); }); +describe('craft-field empty label and instructions', () => { + function heading(element: CraftField): Element | null { + return element.shadowRoot!.querySelector('.form-field__label'); + } + + function helpText(element: CraftField): Element | null { + return element.shadowRoot!.querySelector('.form-field__help-text'); + } + + it('renders no heading without a label', async () => { + const element = await createField(); + expect(heading(element)).toBeNull(); + }); + + it('renders no instructions without help text', async () => { + const element = await createField({label: 'My field'}); + expect(helpText(element)).toBeNull(); + }); + + it('renders a slotted label and instructions', async () => { + const element = await createField( + {}, + '
Custom instructions
' + ); + + expect( + heading(element)!.querySelector('slot[name="label"]') + ).not.toBeNull(); + expect(helpText(element)).not.toBeNull(); + }); + + it('renders the heading and instructions once they are set', async () => { + const element = await createField(); + + element.label = 'My field'; + element.helpText = 'Some instructions'; + await element.updateComplete; + + expect(heading(element)).not.toBeNull(); + expect(helpText(element)).not.toBeNull(); + }); + + it('drops the heading and instructions once they are cleared', async () => { + const element = await createField({ + label: 'My field', + 'help-text': 'Some instructions', + }); + + element.label = ''; + element.helpText = ''; + await element.updateComplete; + + expect(heading(element)).toBeNull(); + expect(helpText(element)).toBeNull(); + }); + + it('keeps the heading for other heading content without a label', async () => { + const element = await createField( + {}, + '' + ); + + expect(heading(element)).not.toBeNull(); + expect(heading(element)!.querySelector('slot[name="label"]')).toBeNull(); + expect( + heading(element)!.querySelector('slot[name="actions"]') + ).not.toBeNull(); + }); + + it('keeps the heading for the read-only badge without a label', async () => { + const element = await createField({readonly: ''}); + expect(heading(element)!.querySelector('.read-only-badge')).not.toBeNull(); + }); + + it('renders the heading once heading content is slotted later', async () => { + const element = await createField(); + + const action = document.createElement('button'); + action.slot = 'actions'; + element.append(action); + await new Promise((resolve) => setTimeout(resolve)); + await element.updateComplete; + + expect(heading(element)).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; diff --git a/packages/craftcms-ui/src/components/field/field.ts b/packages/craftcms-ui/src/components/field/field.ts index 3cc8e931515..7104b7c8385 100644 --- a/packages/craftcms-ui/src/components/field/field.ts +++ b/packages/craftcms-ui/src/components/field/field.ts @@ -8,6 +8,7 @@ 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 {HasSlotController} from '@src/utilities/slot'; import {t} from '@src/utilities/translate'; type FormControlTarget = HTMLElement & { @@ -98,6 +99,16 @@ export default class CraftField extends FormControlMixin(LitElement) { */ @property({type: String, reflect: true}) width?: 'full' | 'auto'; + private readonly __hasSlot = new HasSlotController( + this, + 'heading-prefix', + 'label-extra', + 'actions', + 'heading-suffix', + 'tip', + 'warning' + ); + private __lightDomObserver = new MutationObserver(() => this.__onLightDomChanged() ); @@ -306,19 +317,33 @@ export default class CraftField extends FormControlMixin(LitElement) { /** * The field heading: label, read-only badge, flex-grow spacer, label extras - * and actions, mirroring `.field > .heading` in the Blade wrapper. + * and actions, mirroring `.field > .heading` in the Blade wrapper. Skipped + * entirely when there's nothing to put in it. */ protected override _labelTemplate() { - const hasActions = this.__hasLightChild('actions'); + const hasLabel = this.__hasLabel; + const hasLabelExtra = this.__hasSlot.test('label-extra'); + const hasActions = this.__hasSlot.test('actions'); + const hasOtherContent = + hasLabelExtra || + hasActions || + this.readOnly || + this.__hasSlot.test('heading-prefix') || + this.__hasSlot.test('heading-suffix'); + + if (!hasLabel && !hasOtherContent) { + // Lion types this override as returning a TemplateResult. + return html``; + } return html`
- + ${hasLabel ? html`` : nothing} ${this.readOnly ? html`${t('Read Only')}` : nothing} - ${this.__hasLightChild('label-extra') || hasActions + ${hasLabelExtra || hasActions ? html`
` : nothing} @@ -339,6 +364,11 @@ export default class CraftField extends FormControlMixin(LitElement) { `; } + /** The instructions, skipped entirely when there are none. */ + protected override _helpTextTemplate() { + return this.__hasHelpText ? super._helpTextTemplate() : html``; + } + /** * The input container, mirroring the server-side * `.input.{orientation}.errors.disabled` classes while keeping Lion's @@ -385,7 +415,7 @@ export default class CraftField extends FormControlMixin(LitElement) { * for warnings, each with a visually hidden prefix. */ protected _noticeTemplate(kind: 'tip' | 'warning') { - if (!this.__hasLightChild(kind)) { + if (!this.__hasSlot.test(kind)) { return nothing; } @@ -406,8 +436,19 @@ export default class CraftField extends FormControlMixin(LitElement) { `; } - private __hasLightChild(slotName: string): boolean { - return this.__lightChild(slotName) !== undefined; + /** + * Whether there's a label to show. Lion's `SlotMixin` always generates a + * light-DOM label node (empty when there's no `label`), so the node's + * presence says nothing — its text does. `label` reads that text back when + * a consumer slots their own label instead of setting the attribute. + */ + private get __hasLabel(): boolean { + return this.label.trim() !== ''; + } + + /** Whether there are instructions to show. See `__hasLabel`. */ + private get __hasHelpText(): boolean { + return this.helpText.trim() !== ''; } private __lightChild(slotName: string): HTMLElement | undefined { @@ -423,8 +464,8 @@ export default class CraftField extends FormControlMixin(LitElement) { this.__syncLabelDecorations(); this.__syncHasMaxlength(); this.__syncControlWidth(); - // Conditional templates (tip/warning callouts, heading spacer, action - // group) depend on light DOM children. + // Conditional templates (label, instructions, tip/warning callouts, + // heading spacer, action group) depend on light DOM children. this.requestUpdate(); } From 2586712af55cd96bed975fb5dbfe1784b7567226 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 10 Sep 2026 14:32:46 -0500 Subject: [PATCH 03/13] Space craft-field heading and instructions with gap Replace the margins below the label and help text with a flex gap on .form-field, so elements that aren't rendered or are out of flow leave no space behind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bBBVYBkMG1tj5rwTSPmX7 --- .../components/field/field.browser.test.ts | 35 ++++++++++++++++++- .../src/components/field/field.styles.ts | 19 ++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/craftcms-ui/src/components/field/field.browser.test.ts b/packages/craftcms-ui/src/components/field/field.browser.test.ts index c51e04d05bd..966b183e2c6 100644 --- a/packages/craftcms-ui/src/components/field/field.browser.test.ts +++ b/packages/craftcms-ui/src/components/field/field.browser.test.ts @@ -1,5 +1,6 @@ -import {beforeEach, expect, it, vi} from 'vite-plus/test'; +import {beforeEach, describe, expect, it, vi} from 'vite-plus/test'; import {computeAccessibleName} from 'dom-accessibility-api'; +import type CraftField from './field.js'; import './field.js'; import '../input/input.js'; import '../select/select.js'; @@ -20,3 +21,35 @@ it('labels nested native controls', async () => { 'Operator' ); }); + +describe('spacing', () => { + async function renderField(attrs: string): Promise { + document.body.innerHTML = ``; + const field = document.querySelector('craft-field')!; + await field.updateComplete; + return field; + } + + /** Distance from the top of the field to the top of the input group. */ + function inputOffset(field: CraftField): number { + const inputGroup = field.shadowRoot!.querySelector( + '.form-field__group-two' + )!; + return ( + inputGroup.getBoundingClientRect().top - field.getBoundingClientRect().top + ); + } + + it('leaves no space above the input without a label', async () => { + expect(inputOffset(await renderField(''))).toBe(0); + }); + + it('spaces a visible label from the input', async () => { + const field = await renderField('label="Title"'); + const heading = field.shadowRoot!.querySelector('.form-field__label')!; + + expect(inputOffset(field)).toBeGreaterThan( + heading.getBoundingClientRect().height + ); + }); +}); diff --git a/packages/craftcms-ui/src/components/field/field.styles.ts b/packages/craftcms-ui/src/components/field/field.styles.ts index e01e2dd5284..e48dcbe6a81 100644 --- a/packages/craftcms-ui/src/components/field/field.styles.ts +++ b/packages/craftcms-ui/src/components/field/field.styles.ts @@ -25,6 +25,22 @@ export default css` display: none; } + /* Spacing comes from gap rather than margins on the children: gap only + separates boxes in the flow, so a label or instructions that aren't + rendered, or are visually hidden (absolutely positioned), leave no + space behind. */ + .form-field { + display: flex; + flex-direction: column; + gap: var(--c-spacing-xs, 0.25rem); + } + + /* Puts the heading and instructions directly into .form-field's gap, so a + group with nothing visible in it doesn't leave a gap behind either. */ + .form-field__group-one { + display: contents; + } + .form-field__status-indicator { position: absolute; inset-block-start: 0; @@ -51,10 +67,8 @@ export default css` position: relative; display: flex; flex-wrap: wrap; - gap: 5px; align-items: center; font-weight: bold; - margin-block-end: var(--c-spacing-xs, 0.25rem); } /* Pushes slotted label extras to the far end of the heading row. */ @@ -91,7 +105,6 @@ export default css` /* 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 { From 896ef63209d6059d7264bbfa9f82573800cf0b98 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 10 Sep 2026 14:32:47 -0500 Subject: [PATCH 04/13] Support label-sr-only on craft-field Visually hide the label while keeping it available to screen readers. When the label is the only heading content, the whole heading is hidden so it leaves the flow and takes no gap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bBBVYBkMG1tj5rwTSPmX7 --- .../components/field/field.browser.test.ts | 21 ++++++++++ .../src/components/field/field.test.ts | 38 ++++++++++++++++++ .../craftcms-ui/src/components/field/field.ts | 39 +++++++++++++++++-- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/craftcms-ui/src/components/field/field.browser.test.ts b/packages/craftcms-ui/src/components/field/field.browser.test.ts index 966b183e2c6..cbfafdbef42 100644 --- a/packages/craftcms-ui/src/components/field/field.browser.test.ts +++ b/packages/craftcms-ui/src/components/field/field.browser.test.ts @@ -44,6 +44,13 @@ describe('spacing', () => { expect(inputOffset(await renderField(''))).toBe(0); }); + it('leaves no space above the input for a visually hidden label', async () => { + const field = await renderField('label="Title" label-sr-only'); + + expect(inputOffset(field)).toBe(0); + expect(computeAccessibleName(field.querySelector('input')!)).toBe('Title'); + }); + it('spaces a visible label from the input', async () => { const field = await renderField('label="Title"'); const heading = field.shadowRoot!.querySelector('.form-field__label')!; @@ -52,4 +59,18 @@ describe('spacing', () => { heading.getBoundingClientRect().height ); }); + + it('spaces instructions from the input when the label is visually hidden', async () => { + const field = await renderField( + 'label="Title" label-sr-only help-text="Some instructions"' + ); + const helpText = field.shadowRoot!.querySelector('.form-field__help-text')!; + + expect( + helpText.getBoundingClientRect().top - field.getBoundingClientRect().top + ).toBe(0); + expect(inputOffset(field)).toBeGreaterThan( + helpText.getBoundingClientRect().height + ); + }); }); diff --git a/packages/craftcms-ui/src/components/field/field.test.ts b/packages/craftcms-ui/src/components/field/field.test.ts index 337a8bc77ff..1eccf2951d6 100644 --- a/packages/craftcms-ui/src/components/field/field.test.ts +++ b/packages/craftcms-ui/src/components/field/field.test.ts @@ -558,6 +558,44 @@ describe('craft-field empty label and instructions', () => { }); }); +describe('craft-field label-sr-only', () => { + it('visually hides the whole heading when the label is all it has', async () => { + const element = await createField({ + label: 'My field', + 'label-sr-only': '', + }); + + const heading = element.shadowRoot!.querySelector('.form-field__label')!; + expect(heading.classList.contains('cp-visually-hidden')).toBe(true); + expect(heading.querySelector('slot[name="label"]')).not.toBeNull(); + }); + + it('visually hides only the label when the heading has other content', async () => { + const element = await createField( + {label: 'My field', 'label-sr-only': ''}, + '' + ); + + const heading = element.shadowRoot!.querySelector('.form-field__label')!; + expect(heading.classList.contains('cp-visually-hidden')).toBe(false); + expect( + heading.querySelector('.cp-visually-hidden > slot[name="label"]') + ).not.toBeNull(); + }); + + it('shows the label again when label-sr-only is unset', async () => { + const element = await createField({ + label: 'My field', + 'label-sr-only': '', + }); + + element.labelSrOnly = false; + await element.updateComplete; + + expect(element.shadowRoot!.querySelector('.cp-visually-hidden')).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; diff --git a/packages/craftcms-ui/src/components/field/field.ts b/packages/craftcms-ui/src/components/field/field.ts index 7104b7c8385..cc60bf8d0d2 100644 --- a/packages/craftcms-ui/src/components/field/field.ts +++ b/packages/craftcms-ui/src/components/field/field.ts @@ -1,4 +1,10 @@ -import {LitElement, html, nothing, type PropertyValues} from 'lit'; +import { + LitElement, + html, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; import {property} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {ifDefined} from 'lit/directives/if-defined.js'; @@ -99,6 +105,13 @@ export default class CraftField extends FormControlMixin(LitElement) { */ @property({type: String, reflect: true}) width?: 'full' | 'auto'; + /** + * Visually hides the label, keeping it available to screen readers. + * `FormControlMixin` declares this property (`label-sr-only`), but its + * types leave it out. + */ + declare labelSrOnly: boolean; + private readonly __hasSlot = new HasSlotController( this, 'heading-prefix', @@ -319,6 +332,11 @@ export default class CraftField extends FormControlMixin(LitElement) { * The field heading: label, read-only badge, flex-grow spacer, label extras * and actions, mirroring `.field > .heading` in the Blade wrapper. Skipped * entirely when there's nothing to put in it. + * + * With `label-sr-only`, the label stays available to screen readers but is + * visually hidden. When it's the only thing in the heading, the whole + * heading is hidden rather than just the label, so it leaves the flow and + * takes no gap; otherwise only the label is, keeping the rest visible. */ protected override _labelTemplate() { const hasLabel = this.__hasLabel; @@ -336,10 +354,25 @@ export default class CraftField extends FormControlMixin(LitElement) { return html``; } + let label: TemplateResult | typeof nothing = nothing; + if (hasLabel) { + label = + this.labelSrOnly && hasOtherContent + ? html`` + : html``; + } + return html` -
+
- ${hasLabel ? html`` : nothing} + ${label} ${this.readOnly ? html`${t('Read Only')}` : nothing} From e8cc7d2b8ec7bfa39d50d4677eef13db7eb12647 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 10 Sep 2026 14:32:47 -0500 Subject: [PATCH 05/13] Add labelSrOnly to the Field CP component Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bBBVYBkMG1tj5rwTSPmX7 --- src/Cp/Components/Field.php | 11 +++++++++++ .../Unit/Cp/Components/ComponentManifestDriftTest.php | 5 +++-- tests/Unit/Cp/Components/FieldTest.php | 8 ++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Cp/Components/Field.php b/src/Cp/Components/Field.php index 32de9c445e4..4a713c59dc0 100644 --- a/src/Cp/Components/Field.php +++ b/src/Cp/Components/Field.php @@ -36,6 +36,8 @@ class Field extends ViewComponent protected string|Htmlable|Stringable|ViewComponent|null $label = null; + protected bool $labelSrOnly = false; + protected string|Htmlable|Stringable|ViewComponent|null $input = null; protected string|Stringable|null $instructions = null; @@ -88,6 +90,14 @@ public function label(string|Htmlable|Stringable|ViewComponent|null $label): sta return $this; } + /** Visually hides the label, keeping it available to screen readers. */ + public function labelSrOnly(bool $labelSrOnly = true): static + { + $this->labelSrOnly = $labelSrOnly; + + return $this; + } + /** * The wrapped control. Strings are treated as trusted HTML (matching * `FormFields`), and should have a single root element so the `slot` @@ -255,6 +265,7 @@ protected function hostAttributes(): array || ($this->label instanceof Stringable && ! $this->label instanceof Htmlable && ! $this->label instanceof Markup) ? $this->label : null, + 'label-sr-only' => $this->labelSrOnly, 'required' => $this->required, 'translatable' => $this->translatable, 'translation-description' => $this->translationDescription, diff --git a/tests/Unit/Cp/Components/ComponentManifestDriftTest.php b/tests/Unit/Cp/Components/ComponentManifestDriftTest.php index 45be2235580..e37f0a8c96b 100644 --- a/tests/Unit/Cp/Components/ComponentManifestDriftTest.php +++ b/tests/Unit/Cp/Components/ComponentManifestDriftTest.php @@ -209,8 +209,9 @@ function cpDriftExpectedPhpOnly(): array // are PHP conveniences (pressed state, Invoker Commands API). 'craft-button' => ['type', 'active', 'disabled', 'command'], - // craft-field: `label` is a WC slot; `readonly`/`disabled` are native. - 'craft-field' => ['label', 'readonly', 'disabled'], + // craft-field: `label` is a WC slot; `readonly`/`disabled` are native; + // `label-sr-only` is Lion-inherited (not in the manifest). + 'craft-field' => ['label', 'label-sr-only', 'readonly', 'disabled'], // craft-switch: native input state / a slot, not declared manifest attributes. 'craft-switch' => ['checked', 'disabled', 'label'], diff --git a/tests/Unit/Cp/Components/FieldTest.php b/tests/Unit/Cp/Components/FieldTest.php index 2e34347b2b7..56ae855ea10 100644 --- a/tests/Unit/Cp/Components/FieldTest.php +++ b/tests/Unit/Cp/Components/FieldTest.php @@ -40,6 +40,14 @@ ->and($html)->toContain('orientation="rtl"'); }); + it('only renders label-sr-only when set', function () { + expect(Field::make()->label('My Label')->toHtml()) + ->not->toContain('label-sr-only'); + + expect(Field::make()->label('My Label')->labelSrOnly()->toHtml()) + ->toContain(' label-sr-only'); + }); + it('only renders width when set', function () { expect(Field::make()->toHtml()) ->not->toContain('width'); From ddbf2d4a6d3dd4be48cd834c90ca93b24486c1aa Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 10 Sep 2026 14:32:47 -0500 Subject: [PATCH 06/13] Add labelSrOnly to Field form nodes Passed through to craft-field by both the Vue renderer and the HTML fallback. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bBBVYBkMG1tj5rwTSPmX7 --- resources/js/modules/forms/FieldNode.vue | 3 ++ .../js/modules/forms/FormRenderer.test.ts | 40 +++++++++++++++++++ src/Form/Nodes/Field.php | 12 ++++++ 3 files changed, 55 insertions(+) diff --git a/resources/js/modules/forms/FieldNode.vue b/resources/js/modules/forms/FieldNode.vue index 7ca4ed054ea..02a00073649 100644 --- a/resources/js/modules/forms/FieldNode.vue +++ b/resources/js/modules/forms/FieldNode.vue @@ -23,6 +23,8 @@ type FieldNodeProps = { label?: string | null; + /** Visually hides the label, keeping it available to screen readers. */ + labelSrOnly?: boolean; instructions?: string | null; required?: boolean; instructionsPosition?: 'before' | 'after'; @@ -130,6 +132,7 @@