Skip to content

fix(chip): apply removable on every path and use one error gate (CH1) - #103

Merged
sdcorejs merged 3 commits into
mainfrom
fix/chip-ch1-a11y
Oct 24, 2026
Merged

sdcorejs merged 3 commits into
mainfrom
fix/chip-ch1-a11y

Conversation

@sdcorejs

Copy link
Copy Markdown
Owner

Summary

This is CH1, the first of three sd-chip PRs in the design handoff .sdcorejs/handoff/design-icon-chip-table/ (review of 22/10/2026 on 769f9ca, spec in chip.md). It fixes the behaviour and a11y issues A1–A8 and the specs D4, mostly by applying the fixes that already ship in sd-chip-calendar. No input, output or default changes, so it can ship as a patch. CH2 (input experience) and CH3 (visuals, cleanup, onSelect removal) follow in separate PRs.

The first commit adds the handoff bundle and the specs, which fail on their own. The second commit is the fix, npm run sync, the CHANGELOG, docs and the showcase.

What changed

Item Before After
A1 A removable predicate reached mat-chip-row as a function, which is always truthy. Backspace / Delete on a chip, or Backspace twice from the empty input, removed a locked chip. Each chip gets its own boolean (isRemovable(item)), and onRemove checks it again.
A2 @if (item) hid 0 but still counted it. track $index rebuilt every chip after the removed one, so focus jumped. chips() drops only null, undefined and ''. Chips are tracked by value.
A3 A model [1, 2] plus a typed “1” became [1, 2, '1']. Duplicates compare as strings.
A4 Setting the model to null kept the chips and sent the old array. null / undefined clears the chips and sets formControl to [].
A5 The outline used dirty || touched || submitted and the message used touched, so a submit without touching the field showed red with no message. Also aria-invalid="true" was set before any interaction. One gate, isErrorShown(): invalid, and touched or the parent form submitted. It drives the outline, <mat-error>, the hideInlineError icon and aria-invalid.
A6 The ✕ (cancel) had no name, and sd-icon hid it with aria-hidden. close icon named “Xoá {value}” (core.form.chip.remove, in vi/en/ja/ko/zh).
A7 The wrapper div was in the Tab order with no role or name, disabled included. The wrapper is layout only. With sdViewDef, the face is a role="button" that Enter or Space opens. When disabled it leaves the Tab order and gets aria-disabled.
A8 With hideInlineError, the hidden error text had no id and the input had no aria-describedby. Two duplicate @if blocks. One matSuffix element holds the icon and the hidden text (with errorId), and the input points aria-describedby at it.

Bug that was already on main (not in chip.md)

While checking “form reset to null” on the showcase, we found that mat-chip-grid writes back into the control on blur. As the value accessor of formControl, it waits for a setTimeout after every blur and then writes the text of the rendered chips into the control. The consequences:

  • Number items became strings ([1, 2] → ['1', '2']) with no modelChange.
  • A form reset made while the input had focus was undone.

The grid is now bound to its own gridControl, which nothing reads. It still needs an ngControl to compute its error state on every check. sd-chip marks the field touched when focus leaves it; moving between the input and the chips does not count. A side benefit is that Material's aria-invalid host binding no longer fights the A5 gate. The grid's required is set in code, because a [required] attribute on [formControl] would attach Angular's RequiredValidator to gridControl.

API consistency review

Following the mandatory naming and public API consistency review in AGENTS.md.

  • Compared with:
    • sd-chip-calendar (same template and the source of the fixes): isErrorShown() and its gate, errorId, isRemovable(), removeLabel(), onViewKeydown, the view-trigger pattern, the close icon and the template @let _removable now match. The @let in sd-chip-calendar was renamed from _itemRemovable to _removable to match.
    • sd-date: same rule for when the error shows.
  • Checked: input/output names and aliases, event payloads, defaults, two-way binding (model), public exports and entry points.
  • Inputs and outputs: none added, renamed or retyped. No defaults changed.
  • Behaviour change: model set to null / undefined now clears the chips and writes [] to formControl (A4). An untouched field with no model also holds [] instead of null.
  • New public members (additive): errorId, chips, isErrorShown(), isRemovable(), removeLabel(), onViewKeydown, onFocusOut(), gridControl, chipGrid. Each name matches sd-chip-calendar wherever it has one.
  • i18n: core.form.chip.remove ({value}) mirrors core.form.chip-calendar.remove ({date}). It is a separate key because the sd-chip-calendar strings name a day, which this one should not. Apps that supply a full custom I18nCatalog must add it, because the type is Record<I18nKey, string>.
  • Public exports: unchanged. SdRemovableChipPipe is no longer used by the template but stays exported until CH3, which removes it as a breaking change.
  • v19–v22: rolled out with npm run sync. npm run check:sync is green.
  • Independent review: pending.

