Skip to content

fix(edit-content): adopt the global .form system and standardize hint/error presentation (#37460, #37464) - #37597

Open
adrianjm-dotCMS wants to merge 17 commits into
mainfrom
37460-37464-edit-content-form-hint-error
Open

adrianjm-dotCMS wants to merge 17 commits into
mainfrom
37460-37464-edit-content-form-hint-error

Conversation

@adrianjm-dotCMS

@adrianjm-dotCMS adrianjm-dotCMS commented Sep 17, 2026

Copy link
Copy Markdown
Member

Closes #37460
Closes #37464

Field hints and required errors rendered as unstyled body text, and errors were not red. The dotCMS form system is global CSS scoped to .form (apps/dotcms-ui/src/style.css), and the editor's root <form> never carried that class, so every rule below it was inert. The hint/error slot was also written three different ways across 18 templates, the error fired on blur instead of on save, and the hint was suppressed exactly when the field was in error.

The load-bearing change

.form .field > label is a direct-child selector. The label sat inside a <dot-card-field-label> element, making it a grandchild of .field, so the rule never matched.

display: contents does not fix this. display governs box generation; selectors match the DOM tree. It collapses the layout correctly while leaving the typography untouched — the gap looks right and the label stays at 14px/400. That failure looks like success, which is why AC-105 is a devtools check ("the rule appears as matched") rather than a passing spec.

The component now uses an attribute selector, label[dotCardFieldLabel], so it is the label instead of wrapping one:

<!-- before -->                          <!-- after -->
<div class="field">                      <div class="field">
  <dot-card-field-label>                   <label dotcardfieldlabel="" for="title">
    <label for="title">Title</label>         Title
  </dot-card-field-label>                  </label>
</div>                                   </div>

17 consumer templates updated. Because the host is the label, the required directive is attached with hostDirectives rather than from the template — a component cannot put a directive on its own host that way.

What each issue changes

#37460class="form" on the root <form>; .field in dot-card-field, keeping the field-error-marker anchor and the empty-footer collapse; .form-checkbox / .form-radio on option rows; labels stripped of typography utilities; dotFieldRequired replaces the hand-written p-label-input-required.

#37464$hasError moves from control.invalid && control.touched to hasAttemptedSubmit && control.invalid, so tabbing out of an empty required field no longer turns it red. The three markup variants collapse into one shared footer. The label's hint tooltip is gone — hints are always text below the control. Error and hint now render together, error first; previously every template dropped the hint the moment an error appeared, removing the sentence explaining how to fix it. Text Area gains the hint/error slot it never had — the one piece of genuinely new behavior, worth a QA pass.

Accessibility — AC-209

The asterisk is ::after content, deliberately outside the label's accessible name so a screen reader never announces "star" — which leaves assistive technology with nothing unless the control itself says it is mandatory.

Marking "the control" requires knowing which element that is, and the only declaration of it is the label's for. On 13 of 22 field types it pointed at an id that exists nowhere: those fields announced with no name, and clicking their label did nothing. Pre-existing, and invisible to anyone looking at the screen.

One pattern does not fit every widget, so there are three:

shape named by aria-required?
a labelable control <label for> → the control's id yes
a composite widget (div, span) role + aria-labelledby → the label's id only where the role allows it
a third-party editor its own option (Monaco, TinyMCE, Block Editor) n/a

<label for> only reaches labelable elements, so a <div role="group"> can never be named that way however many roles it carries — the label now exposes a stable id for those to point at, and that id is load-bearing: renaming it silently strips the name off seven field types. ARIA defines aria-required on combobox, listbox, radiogroup, spinbutton, textbox and treenot on plain group, and there is no checkboxgroup role, so a required checkbox group shows its asterisk but has no vocabulary to announce it.

Measured on a content type carrying every field type: an accessible name resolves on 23 of 23 — 10 via label.control, 10 via aria-labelledby, 3 via the editor's own option. aria-required on 11, every field ARIA permits it on.

Measured with label.control, the association the browser itself resolves — not "an element with that id exists", which is not the same thing and hid a gap.

Deviations, each verified rather than assumed

#37460's AC to delete the SCSS in dot-form-file-editor / dot-form-import-url is not achievable — and the criterion stays in the issue. Those compile into dotcms-binary-field-builder, the app behind the <dotcms-binary-field> element the legacy Dojo editor loads. That bundle has its own 14-line style.css, no Tailwind and no PostCSS config, so the global system neither exists nor could compile there; independently, both dialogs open appendTo: 'body', outside any .form. Confirmed at runtime: the import-URL dialog computes gap: 10.5px and width: 448px straight from that SCSS. Deleting it leaves both unstyled in the legacy editor. Both files now carry that reasoning in their STYLE EXCEPTION header. The only change in that subtree is line 47 adopting dotFieldRequired.

libs/ui's dotFieldRequired gains a boolean mode. Additive: all 77 existing callers use bare mode and none passes a boolean.

Merged main, which brought #37192 with it. That deleted the old relationship search dialog wholesale, so what this branch did to those four files is moot — and the suite drops from 2459 to 2425 tests purely because their specs went with them. Conflict-by-conflict reasoning is in the merge commit.

Verified in the browser, not by inspection

Re-run after the merge; these are the post-merge numbers.

.form .field > label a matched rule in the CSSOM 23/23 labels at 12.25px / 500, all direct children
Typography vs. an existing form (dot-tags-create) identical — 12.25px / 500, gap 3.5px
Hints / errors rgb(107,114,128) / rgb(239,68,68), no icons
Blur vs. save 0 errors after tabbing required fields → 20 after Save, error above hint
Error clears one per field, no second save
Save blocked, scroll to first error 20 .field-error-marker anchors
Accessible names 23/23; 0 nested role="textbox"
Legacy Dojo editor bundle rebuilt, docker cp'd, both dialogs exercised after every round of changes

Notes for the reviewer

  • One PR instead of the two the spec planned. The work was implemented on one branch without committing at the intermediate checkpoint, so a clean split is no longer possible without a broken intermediate commit. My error, flagged rather than hidden.
  • Two bugs surfaced while testing and are fixed here: a missing DotMessagePipe import that threw NG0302 and killed the Block Editor's change-detection pass, and a pre-existing null-content crash in libs/block-editor (typeof null === 'object' fell through the guard).
  • docs/frontend/STYLING_STANDARDS.md had the global form system filed as legacy, do not extend — the opposite of what this establishes. Rewritten, plus the docs that pointed at it: the index assigned "forms" to ANGULAR_STANDARDS.md, which has no form-markup section.
  • libs/block-editor's own suite has 35 pre-existing failures, measured before this change and unchanged by it.
  • pnpm nx test edit-content: 2425 passing across 116 files. new-block-editor: 296. pnpm nx build dotcms-ui compiles. lint clean.

🤖 Generated with Claude Code

adrianjm-dotCMS and others added 6 commits September 15, 2026 12:07
…the picker footer (#37465)

Date, Time and Date-and-time were the only fields in the new Edit Contentlet
that behaved unlike their neighbours: narrower than their column, impossible
to empty once set, and carrying a picker footer that offered a redundant
Clear while hiding the timezone the value is read in.

- Width: the control is block-level flex at full width; the input grows and
  the trigger stays flush right, with the focus ring and the invalid border
  still enclosing both as one unit.
- Clearing: showClear is unconditional, so every type can be emptied, not
  just the expire-date field. PrimeNG's default clear icon is a bare <svg>
  with a click handler — unfocusable and unnamed — so a real <button> is
  projected through #clearicon instead.
- Picker footer: rebuilt through #buttonbar. The timezone reads on the left
  for the two types that carry a time; a single secondary-outlined Today
  (Now for time-only) sits on the right; PrimeNG's Clear is gone.
- Today/Now resolves from the SERVER clock via getCurrentServerTime and the
  existing onCalendarChange conversion. PrimeNG's supplied todayCallback is
  deliberately unused: it reads new Date(), the browser's clock, which is the
  defect this corrects. Verified in the browser with the server on UTC and the
  browser on UTC-4 — the field took 15:57, not 11:57.
- The timezone line under the input is gone and the hint returns to the field
  footer, as it renders for every other field type.

Two behaviours beyond the issue's scope, accepted deliberately to reduce debt
in a file already being touched: the required error no longer evicts the hint
(they stack, error first), and both carry their colours directly, because
.form .p-field-error in style.css never applies — the new editor has no .form
ancestor. Both belong to #37464 / #37460 and here cover the calendar field
only, not the other ~15 field types.

One defect was found by manual verification and would not have been found by
the suite: on reopening saved content no field showed a clear control until
the author focused it. PrimeNG gates that control on a DOM read that
updateInputfield() performs without markForCheck(), and the DatePicker is
OnPush. An effect now schedules the pass in a microtask. The test helper had
been masking it by running the DatePicker's own detector — doing in the test
what the component failed to do in production — and no longer does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng labels (#37465)

Review on PR #37542 surfaced a real bug: getCurrentServerTime(null) returned
UTC clock components reinterpreted as local, while every other timezone-less
path in this field (convertServerTimeToUtc, convertUtcToServerTime) treats a
missing zone as "use the browser's local clock as-is". The mismatch meant
Today/Now displayed one time and stored another, off by the browser's UTC
offset — permanently, if the timezone request fails outright rather than
merely arriving late.

Falls back to the browser's own local clock instead, matching the convention
already used elsewhere. No change to the conversion helpers themselves, so
FR-017's storage guarantees are untouched.

Also: the footer's timezone text now truncates (min-w-0 + truncate, with a
title attribute carrying the full label) rather than being able to push the
Today/Now button out of the overlay on a very long zone name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… spec

- Today/Now no longer falls back to the browser's clock when the system
  timezone is missing. getCurrentServerTime is restored to its previous
  implementation, so the button always goes through the existing
  getCurrentServerTime / convertServerTimeToUtc path, as the issue's
  acceptance criterion requires. The test asserting the fallback is
  removed.
- Today/Now no longer closes the picker on Date-only fields. The field
  sets hideOnDateTimeSelect to false for all three types, so selecting a
  day already keeps the picker open; the shortcut now behaves the same.
  The test covers all three types.
- The footer timezone label drops its truncation and tooltip; the label
  is always short enough for the picker.
- Removes the test for the timezone-unavailable footer state, which the
  spec no longer describes. The @if guard stays: it prevents reading
  .label on null and implements the Date-only rule (FR-008a).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…37465)

CI's "Frontend Unit Tests" job failed on its format-test goal, which runs
nx format:check. The tests themselves passed; two files were unformatted:

- calendar-field.component.spec.ts: two stray blank lines.
- dot-edit-content-calendar-field.component.html: Tailwind class order,
  which prettier-plugin-tailwindcss sorts.

Both came in through edits whose commits staged a different file set, so
lint-staged's format:write never saw them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…/error presentation (#37460, #37464)

Field hints and required-error messages rendered as unstyled body text in the new
Edit Contentlet, and errors were not red. The dotCMS form system is global CSS
scoped to `.form` (apps/dotcms-ui/src/style.css), and the editor's root form never
carried that class, so every rule below it was inert.

#37460 -- make the global system reach the editor

The load-bearing change is structural. `.form .field > label` is a DIRECT-CHILD
selector, and the label sat inside a `<dot-card-field-label>` element, so it was a
grandchild of `.field` and the rule never matched. `display: contents` on that
wrapper does not fix it: `display` governs box generation, selectors match the DOM
tree. It collapses the layout correctly while leaving the typography untouched --
a failure that looks like success, which is why AC-105 is a devtools check rather
than a passing spec.

`dot-card-field-label` now uses an attribute selector, `label[dotCardFieldLabel]`,
so the component IS the label instead of wrapping one. The intermediate element is
gone rather than worked around. 17 consumer templates updated accordingly.

Also: `.form` on the root form, `.field` in `dot-card-field`, `.form-checkbox` /
`.form-radio` on option rows, clean labels, and `dotFieldRequired` replacing the
hand-written `p-label-input-required`.

#37464 -- one hint/error presentation, gated on save

`$hasError` moves from `control.invalid && control.touched` to
`hasAttemptedSubmit && control.invalid`, so tabbing out of an empty required field
no longer turns it red before the author has tried to save anything. The three
duplicated markup variants collapse into one footer shared by every field type,
the label's hint tooltip is gone (hints are always text below the control), and
error and hint now render together, error first -- previously every template
suppressed the hint the moment an error appeared, removing the sentence that
explained how to fix it.

Text Area gains the hint/error slot it never had. This is the only new behavior:
a Text Area hint becomes visible for the first time, and an empty required one
starts explaining why the save is blocked.

Deviations from the issues, both verified rather than assumed

- #37460's AC to delete the SCSS in `dot-form-file-editor` / `dot-form-import-url`
  is not achievable. Those compile into `dotcms-binary-field-builder`, a separate
  app that loads neither `apps/dotcms-ui/src/style.css` nor Tailwind, and both
  dialogs open `appendTo: 'body'`. Confirmed at runtime in the legacy Dojo editor
  after deploying the bundle: the dialogs compute their layout from that SCSS.
  The only change there is line 47 adopting `dotFieldRequired`, which is safe
  because `_misc.scss` styles that class in both bundles.

- #37464's `aria-required` half of the required-indicator AC is partial: 9 of 22
  fields. The rest have a label whose `for` points at an id that does not exist,
  so there is no declared control to mark. An earlier attempt to infer the control
  marked the editor-mode dropdown on Text Area and WYSIWYG, which is worse than
  marking nothing, so the code now marks only what the label declares. Fixing the
  associations is separate work, tracked for its own issue.

`libs/ui`'s `dotFieldRequired` gains a boolean mode. Additive: all 77 existing
callers use bare mode and none pass a boolean.

Verified in the browser, not by inspection: the rule matching in the CSSOM, the
four presentation states, blur producing no error while save produces four, the
error clearing without a second save, scroll-to-first-error, and the legacy Dojo
binary field after deploying the rebuilt bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Decision 1 said to withdraw the AC asking for the file-editor / import-URL SCSS
to be deleted. Developer decision: keep it as written and document on the PR why
the SCSS is retained -- it ships inside the dotcms-binary-field custom element the
legacy Dojo editor loads, a bundle with neither the global stylesheet nor Tailwind.
The constraint is more useful to the next reader than the removal of the criterion
that surfaced it.

Design has approved the visual deltas, including the checkbox/radio option labels
dropping to 12.25px, which #37460's measured-impact table does not list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @adrianjm-dotCMS's task in 4m 3s —— View job


PR Review

Reviewed the logic-bearing changes: the dotFieldRequired boolean mode, BaseWrapperField.$hasError, the dot-card-field / label / footer trio, the block-editor null-content and DotMessagePipe fixes, the submit-attempt store wiring, and the accessibility markup across the field templates. The refactor is careful and well-documented. Two accessibility findings, both non-blocking.

New Issues

  • 🟡 Medium: core-web/libs/edit-content/src/lib/fields/dot-edit-content-select-field/dot-edit-content-select-field.component.html:21[attr.aria-labelledby]="'field-' + $field().variable" points at an id (field-<variable>) that exists nowhere — the label's id is label-<variable> (dot-card-field-label.component.ts:71, $testId = label-${variable}). It's a grep-confirmed one-off: the only field- reference in the whole fields/ tree, with no matching id=. It's also redundant with, and contradicts, the correct [ariaLabelledBy]="'label-' + field.variable" set on the same element at line 14. The focusable inner combobox keeps the correct label- reference via PrimeNG's ariaLabelledBy input, so the name still resolves in practice — but the host element carries a dangling reference that directly undercuts the AC-209 claim that names resolve via the label id. Should be label- or removed. Fix this →

  • 🟡 Medium: dot-edit-content-radio-field.component.html:16, dot-edit-content-category-field/components/dot-category-field/dot-category-field.component.ts:52, dot-edit-content-file-field.component.html:14 (and the host-folder / relationship equivalents) — the aria-labelledby / labelledBy binding to 'label-' + field.variable is rendered unconditionally, but the <label dotCardFieldLabel> that owns that id sits behind @if (showLabel). When a field carries the hideLabel variable ($showLabel() → false), the label element is not in the DOM and every composite widget's aria-labelledby becomes a dangling reference — the group announces with no accessible name. Assumption: hideLabel is reachable for these composite fields via $showLabel/fieldVariables. What to verify: whether these field types can actually be configured with hideLabel; if so, guard the aria-labelledby on showLabel or leave the name off entirely rather than pointing at a missing id.

Everything else checked out:

  • BaseWrapperField.$hasError correctly gates on hasAttemptedSubmit + live control.invalid, and markSubmitAttempted() fires only on the invalid-save path (dot-edit-content-form.component.ts:533) — errors surface on Save, not on blur, as intended.
  • DotFieldRequiredDirective boolean mode: the typeof field === 'boolean' check precedes the truthiness test, so false correctly clears the asterisk instead of being read as "no value".
  • The dot-card-field aria-required effect resolves the control strictly via the label's for, never a first-focusable guess — correct for Text Area / WYSIWYG.
  • block-editor !content || typeof content === 'string' null guard and the DotMessagePipe import fix are both sound.

Notes

  • No merge base was reachable in the shallow CI checkout, so this review is against the current file state rather than a literal origin/main...HEAD diff.

· branch 37460-37464-edit-content-form-hint-error

…ld (#37464)

A required, empty Block Editor showed no "This field is required" message, even
though its control was INVALID and the save was correctly blocked. Two separate
faults, found by exercising a content type covering every field type.

1. Missing pipe -- introduced by the footer consolidation in this branch

The Block Editor was the only field template with no footer before #37464. Giving
it the shared one made its template use `{{ '...required' | dm }}`, but the
component never imported DotMessagePipe. Resolving the pipe threw NG0302, which
aborted the change-detection pass partway through the template: the error block
rendered nothing, and bindings after it kept stale values. Audited all 18 field
templates -- this was the only one affected.

The unit suite did not catch it because the Block Editor spec never renders the
error state; it only shows against a required, empty field in a browser.

2. Null content -- pre-existing in libs/block-editor

`setEditorJSONContent` guards an uninitialised editor and string content, but not
null. `typeof null === 'object'`, so a new contentlet with an empty Story Block
fell through to `content.content` and threw during ngOnInit. Fixed with `!content`
in the same guard, covered by a test in the spec that already exercises that method.

Verified in the browser on a content type with every field type: 20 of 20 required,
empty fields now render their message, Block Editor included, with the hint below it.

Note on libs/block-editor: its suite has 35 pre-existing failures, measured before
this change and unchanged by it. The test added here passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adrianjm-dotCMS
adrianjm-dotCMS added this pull request to stack #37557 September 17, 2026 17:13
adrianjm-dotCMS and others added 6 commits September 17, 2026 14:57
…rk required where ARIA allows (#37464)

AC-209 has two halves. The asterisk half was already met: it is `::after` content,
deliberately outside the label's accessible name so a screen reader never announces
"star". That is also what left assistive technology with nothing -- which the second
half, `aria-required` on the control, was meant to supply.

Implementing it surfaced the larger problem underneath. Marking "the control"
requires knowing WHICH element that is, and the only declaration of that is the
label's `for`. On 13 of 22 field types it pointed at an id that exists nowhere: a
screen reader announced those fields as "combobox" with no name at all, and clicking
their label did nothing. Pre-existing, and invisible to anyone looking at the screen.

Three shapes, because one pattern does not fit every widget

1. Labelable controls -- `<label for>` + `id`, then `aria-required` on the control.
   Host/Folder's trigger was already behaving as a combobox: it carried aria-expanded
   and opened a tree overlay. Declaring `role="combobox"` makes that true, and it is
   a role ARIA defines aria-required on.

2. Option groups and collection widgets -- `role` + `aria-labelledby`. `<label for>`
   only associates with labelable elements, so a `<div>` group can never be named that
   way however many roles it carries. The label now exposes a stable id for them to
   point at. Radio takes `radiogroup`, which supports aria-required; checkbox sets,
   Key/Value, Category, Relationship, File/Image/Binary and Custom Field take `group`,
   which ARIA does not define it on -- so it is deliberately absent rather than
   invalid.

3. Third-party editors -- their own documented option, since each owns its DOM.
   Monaco's `ariaLabel`, TinyMCE's `iframe_aria_text` for the body inside the frame
   plus `iframe_attrs.title` for the frame itself (TinyMCE hardcodes that to "Rich
   Text Area", identical for every rich-text field on a form), and a computed in
   new-block-editor preferring the field name over its generic translated label.

Result: accessible name on 22 of 22 required fields, up from 9. aria-required on 11,
every field type ARIA permits it on. An earlier attempt inferred the control from the
first focusable descendant and marked the editor-mode dropdown as the mandatory field
on Text Area and WYSIWYG -- worse than marking nothing -- so the code only ever marks
what the label declares.

dot-file-field takes `labelledBy` as an input defaulting to empty rather than deriving
it: that component also compiles into the dotcms-binary-field-builder bundle the legacy
Dojo editor loads, where no such label exists. Verified there after rebuilding and
deploying the bundle -- the attribute is simply absent rather than pointing at an id
that is not on the page, and both file dialogs render unchanged.

pnpm nx test edit-content: 2458 passing. new-block-editor: 296 passing. Lint and
format clean on all three projects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot reach (#37464)

`<label for>` only associates with LABELABLE elements. PrimeNG's select puts `inputId`
on a `<span role="combobox">`, which is not one, so the browser resolved
`label.control` to nothing and a screen reader announced those fields unnamed --
despite the id existing on the page. `ariaLabelledBy` is PrimeNG's own way to name
that span, pointing back at the label's id.

Two instances: the Select field, and the language filter in the relationship dialog.
The latter is the pattern the repo held up as correct, and was the one actually
broken. MultiSelect is unaffected -- it renders a labelable element for `inputId`,
which is why it resolved all along.

This also corrects an earlier measurement. Counting "named" as "an element with that
id exists" is not the same as the browser associating them; `label.control` is. Under
the honest metric the count was 21 of 22, not 22, and the gap was the Select.

AC-110 needs no work: `p-treeSelect` does render its `inputId`, and site-field's
label resolves to `input#site-field` with `role="combobox"`. The earlier report that
PrimeNG dropped it came from measuring before the dialog's popover had mounted.

Accessible name now resolves on 22 of 22 required fields: 9 via label.control,
10 via aria-labelledby, 3 via the editor's own aria-label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tterns this work needed (#37460, #37464)

STYLING_STANDARDS.md contradicted the state this branch leaves the code in, in five
places. It is the doc a reviewer or the next developer will reach for, so leaving it
stale would have cost more than the work it documents.

Reverses the central guidance. The doc called the global `.form`/`.field` classes
legacy and said not to extend them to new features. #37460 did exactly that, on
Design's request: edit-content was the one surface that had never adopted them, which
is why its labels rendered larger and lighter than every other admin form and its
hints and errors rendered unstyled. The classes are now documented as the convention,
with the reversal called out rather than quietly applied.

Corrects three stale rules. The label-to-control gap is supplied by `.field`, not
written by hand -- the doc prescribed a `gap-2` that contradicts the system's `gap-1`.
The worked example now uses the global markup instead of the hand-rolled wrapper
edit-content just abandoned. And the `dotFieldRequired` section claimed the directive
has no input and exactly two usages; it has four.

Adds two patterns that were learned the hard way here:

**Reaching a direct-child rule from a component.** `.form .field > label` is a
direct-child selector, so a component rendering its own `<label>` inside its element
makes that label a grandchild and the rule never matches. `display: contents` does not
fix it -- `display` governs box generation while selectors match the DOM tree -- and
its failure mode is the dangerous kind: the gap collapses correctly while the
typography stays wrong, so a visual check passes. The fix is an attribute selector, so
the component's host IS the label.

**Naming a field for assistive technology.** Three shapes by widget, because
`<label for>` only associates with labelable elements: a `<div role="group">` and
PrimeNG's select (which puts `inputId` on a `<span role="combobox">`) can never be
named that way. Records `label.control` as the verification rather than "an id
exists", which is not the same thing and hid a real gap; ARIA's list of roles that
accept `aria-required`, and that plain `group` is not among them; and that inferring
the control from the first focusable descendant marks the editor-mode dropdown as the
mandatory field on Text Area and WYSIWYG.

Also records the dual role of `dot-card-field-label`'s `$testId`: the format is fixed
because specs select on it, and the same value is the element id that composite
widgets point `aria-labelledby` at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…shipped (#37460, #37464)

Convergence findings on the spec's own artifacts. No behavior changes.

The STYLE EXCEPTION header on the two file-field stylesheets named only Tailwind's
absence from the legacy bundle. It now names both reasons this subtree keeps its SCSS
while #37460 removed every other hand-rolled form style: the bundle loads neither
apps/dotcms-ui/src/style.css nor Tailwind, and both dialogs open with appendTo: 'body',
which puts them outside the editor's <form class="form"> even in dotcms-ui. Deleting the
file does not fall back to the global system — it falls back to unstyled.

The dot-card-field-label contract described an API that was never built: a component
`imports` and an `isRequired` component input. What shipped attaches the directive as a
host directive with an aliased input, because a component cannot apply a directive to its
own host from its own template. The contract also missed the `[attr.id]` host binding,
which is load-bearing: seven composite field types name themselves with
aria-labelledby pointing at it.

data-model.md said the control "carries required / aria-required" as though it were
uniform. It resolves three different ways by widget, and aria-required only goes on roles
ARIA defines it for — which is why the count is 22/22 accessible names but 11/22
aria-required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…37460, #37464)

STYLING_STANDARDS.md gained the form-markup and accessible-naming rules in 3522b4d,
but the documents that point at it did not catch up. The index called it "Tailwind,
PrimeNG theme, BEM, SCSS variables" and assigned forms to ANGULAR_STANDARDS.md, which has
no form-markup section at all — so a developer following the index lands on the wrong file
and writes the markup #37460 removed. core-web/CLAUDE.md already routed correctly; the two
index documents now match it, and a short section says plainly that forms are split:
ANGULAR_STANDARDS owns the TypeScript side, STYLING_STANDARDS owns everything visible.

The form example in COMPONENT_ARCHITECTURE.md is the only end-to-end form template in
docs/ and carried no class="form", so every field in it renders with the global system
inert — exactly the defect #37460 was filed for. Copying the canonical example reproduced
the bug.

ANGULAR_STANDARDS.md's accessibility section said "pass AXE" and "ARIA attributes where
needed". That is the guidance that left 13 of 22 field types with a label `for` pointing
at an id that was never rendered: nothing throws, and AXE may still pass. It now carries
the rule that `for` only reaches labelable elements, and how to verify it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AC-209 was reported met for the Block Editor. It was not: the editable surface had
no accessible name, and a screen reader landing on it announced nothing.

role="textbox", aria-multiline and aria-label were bound in the template, on the div
that carries the `tiptap` directive. ngx-tiptap mounts ProseMirror's contenteditable as
a CHILD of that div, so the named element never receives focus — and the page ended up
declaring two nested role="textbox", the inner one anonymous.

editorProps.attributes is the only way to reach the element ProseMirror owns, so the
three attributes move there and come off the wrapper. Verified in the browser: the
contenteditable now resolves to the field's name, and there is exactly one textbox.

Across the QA content type covering every field type, that closes the last gap:
23 of 23 fields resolve an accessible name — 10 through `label[for]`, 10 through
role + aria-labelledby, 3 through the editor's own option.

Not addressed here, and pre-existing: aria-haspopup, aria-controls, aria-expanded and
aria-activedescendant for the slash-command menu sit on the same unfocused wrapper, so
they announce nothing either. Moving them needs reactivity editorProps does not offer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base automatically changed from issue-37465-calendar-field-width-clear-picker-footer-impl to main September 18, 2026 17:10
adrianjm-dotCMS and others added 2 commits September 18, 2026 13:24
Brings in #37192 (the relationship field on the shared search surface) and #37555
(#37465's Date/Time work), both of which landed while this branch was open.

Conflicts, and how each was settled:

- dot-select-existing-content/**/search/ — deleted in main by #37192, which replaced the
  bespoke dialog with the shared search surface. Deletion accepted. What this branch did
  to those four files is moot: the `.form` adoption, the `for="language-field"` →
  `for="site-field"` fix (AC-109) and the language combobox naming (AC-110) all targeted
  markup that no longer exists. No dangling references remain.

- calendar-field.component.{html,scss,spec.ts,ts} and dot-edit-content-calendar-field.component.ts
  — untouched by this branch; took main's merged #37555.

- dot-edit-content-calendar-field.component.{html,spec.ts} — rebuilt on main's version with
  this branch's delta re-applied, keeping the `[contentlet]` input #37555 added. The comment
  main carries there asked for exactly this ("drop the utilities, keeping the semantic
  classes, once either lands").

- host-folder-field.component.{html,ts} — took main's restructuring (#37192 added a projected
  trigger and keyboard activation) and re-applied the accessibility attributes on the DEFAULT
  trigger only. A projected trigger is Content Drive's filter chip, which has no <label for>
  to associate with.

pnpm nx test edit-content: 116 files, 2425 tests, green. The drop from 2459 is the specs
that went with the deleted dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…null (#37464)

Two review findings from PR #37597.

**formControl.** The getter destructured `this.$field()` unguarded. `$field` is
`input.required` on most subclasses but NOT all — custom-field and json-field both declare
it with a `null` default — so the getter throws a TypeError rather than yielding "no
control", and it throws from inside the `$hasError` computed. Every caller, here and in the
subclasses, already handled a null control; the getter was the one that did not. It now
returns `FormControl | null`, which is what all of them already assumed.

**aria-required on late-mounting controls.** The afterRenderEffect in dot-card-field is
reactive, not per-render, so a control that mounts in a later change-detection pass without
a tracked signal changing is never visited. That is currently unreachable, but only by
coincidence: every field that defers its control (category, file, custom/iframe) exposes it
as `role="group"`, which ARIA gives no aria-required, and every field that can carry the
attribute either renders its control synchronously or sets it in its own template (radio,
host-folder). Behavior unchanged; the constraint is now written down, because giving a
deferred widget an eligible role would silently stop marking it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added Area : Documentation PR changes documentation files Area : Frontend PR changes Angular/TypeScript frontend code and removed AI: Safe To Rollback labels Sep 18, 2026
@adrianjm-dotCMS
adrianjm-dotCMS marked this pull request as ready for review September 18, 2026 17:48
…ouched (#37460, #37464)

CI's strict-gate runs tsc in strict mode over the changed lines. It failed on 45
diagnostics, all in code this branch added.

**TS1117 — duplicate object keys (12).** dot-wysiwyg-tinymce's spec declared the
`iframe_aria_text` / `iframe_attrs` pair three times over inside the same literal, in three
separate expectations. The last one silently wins in JS, so the assertion passed and the
duplication was invisible.

**TS2531 / TS18047 — possibly null (32).** `spectator.query` returns `T | null` and these
specs dereferenced it directly.

The obvious fix is the wrong one here. Adding `?.` everywhere makes every absence assertion
vacuous: `expect(x?.getAttribute('aria-required')).toBeNull()` passes just as happily when
`x` is null, so the checkbox-group test — whose whole point is that ARIA defines no
aria-required on plain `group` — would stay green even if the group never rendered. So:

- assertions about a VALUE use `?.`, since `toBe(...)` still fails on undefined;
- assertions about ABSENCE either assert existence first, or go through a helper that throws
  when the element is missing, which also narrows the type for free.

**TS7006 (1).** Typed the spec's `setup` parameter as tinymce's `Editor`.

One production change: `base-wrapper-field`'s `controlContainer.control` is itself nullable —
a subclass rendered outside a form directive has no container control to ask. Pre-existing,
surfaced because this branch touched the line.

strict-gate: PASS. edit-content: 2425 tests across 116 files, lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ced it (#37464)

Three E2E specs failed — the required-error assertion on the binary, file and image
fields. A true positive: this branch broke their page object.

The locator was `.error-message small`. AC-208's whole point is that `.error-message`
stops existing: the file field's own error markup was replaced by the footer every field
type now shares, where the <small> carries `p-field-error` itself rather than sitting
inside a wrapper. So the selector matched nothing and the assertion timed out waiting for
an element that renders correctly under a different shape.

The audit for `.error-message` covered libs/edit-content and missed apps/dotcms-ui-e2e.
This was the only stale locator there; `.hint-message` has none.

Verified in the browser against a content type with all three field types required: before
a save attempt each renders 0 errors, after one each renders exactly 1 `small.p-field-error`,
and `.error-message small` matches nothing anywhere on the page. REQUIRED_FIELD_ERROR is
unchanged — the text was never the problem, only where the test looked for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Documentation PR changes documentation files Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

1 participant