Skip to content

feat(table): loading layer without an overlay (skeleton rows, delayed progress bar) - #118

Merged
sdcorejs merged 8 commits into
mainfrom
feat/table-loading
Oct 28, 2026
Merged

sdcorejs merged 8 commits into
mainfrom
feat/table-loading

Conversation

@sdcorejs

@sdcorejs sdcorejs commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

Implements the sd-table loading design handoff (option 1e, .sdcorejs/handoff/table-loading/). The dark overlay with a 100px spinner is gone: it covered the header, filters and scrollbars, stopped 56px above the bottom whatever the footer, and was nearly invisible in dark mode.

  • No rows yet (first read, after an empty result or an error, before the configuration is ready): skeleton rows under the real header at once. Desktop rows are real <tr>/<td> of a matNoDataRow template, so they line up with the columns without measuring; mobile cards show up to 3 skeleton cards.
  • Rows on screen: they stay. A read longer than showDelay (300 ms) dims the table body to 55% (pointer input off, buttons in cells included; keyboard untouched) and shows a 2px progress bar under the last header row, kept for at least minDuration (400 ms) when the new result has rows. Faster reads show nothing. A read that replaces the running one (sort, paging, filter) keeps an already visible bar through its debounce; the debounce alone never shows it.
  • Header, inline filters, sort, quick search, external filters, paginator and scrollbars stay usable throughout.
  • Status region: a hidden span.sd-table-live is always in the DOM and reads "Đang tải dữ liệu bảng", then "Đã tải xong {count} dòng" (new key core.component.table.loaded, five locales).
  • Tree and expand buttons stay in place while their children/details load (16px spinner, aria-busy), so keyboard focus no longer falls back to the page; .lds-ring is removed.
  • Local read errors use the same sd-data-state block and sdDataStateTemplate as server tables (retry = reload()); the warning toast stays.
  • Unchanged: loading(), data-loading, aria-busy, readState, retryRead(), reload(), the paginator lock and the read logic.

New API (additive): SdTableOptionLoading on option.loading and SD_TABLE_CONFIGURATION.loading (skeleton, skeletonRows, showDelay, minDuration, dim, merged field by field), host attribute data-loading-view (none / skeleton / refresh), tokens --sd-table-skeleton-bg, --sd-table-skeleton-highlight, --sd-table-progress-track, CSS variable --sd-table-loading-dim.

Commits (one per step of the handoff)

  1. feat(table): loading view state, option, status region (no visual change)
  2. feat(table): desktop skeleton rows, dimmed rows, progress bar, tokens
  3. feat(table): mobile skeleton cards, dimming, progress bar
  4. feat(table): 16px spinner for tree and expand buttons
  5. feat(table): local read errors with sd-data-state
  6. docs(table): sd-table.md, CHANGELOG [Unreleased], showcase controls, screenshots
  7. fix(table): findings of the independent review (see below)
  8. fix(table): empty results announced through the status region; the desktop tree toggle gets a name and aria-expanded

All library changes start in versions/v19 and roll out with npm run sync; check:sync passes on every commit.

API consistency review

Independent review done (two fresh reviewers given only the code, the spec and AGENTS.md; findings verified by the author). It confirmed the naming, typing and merge order against the conventions below and found no break. Its findings are fixed in commit 7:

  • Dimmed rows still took clicks on sd-button (it sets pointer-events: auto): the table body now dims as one layer and blocks the pointer on every descendant (mobile cards too); nested tables no longer dim twice.
  • A held bar survived an option change; a replacing read dropped the bar/skeleton during its debounce without rows (empty block flash).
  • Timer callbacks now run inside the zone (apps with ignoreChangesOutsideZone); the bar is measured after render after a mobile → desktop switch; a percentage style.maxHeight no longer counts as pixels.
  • Status region: no announcement for skeletons that resolve without a running read, stale counts cleared, expanded tree rows counted, core.component.table.loaded-one.
  • Docs: data-loading-view in docs/E2E-ATTRIBUTES.md, sd-table.md wording, CHANGELOG bullets placed back under their entry.