Deviations from chip.md

  • Blur fix: the grid write-back fix above is outside the md, and was agreed before it was made.
  • A5: the md describes the fix as a template binding. That was not enough, because MatChipInput binds aria-invalid = ngControl.invalid as a host binding. Giving the grid its own control solves it.
  • A7: the view face also leaves the Tab order when disabled. The md does not mention this.
  • A8: the md says to merge the two @if blocks. Merging them directly would break matSuffix projection, so the icon and the hidden text share one matSuffix wrapper instead.
  • A2: chips() also drops exact duplicates from the display, because chips are tracked by value. The model is never rewritten.
  • Dev-mode NG0956: replacing the whole chip list with different values now triggers this warning about track. That is the expected result of tracking by value, and sd-chip-calendar does the same.

Verification

All of this ran locally on exact Node 22.22.3:

  • Karma v19 with coverage: npx ng test sdcorejs-angular --watch=false --browsers=ChromeHeadlessCI --code-coverage passes 7897/7897. Statements 90.01%, Branches 79.35%, Functions 90.64%, Lines 92.44%.
  • v22 (Angular / Material 22.1.5): the chip, chip-calendar and form-generic specs pass 674/674.
  • Other checks, all green:
    • npm run lint
    • npx ng build sdcorejs-angular
    • check:i18n, check:i18n-parity (1398 keys × 5 languages)
    • npm run test:theme, npm run check:scss-hex, npm run test:showcase-examples
    • npm run sync + npm run check:sync
  • New specs: 27 cases in chip.behaviour.spec.ts, using real keydown, focus and submit events. Before the fix they failed (19, plus 2 for the blur write-back).
  • Showcase (/forms/chip/examples, 768px light and dark):
    • Backspace twice and Delete do not remove the locked “Quản trị” chip.
    • The ✕ is named “Xoá Biên tập”.
    • A submit without touching shows message, outline and aria-invalid together.
    • The hideInlineError field points aria-describedby at the hidden text.
    • “Đặt lại” while the input has focus resets to null and untouched.

Not verified:

  • Forced-colors mode.
  • A real screen reader. Accessible names were checked in the DOM only.
  • The 1280px layout. It was checked through the DOM only, because the preview pane scales it down too far to read.

v20 and v21 run in CI.

Showcase

  • New section “Chip khoá” with a removable predicate.
  • “Bắt buộc & số tối thiểu” is now a <form [formGroup]> with Gửi / Kiểm tra / Đặt lại buttons and a second hideInlineError field.
  • demoSectionCount goes from 5 to 6, and the example sources were regenerated.

🤖 Generated with Claude Code

nghiatt15 and others added 3 commits October 24, 2026 01:14
Add the design handoff for sd-icon, sd-icon-picker, sd-chip and sd-table
under .sdcorejs/handoff/design-icon-chip-table/ (review of 22/10/2026 on
769f9ca).

Add chip.behaviour.spec.ts for CH1. It drives A1-A8 through real
keydown, focus and submit events instead of calling onAdd / onRemove
with fake events (D4). It also covers mat-chip-grid writing the text of
the rendered chips back into the control on blur, which turned up while
checking "form reset to null" on the showcase. Rename the spec that
claimed to test sd-label but checks mat-label.

These specs fail until the fix commit that follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apply the sd-chip-calendar fixes to sd-chip (design handoff CH1, A1-A8),
with no API change:

- removable: each mat-chip-row gets its own boolean, and onRemove checks
  it again. A predicate used to reach Material as a function, which is
  always truthy, so Backspace / Delete removed locked chips.
- chips(): every item except null, undefined and '' is a chip, so 0
  shows. Chips are tracked by value.
- Duplicates compare as strings, so [1] and a typed "1" are one chip.
- A null or undefined model clears the chips and sets formControl to [].
- isErrorShown() is one gate (invalid, and touched or the parent form
  submitted) for the outline, <mat-error>, the hideInlineError icon and
  aria-invalid.
- The remove button is named "Xoá {value}" (core.form.chip.remove, in
  five locales) and uses the close icon.
- The wrapper is layout only. With sdViewDef, the face is a
  role="button" that Enter or Space opens.
- With hideInlineError, the hidden error text has an id that
  aria-describedby points at.

Also fix a bug that was already on main: mat-chip-grid, the value
accessor of formControl, wrote the text of the rendered chips into the
control after every blur. Numbers became strings and a form reset made
while the input had focus was undone. The grid now has a gridControl of
its own, and sd-chip marks the field touched when focus leaves it. This
also removes Material's aria-invalid host binding from the error gate.

