Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ export default css`

:host([multiple-choice]) .input-group__input {
${baseFormControlStyles}
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
Expand Down
47 changes: 46 additions & 1 deletion packages/craftcms-ui/src/components/field/field.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,3 +21,47 @@ it('labels nested native controls', async () => {
'Operator'
);
});

describe('spacing', () => {
async function renderField(attrs: string): Promise<CraftField> {
document.body.innerHTML = `<craft-field ${attrs}><input slot="input"></craft-field>`;
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('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 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
);
});
});
1 change: 0 additions & 1 deletion packages/craftcms-ui/src/components/field/field.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export default css`
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. */
Expand Down
125 changes: 125 additions & 0 deletions packages/craftcms-ui/src/components/field/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,131 @@ 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(
{},
'<label slot="label">Custom label</label><div slot="help-text">Custom instructions</div><input slot="input" type="text">'
);

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(
{},
'<input slot="input" type="text"><button slot="actions">Hide</button>'
);

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 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': ''},
'<input slot="input" type="text"><button slot="actions">Hide</button>'
);

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;
Expand Down
96 changes: 85 additions & 11 deletions packages/craftcms-ui/src/components/field/field.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,6 +14,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 & {
Expand Down Expand Up @@ -98,6 +105,23 @@ 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',
'label-extra',
'actions',
'heading-suffix',
'tip',
'warning'
);

private __lightDomObserver = new MutationObserver(() =>
this.__onLightDomChanged()
);
Expand Down Expand Up @@ -306,19 +330,53 @@ 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.
*
* 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 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``;
}

let label: TemplateResult | typeof nothing = nothing;
if (hasLabel) {
label =
this.labelSrOnly && hasOtherContent
? html`<span class="cp-visually-hidden"
><slot name="label"></slot
></span>`
: html`<slot name="label"></slot>`;
}

return html`
<div class="form-field__label">
<div
class=${classMap({
'form-field__label': true,
'cp-visually-hidden': this.labelSrOnly && !hasOtherContent,
})}
>
<slot name="heading-prefix"></slot>
<slot name="label"></slot>
${label}
${this.readOnly
? html`<span class="read-only-badge">${t('Read Only')}</span>`
: nothing}
${this.__hasLightChild('label-extra') || hasActions
${hasLabelExtra || hasActions
? html`<div class="flex-grow"></div>`
: nothing}
<slot name="label-extra"></slot>
Expand All @@ -339,6 +397,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
Expand Down Expand Up @@ -385,7 +448,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;
}

Expand All @@ -406,8 +469,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 {
Expand All @@ -423,8 +497,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();
}

Expand Down
7 changes: 5 additions & 2 deletions packages/craftcms-ui/src/styles/form.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {css} from 'lit';
* min-height, so the control's `:focus-visible` outline traces its own shape
* rather than an ancestor's. Pair it with {@link baseInputWrapperStyles} on
* the surrounding `.input-group__input`/`.input-group__container` div.
*
* The control is sized border-box, so `--c-input-height` is its full outer
* height — matching buttons — whether or not the page applies a global
* border-box reset.
*/
export const baseFormControlStyles = css`
--_input-border-width: var(
Expand All @@ -27,8 +31,8 @@ export const baseFormControlStyles = css`
var(--_input-end-end-radius) var(--_input-end-start-radius);
background-color: var(--c-input-fill, var(--c-form-control-fill));
box-shadow: var(--c-input-shadow);
min-height: var(--c-input-height, var(--c-size-control-md));
box-sizing: border-box;
min-height: var(--c-input-height, var(--c-size-control-md));
`;

/**
Expand Down Expand Up @@ -75,7 +79,6 @@ export const baseComboboxStyles = css`
${baseFormControlStyles}
width: 100%;
height: 100%;
min-height: none;
appearance: none;
padding-inline: var(--c-input-spacing-inline)
calc(var(--c-input-spacing-inline) * 1.5 + 1em);
Expand Down
Loading
Loading