Two open points of the review are fixed in commit 8:

  • Empty results: the .c-no-data-row block was role="status" but inserted together with its text, which screen readers often skip. The block loses its live role; the always-present .sd-table-live region now reads the title of the empty block (no-results, choose-filter-hint or no-data) when a read ends without rows, also after a read faster than showDelay and under a custom sdDataStateTemplate.
  • The desktop tree toggle was an unnamed button: it now has aria-label (new key core.component.table.tree-toggle, “Mở/đóng dòng con”, five locales) and aria-expanded, like the mobile one.

Review notes on the API:

  • Compared with: the option sub-objects SdTableOptionPaginate / SdTableOptionReload / SdTableOptionExpand / SdTableOptionTree, the app-wide ISdTableConfiguration.paginate / .filter / .images, the host attributes data-autoid / data-loading, the i18n keys core.component.table.* and the --sd-table-* tokens.
  • Naming: SdTableOptionLoading in models/table-option-loading.model.ts, exported through models/index.ts like table-option-tree.model; option.loading sits next to option.reload / option.paginate; ISdTableConfiguration.loading mirrors paginate / filter.
  • Defaults: option > configuration > default, field by field, like paginate.pageSize and filter.*; invalid values count as not set.
  • Types: booleans (skeleton, dim), milliseconds (showDelay, minDuration) and a count (skeletonRows). No outputs or callbacks, so the on<Action> convention does not apply.
  • Name overlap: option.loading (configuration) vs the loading() signal follows the existing option.reload vs reload() pair.
  • New public members on SdTable (read-only signals or template data): loadingSettings, loadingView, liveMessage, skeleton, genericSkeleton.
  • New i18n keys follow core.component.table.*: loaded, loaded-one, tree-toggle (the mobile toggle keeps mobile.expand-row, which names the row).
  • v20–v22 come from v19 through the sync; no per-major shim.
  • Compatibility: the default look changes and internal classes are removed (.c-loading, the overlay mat-spinner, .lds-ring, @keyframes lds-ring, .sd-tree-spinner). Both are listed in the CHANGELOG ("Before upgrading" and "Removed") for apps that restyled them with ::ng-deep.

Deviations from table-loading.md

  • Skeleton alignment uses matNoDataRow rows, neither option (a) (a measured grid after the table) nor (b) (fake rows in the data source). Skeleton cells of sticky columns do not stick while scrolling sideways.
  • The body dims as one layer on tbody, not per tr (same sticky-safe result, one transition, no double dimming of nested tables); cursor: progress shows over the body only, so the header does not look busy.
  • The expand button uses sd-button [loading]: an 18px spinner without a track, color="primary" while loading (sd-button forces the spinner colour per color), and no aria-disabled (sd-button has no input for it).
  • Agreed additions: skeleton while the first read of an option waits in its debounce and before the configuration is ready (no empty-state flash); the scheduled replacing read keeps a visible bar.
  • Kept as agreed: the local-error toast (the error is then announced twice).

Tests

  • New table.component.loading.spec.ts covers section 13 of the spec (items 1–18) and table-loading.util.spec.ts covers merging, timers and skeleton shapes. The overlay test in table.component.spec.ts now checks the status region.
  • Full v19 suite with --code-coverage: 8360/8360 (seed 159142) after the review fixes. Table specs on each commit: 846 / 858 / 862 / 865 / 868 / 880 / 884. Full suite after commit 8: 8364/8364 (seed 978709).
  • npm run lint, npm run test:theme, npm run check:scss-hex, npm run check:sync, npm run test:scripts, showcase unit tests (331/331).
  • Showcase (section 15) with Playwright: 150 ms shows nothing; 600 ms reads show the bar about 300 ms after the read starts and hold it about 400 ms; a sort during a 3 s read keeps the bar on; error, retry and skeleton; empty, then reload; fill mode (14 skeleton rows for a 507px body); sticky column with horizontal and vertical scrolling during refresh; dark, graphite, forced colors, reduced motion; mobile at 390px with touch; tree and expand by keyboard and mouse.
  • Not run: NVDA / VoiceOver listening test, showcase e2e suite. No showcase demo has a grouped header, so that case is covered by a Karma spec only.

The showcase "Loading / empty / error" example gains a fake-delay switch (0 / 150 / 600 / 3000 ms), a "Lần tới lỗi" toggle and sorting, to try these by hand.

Screenshots

Before After
First load
Reload with rows
Mobile, first load
Mobile, reload
Tree children loading (focus)
Expand details loading (focus)
Local read error

More checks: dark · forced colors · graphite · fill mode, first load · fill mode, refresh · sticky column scrolled during refresh · custom error template

The "before" shots use a library built from main; the demos' fake delays were stretched for the shots only (the demo code is unchanged except the "Loading / empty / error" controls).

🤖 Generated with Claude Code

nghiatt15 and others added 8 commits October 28, 2026 20:55
Foundation of the sd-table loading layer (design handoff 1e, kept in
.sdcorejs/handoff/table-loading/). The table looks the same after this
commit; the next ones draw the new layer.

- SdTableOptionLoading (skeleton, skeletonRows, showDelay, minDuration,
  dim) on option.loading and SD_TABLE_CONFIGURATION.loading, merged field
  by field (option, configuration, defaults); invalid values count as
  not set.
- loadingView() (host data-loading-view: none / skeleton / refresh),
  derived from loading(): skeleton while a table without rows loads,
  waits for its first read or for its configuration; refresh once rows
  have been loading for showDelay, kept for minDuration unless the
  result has no rows. A read that replaces the running one keeps a
  visible bar through its debounce, but the debounce never starts it.
  Timers run outside the zone and stop on destroy.
- loading(), data-loading and aria-busy are unchanged.
- span.sd-table-live, always in the DOM, announces "loading" when the
  layer shows and "loaded {count}" (new key core.component.table.loaded
  in the five locales) when the read ends.

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

The dark overlay with a 100px spinner covered the header, filters and
scrollbars and stopped 56px above the bottom whatever the footer was.
Nothing covers the table any more:

- No rows yet: skeleton rows under the real header, as rows of a
  matNoDataRow template, so they line up with the columns (grouped
  headers included) without measuring and the footer stays below. 5
  rows, or enough to fill a fill-mode / style.maxHeight table, at most
  paginate.pageSize and 20. Bar widths follow the column type and
  align. Before the configuration is ready a generic 5 x 4 skeleton
  shows, and the empty block no longer flashes before the first read.
- Rows on screen, after showDelay: body rows (not footers) dim to
  --sd-table-loading-dim and ignore the pointer; a 2px bar sits in
  .c-container right under the last header row (--sd-table-head-h,
  measured while it shows), so it does not scroll sideways.
  .c-container isolates the sticky z-indexes.
- Tokens --sd-table-skeleton-bg, --sd-table-skeleton-highlight and
  --sd-table-progress-track (one color-mix for light and dark).
  Animations stop under prefers-reduced-motion; forced-colors uses
  system colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- No rows yet: up to three skeleton cards (skeletonRows, at most 3) in
  the same .sd-mobile-list, with the frame of .sd-mobile-card and three
  bars; the list drops role="list" while it only holds them.
- Refresh: the list dims and ignores taps (scrolling and the keyboard
  still work); the 2px bar sits at the top of the card list, under the
  mobile command header, in a 0-height sticky anchor so it never
  pushes the cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tree toggle and the expand button used to be replaced by a 40px
.lds-ring while their children or details loaded: keyboard focus fell
back to the page, the row grew taller and the ring was a pale #cceeff.

- Tree toggle (desktop): the button stays; while loading its chevron
  becomes a 16px CSS spinner (2px arc in --sd-table-spinner on
  --sd-table-progress-track), with aria-busy and aria-disabled. A still
  25% arc under prefers-reduced-motion.
- Expand (desktop): the 35px sd-button stays and shows its loading
  spinner ([loading], primary while loading), aria-busy on sd-button.
  onExpand / onTreeToggle already ignore clicks while loading.
- Mobile tree and expand buttons swap hourglass_empty for the same
  16px spinner and keep their disabled state.
- Removed .lds-ring, @Keyframes lds-ring and .sd-tree-spinner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local table whose items() throws or returns no array showed a line of
text and a grey "Tải lại" button, unlike the sd-data-state error of a
server table. It now uses the same sd-data-state error block (title,
message, "Thử lại" calling reload()) and the same sdDataStateTemplate
branch; hideReadError still hides it. The warning toast stays.

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

- sd-table.md: a Loading section (views, timings, SdTableOptionLoading,
  tokens, motion and forced colours), the loading option row,
  data-loading-view in the E2E attributes, the accessibility notes and
  the configuration provider example; the overlay and Material spinner
  lines are gone.
- CHANGELOG [Unreleased]: the loading change in Before upgrading and
  Highlights, Added (option, attribute, i18n key, tokens), Changed and
  Removed (internal loading classes).
- Showcase "Loading / empty / error": a fake-delay switch (0 / 150 /
  600 / 3000 ms), a "Lần tới lỗi" toggle that fails the next read, and
  sorting on two columns; note, props and English description updated,
  example sources regenerated.
- Before/after screenshots in .sdcorejs/handoff/screenshots/table-loading/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Dimmed rows still took clicks on sd-button (expand, group toggle, row
  commands, buttons in cells): sd-button sets pointer-events: auto on its
  host and inner button. The table body (tbody) now dims as one layer and
  blocks the pointer on every descendant; the same on mobile cards. This
  also stops nested tables from dimming twice and replaces one opacity
  transition per row with one per table. .sd-table-dim is .c-table--dim.
- The progress bar no longer survives an option change (a held bar came
  back over the new rows), and a read that replaces the running one keeps
  the bar or the skeleton through its debounce even without rows, so the
  empty block no longer flashes (skeleton: false, after an empty result).
- Timers stay outside the zone but run their callback inside it, so apps
  with ignoreChangesOutsideZone also render them.
- The bar is measured after render when the table was not in the DOM yet
  (mobile to desktop during a refresh); z-index comments corrected.
- A percentage style.maxHeight falls back to the default skeleton rows.
- Status region: "loading" only once a read really runs (no "loaded N"
  on every page load for synchronous local tables), a new read clears an
  earlier count, tree tables count expanded rows, and a new key
  core.component.table.loaded-one (five locales) for one row.
- Docs: data-loading-view in docs/E2E-ATTRIBUTES.md, sd-table.md wording
  (skeleton is immediate; focus kept on desktop), CHANGELOG bullets moved
  back under their release entry. Generic skeleton gets the table
  surface; showcase example sets option.loading explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e tree toggle

- The empty block (.c-no-data-row) loses role="status"/aria-live: its text
  was inserted together with the role, which screen readers often skip. The
  always-present .sd-table-live region now reads the title of the empty block
  (no-results, choose-filter-hint or no-data) when a read ends without rows,
  also after a read faster than showDelay and under a custom
  sdDataStateTemplate.
- The desktop tree toggle gets aria-label (new key
  core.component.table.tree-toggle in the five locales) and aria-expanded,
  like the mobile toggle.
- Specs, sd-table.md and CHANGELOG [Unreleased] updated; rolled out to
  v20-v22 with npm run sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sdcorejs
sdcorejs merged commit 0f947fb into main Oct 28, 2026
7 checks passed
@sdcorejs sdcorejs mentioned this pull request Oct 28, 2026
2 of 3 tasks
@sdcorejs
sdcorejs deleted the feat/table-loading branch November 4, 2026 03:51
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