Roll out with npm run sync. Rename the @let of sd-chip-calendar to
_removable, as in sd-chip. Add a "Chip khoá" showcase section and a
submit / reset / hideInlineError demo, with regenerated example sources.
Update CHANGELOG [Unreleased] and sd-chip.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the Node 24.22.1 pin and the single AGENTS.md guide (#102).
SYNC-STATUS.md conflicted only on the Updated At timestamp of both
syncs; resolved by running npm run sync again on the merged tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sdcorejs
sdcorejs merged commit 505d048 into main Oct 24, 2026
7 checks passed
@sdcorejs
sdcorejs deleted the fix/chip-ch1-a11y branch October 24, 2026 17:48
@sdcorejs
sdcorejs restored the fix/chip-ch1-a11y branch October 24, 2026 17:49
@sdcorejs
sdcorejs deleted the fix/chip-ch1-a11y branch October 24, 2026 17:49
sdcorejs pushed a commit that referenced this pull request Oct 24, 2026
#103 (CH1) was squash-merged into main as 505d048, whose tree is the
same as the CH1 branch tip c238754 this branch was built on (tree
da5b7c3). Record main as a parent so the PR diff shows only CH2. The
ours strategy keeps this branch's tree, which already holds everything
on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sdcorejs added a commit that referenced this pull request Oct 26, 2026
…sd-page-list (E1) (#106)

* feat(badge): export SdBadgeData and use it for sd-table useBadge (PR 1)

First step of the page-layouts handoff (doc 02). SdBadgeData describes one
badge passed through an option instead of an <sd-badge> tag; sd-page
[badges] will reuse it in the next step.

- components/badge: new SdBadgeData { title, type, color, icon, label,
  tooltip }, exported from @sdcorejs/angular/components/badge. title keeps
  null so objects of the old internal Badge shape still type-check.
- components/table: UseBadgeFunc / UseBadgeValuesFunc return SdBadgeData;
  SdTableDisplay.badge gains label and tooltip; the cell binds label and
  uses the badge tooltip only when the column has no tooltip.
- Docs: SdBadgeData model section in sd-badge.md, badge shape in
  sd-table.md, CHANGELOG entry.
- Rolled out to v20/v21/v22 with npm run sync.

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

* feat(page): sd-page P0 — backLink, badges, width and new slots (PR 2)

Second step of the page-layouts handoff (doc 01).

sd-page
- backLink (URL or router commands) renders a real <a routerLink> before
  the title; the title, description and badges still come from inputs.
  headerLeft still replaces the left area and then drops backLink with a
  dev-mode warning.
- [badges]: SdBadgeData[] next to the title as md badges, round by
  default; badges without a title are skipped; more than two or a repeated
  type warn in dev mode.
- width: full (default) | wide 1200 | medium 960 | narrow 720. The column
  limit includes 24px side padding; subheader and footer use the same
  padding. The host gets sd-page--wide/medium/narrow.
- Slots: [sdSubheader] (in the header block, outside the scroll area),
  [sdAside] (320px sticky column, under the body below 1200px of
  viewport), [sdFooterLeft]/[sdFooterRight] (footer pinned to the bottom,
  hidden while empty), and [sdHeaderLeft]/[sdHeaderRight] aliases.
- The title renders in <h1 class="T20M"> with its margin reset; the
  component is OnPush.
- The new slots are projected without wrappers, so a page that uses none
  of them keeps its header and body DOM; only a hidden empty <footer> is
  added.

Also
- i18n: core.common.back and core.module.layout.page.actions in the five
  catalogs.
- Theme: component tokens page-divider, page-back-fg, page-back-hover-bg.
- Docs: new sd-page.md, sd-layout.md row, CHANGELOG (Added and Changed).
- Showcase: components/page demo (4 sections) and registry entry. The
  registry spec demo total goes from 450 to 457: 4 new sections plus 3
  chip sections added by #103-#105 without updating the count (showcase
  Karma does not run in CI). Route-shell test counts updated.
- Rolled out to v20/v21/v22 with npm run sync.

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

* feat(page): sd-page-detail and sd-page-form, toc and leave guard (PR 3)

Third step of the page-layouts handoff (docs 04 and 05).

sd-page
- sd-page-detail and sd-page-form are selectors of SdPageComponent; the
  host tag picks the default width (sd-page full, sd-page-detail wide,
  sd-page-form medium). An explicit width still wins.
- aside: 'end' (default) | 'start'. start puts the aside column first
  from 1200px of viewport; below that it stays under the body.
- toc lists the titled top-level sd-sections of the body in the aside
  column as a <nav> (core.module.layout.page.toc), scrolls to a section
  on click and marks the section at the top of the scroll area with
  aria-current. The list is collected after render, because a nested
  section only reaches its parent's DOM when the parent view renders.
  Hidden below 1200px. sd-anchor is not used: it wraps its own items and
  owns its scroll area, so it cannot list sections of the page body.

Leave guard
- sdPageFormLeaveGuard (CanDeactivateFn) asks through
  SdUnsavedChangesService with the Core confirm dialog (SdConfirmService)
  and the doc 05 texts, by passing its own adapter for that prompt.
  SD_UNSAVED_CHANGES_CONFIRMATION_ADAPTER and sdUnsavedChangesGuard keep
  their behaviour.
- SdUnsavedChangesConfirmOptions.adapter: per-call adapter.
- registerSdUnsavedChangesForm(..., { dirty: 'value' }): dirty only while
  the value differs from the snapshot; default 'control' is unchanged.

Also
- i18n: core.module.layout.page.toc and leave.{title,message,stay,leave}
  in the five catalogs.
- Docs: sd-page.md (selectors, aside, toc, leave guard),
  sd-unsaved-changes.md, sd-layout.md row, CHANGELOG.
- Showcase: two more page demos (detail with the aside first, form with
  a table of contents); registry demo total 459.
- Rolled out to v20/v21/v22 with npm run sync.

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

* feat(page-list): sd-page-list with the E1 height chain and sd-table fill mode (PR 4)

Fourth step of the page-layouts handoff (doc 03, acceptance E1 of doc 10).

sd-page-list (modules/layout)
- Wraps an sd-page, forwards headerRight, sdSubheader and the footer
  slots, and renders the router outlet for the detail child route.
- Height chain: the body is a 16px padded flex column (gap 12px) and the
  only scroll area; the main table (the last direct sd-table, or an
  [sdPageListMain] wrapper, chosen again by a MutationObserver) takes the
  space left.
- compact() = autoCompact (default true) and a viewport lower than 720px.
  The main table's external filter then starts collapsed; content reads
  compact() from SD_PAGE_LIST to fold its own summary.
- detail="page" hides the list (display: none) while a child route is
  open, then restores every scroll position it captured and the focus of
  the element that opened the child. detail="drawer" keeps the list.
- SD_PAGE_LIST gives the content compact, childActive and detail.

sd-table
- SD_TABLE_LAYOUT (new, in the table entry so the table does not depend
  on modules/layout) names a fill target. The table in it gets
  sd-table--fill: flex 1 1 0, a measured min-height (fixed parts +
  header + 5 rows, kept in sync by Mutation/ResizeObserver), a data area
  of at least header + 5 rows and a footer of at least 48px.
- filter.externalFilterCollapsed sets the initial collapsed state; the
  user's choice holds until the default changes. Not applied when
  collapsible is false.
- One-line rows are 36px (--mat-table-row-item-container-height),
  instead of Material's 52px, as agreed for every sd-table.

Showcase
- /demo/page-list: full-viewport live list (128 seeded orders) with
  ?cfg= toggles for tabs, score card, quick search, external filter,
  selection, record count, page size, read state, autoCompact and detail;
  child route :id/detail as a page or a 600px drawer.
- e2e/page-list.e2e.ts: E1-1..E1-6 at 1440x960, 1440x800, 1280x768 and
  1440x650 (runs in the asia-bangkok project only; layout does not depend
  on the zone). E1-4 measures the data area because the external filter
  lives inside sd-table.
- components/page-list docs page (2 demos); registry and route-shell
  counts updated.

Docs: sd-page-list.md, sd-table.md (row height, fill mode,
externalFilterCollapsed), sd-layout.md, CHANGELOG (Added; Changed for the
36px rows). Rolled out to v20/v21/v22 with npm run sync.

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

* test(page-list): wait for page 2 rows before selecting in the E1-6 page test

On the CI runner the paginator label switched to 21-40 on click while the
rows were still those of page 1 until the delayed read landed, so the
test checked ĐH-2400 of page 1 and found the first row of page 2
unchecked after Back. Wait for the first row of page 2 (ĐH-2380) before
selecting.

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

* fix(page): render the sd-page header as <header>; run E1 on a bare sd-table screen

Two items of the handoff left out of the first four commits:
- doc 01 (a11y): the header of sd-page is a <header> element. Classes
  (c-page-header bg-white) and children are unchanged; the legacy DOM
  spec now checks the tag. sd-page.md and the CHANGELOG entry say so.
- doc 10 (E1): also run E1-1/2/3 on a screen with only sd-table (no quick
  search, no filter row, no footer buttons) at the four viewports.

v20-v22 are rolled out together with the merge of main that follows.

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

---------

Co-authored-by: nghiatt15_onemount <nghiatt15@onemount.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@sdcorejs sdcorejs mentioned this pull request Oct 28, 2026
2 of 3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants