Skip to content

Drop node-sass support - #41112

Closed
julien-deramond wants to merge 5 commits into
mainfrom
main-jd-drop-node-sass-support
Closed

Drop node-sass support#41112
julien-deramond wants to merge 5 commits into
mainfrom
main-jd-drop-node-sass-support

Conversation

@julien-deramond

@julien-deramond julien-deramond commented Dec 19, 2024

Copy link
Copy Markdown
Member

Warning

This PR is heavily draft. It prepares the modifications for when we'll drop node-sass support

Context

Sass has begun deprecating certain features (see #40962 and #40849), which currently generate warning messages during compilation with Sass.

The first major breaking change for us came in Sass 1.79.x, where the blue(), red(), and green() functions were deprecated. These functions were replaced by the color.channel() function, which is not supported by node-sass, and where we can't use any workaround on our side.

As a result, addressing these warnings requires us to drop node-sass support.

Bootstrap v6 development has not yet started, which raises an important consideration: Dart Sass v2 might be released before Bootstrap v6. If that happens, it may become necessary to remove node-sass support in the final Bootstrap v5.x release rather than waiting for v6. This is because Bootstrap v6 will likely introduce a significant set of new features, whereas a v5.x release can focus on resolving compatibility issues. Dropping node-sass in the last v5.x release ensures that all v5 content remains accessible to node-sass users while aligning with Dart Sass's latest updates.

Even if it is clearly a breaking change, for node-sass users, with the right communication, I think this could be an acceptable solution for everyone.

It'll also mean that for everyone, cascading the node-sass removal, Bootstrap users will need to use @use instead of @import, but also @use <color;math;map;etc.>.

Description

This PR drops node-sass support and bump the sass dependency to the latest release.

Following these changes, users will need to use @use instead of @import, and use @use Sass color, math, map, etc. modules.

Sub-tasks

  • Check the diff between dist/css/bootstrap.css from the main branch and this one
  • Check if we need to do something about rfs repository (and vendor dir here)
  • Documentation build locally, with the docs job, and with Netlify
  • Check the documentation to remove node-sass specific content (if any)
  • Add somewhere in the documentation that Bootstrap doesn't support node-sass anymore
  • After the merge of this PR, update the examples repository

Type of changes

  • Breaking change (fix or feature that would change existing functionality)

Checklist

  • I have read the contributing guidelines
  • My code follows the code style of the project (using npm run lint)
  • My change introduces changes to the documentation
  • I have updated the documentation accordingly
  • (N/A) I have added tests to cover my changes
  • All new and existing tests passed

Live previews

Related issues

Closes #40962
Closes #40849

@julien-deramond
julien-deramond force-pushed the main-jd-drop-node-sass-support branch 2 times, most recently from 1816145 to f232df5 Compare December 19, 2024 13:59
}
}
@include color-mode(dark, true) {
--custom-color: #{mix($indigo, $blue, 50%)};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the mix($indigo, $blue, 50%) because color.mix($indigo, $blue, 50%) doesn't return a value as an hex string, but a color that should be transformed to a hex string. I don't think it's necessary to add such complexity for now. Let's see at the end of the PR if we reintroduce something like that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the mix($indigo, $blue, 50%) is not appropriate in this as css and bootstrap has feature of colour-mix which include the change or mix in colour using hex string but you can't use colour.mix to change colour into hex string

Comment thread package.json Outdated
@julien-deramond
julien-deramond force-pushed the main-jd-drop-node-sass-support branch 2 times, most recently from 8ec183e to 343bc9e Compare December 24, 2024 08:16
Comment thread .bundlewatch.config.json
{
"path": "./dist/css/bootstrap-reboot.min.css",
"maxSize": "3.25 kB"
"maxSize": "3.5 kB"

@julien-deramond julien-deramond Dec 24, 2024

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes here are for now due to the introduction of color.* functions that return the color as a rgb values instead of an hex value. The strings for the fill values of SVGs are a bit longer, so the bundle is a little bit bigger. For instance:

4698,4699c4698,4699
<   --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
<   --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
---
>   --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%28109.8, 168, 253.8%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
>   --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%28109.8, 168, 253.8%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");

However, it doesn't seem to be a regression so far.

@mrholek

mrholek commented Dec 27, 2024

Copy link
Copy Markdown
Contributor

@julien-deramond Sass color functions don't round particular values in RGB colors (Please check this - sass/dart-sass#2456). You chose to round values in the following functions:

@function to-rgb($value) {
  @return math.round(color.channel($value, "red", $space: rgb)), math.round(color.channel($value, "green", $space: rgb)), math.round(color.channel($value, "blue", $space: rgb));
}
@function luminance($color) {
  $rgb: (
    "r": math.round(color.channel($color, "red", $space: rgb)), // stylelint-disable-line scss/at-function-named-arguments
    "g": math.round(color.channel($color, "green", $space: rgb)), // stylelint-disable-line scss/at-function-named-arguments
    "b": math.round(color.channel($color, "blue", $space: rgb)) // stylelint-disable-line scss/at-function-named-arguments
  );

  @each $name, $value in $rgb {
    $value: if(divide($value, 255) < .04045, divide(divide($value, 255), 12.92), nth($_luminance-list, $value + 1));
    $rgb: map-merge($rgb, ($name: $value));
  }

  @return (map-get($rgb, "r") * .2126) + (map-get($rgb, "g") * .7152) + (map-get($rgb, "b") * .0722);
}

I recommend avoiding math rounds in those functions, as suggested by the Sass creators. It requires modifying the luminance function like this. The math.round is only required in nth()

@function luminance($color) {
  $rgb: (
    "r": color.channel($color, "red", $space: rgb), // stylelint-disable-line scss/at-function-named-arguments
    "g": color.channel($color, "green", $space: rgb), // stylelint-disable-line scss/at-function-named-arguments
    "b": color.channel($color, "blue", $space: rgb) // stylelint-disable-line scss/at-function-named-arguments
  );

  @each $name, $value in $rgb {
    $value: if(divide($value, 255) < .04045, divide(divide($value, 255), 12.92), nth($_luminance-list, math.round($value + 1)));
    $rgb: map-merge($rgb, ($name: $value));
  }

  @return (map-get($rgb, "r") * .2126) + (map-get($rgb, "g") * .7152) + (map-get($rgb, "b") * .0722);
}

@XhmikosR

This comment was marked as outdated.

@mrholek

mrholek commented Jan 19, 2025

Copy link
Copy Markdown
Contributor

@julien-deramond @XhmikosR I just finished the migration in my Bootstrap-based project, and I'm able to generate almost the same CSS as is available now. The only differences are in color: hex colors are now rgb, and the precision of the rgb colors is different than before.

Almost everything works like before. I can use whole library and override variables:

@use "bootstrap" with (
  $accordion-padding-x: 20rem
);

I can use particular components:

@use "alert";

But the problem occurs when I want to import a particular component and override its variables:

@use "alert" with (
  $accordion-padding-x: 20rem
);

Then I have the following error:

Error: This variable was not declared with !default in the @used module.
2 │   $accordion-padding-x: 20rem

What will you do with specific component variables? It's not possible to use certain components with custom variables. The only solution I came up with is to define component-related variables directly within the components. What do you think about it?

@julien-deramond

Copy link
Copy Markdown
Member Author

Closing this one. Superseded by #41512.

PieterjanDeClippel added a commit to MintPlayer/mintplayer-ng-bootstrap that referenced this pull request May 13, 2026
Bootstrap 5.3.x's SCSS triggers Sass 3.0 deprecations (`@import`, `mix()`
and other global builtins, `red()`/`green()`/`blue()`, `if()`, the legacy
JS API). Upstream's fix PR (twbs/bootstrap#41112) was closed without
merging; the cleanup landed only on v6-dev as part of a full rewrite
(twbs/bootstrap#40849, twbs/bootstrap#40962). Until we can move the
peer dep to Bootstrap v6, these IDs are silenced at each Sass compiler
invocation:

- apps/ng-bootstrap-demo/project.json — extends the existing demo
  `stylePreprocessorOptions.sass.silenceDeprecations` array (was just
  `["import"]`). Comment on `targets.build` explains the rationale and
  the upstream issue/PR numbers for future readers.

- tools/scripts/build-web-components.mjs — passes the same list to
  `sass.compile()` calls in the codegen-wc pipeline (which compiles
  `*.element.scss` and `*.styles.scss` files, some of which @import
  bootstrap partials).

- libs/mintplayer-ng-bootstrap/ng-package.json — adds
  `lib.sass.silenceDeprecations` for the umbrella primary entry point.

- libs/mintplayer-ng-bootstrap/ng-package.secondary.cjs — new shared
  module exporting `secondaryEntry(extra)` that builds each secondary
  entry point's ng-package config with the same silenced-deprecations
  list. Single source of truth for the 94 secondaries.

- 94 secondary entry-point configs renamed via `git mv` from
  `ng-package.json` to `ng-package.js` (the only ng-package extension
  ng-packagr supports for JS modules — `.cjs` is not scanned). Each
  is a two-liner: `module.exports = require('<rel>/ng-package.secondary.cjs').secondaryEntry();`
  with optional override for `styleIncludePaths` on `datatable` and
  `virtual-datatable`.

Net effect: `nx build ng-bootstrap-demo` goes from 1538 deprecation
warnings (incl. the per-entry-point "N repetitive deprecation warnings
omitted" rollups) to 0.

Removal trigger: when the bootstrap peer dep bumps to v6, drop the
`silenceDeprecations` arrays from all four locations above (the
shared `.cjs` comment lists them).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PieterjanDeClippel added a commit to MintPlayer/mintplayer-ng-bootstrap that referenced this pull request May 13, 2026
…335)

* fix(dock): isolate intersection glyphs to splitters in the same layer (#326)

renderIntersectionHandles() paired every horizontal divider with every
vertical divider across the entire shadow root, including dividers from
floating panes. Coincidental on-screen alignment between a docked
splitter and a floating pane's splitter (or between two floating panes)
produced a phantom grip between two splitters that don't actually cross.

Reject cross-layer (h, v) pairs by tagging each DividerInfo with the
layer of its containing element. Layer key is read via
splitter.closest('.dock-floating')?.dataset.path — splitters carry no
layer prefix on their own data-path, but the floating wrapper does
(formatted as f:N by renderFloatingPanes).

The corner-resize fast path is untouched: it acts on an already-
validated pair from a previous render.

Covered by mint-dock-manager.intersections.spec.ts (4 specs, regression-
positive — confirmed to fail when the guard is disabled) and
dock-intersections.spec.ts e2e (Chromium + Firefox).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release): bump @mintplayer/ng-bootstrap to 21.33.0

Carries the dock cross-layer intersection-glyph fix (#326).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(build): silence Bootstrap 5.3 SCSS deprecation warnings

Bootstrap 5.3.x's SCSS triggers Sass 3.0 deprecations (`@import`, `mix()`
and other global builtins, `red()`/`green()`/`blue()`, `if()`, the legacy
JS API). Upstream's fix PR (twbs/bootstrap#41112) was closed without
merging; the cleanup landed only on v6-dev as part of a full rewrite
(twbs/bootstrap#40849, twbs/bootstrap#40962). Until we can move the
peer dep to Bootstrap v6, these IDs are silenced at each Sass compiler
invocation:

- apps/ng-bootstrap-demo/project.json — extends the existing demo
  `stylePreprocessorOptions.sass.silenceDeprecations` array (was just
  `["import"]`). Comment on `targets.build` explains the rationale and
  the upstream issue/PR numbers for future readers.

- tools/scripts/build-web-components.mjs — passes the same list to
  `sass.compile()` calls in the codegen-wc pipeline (which compiles
  `*.element.scss` and `*.styles.scss` files, some of which @import
  bootstrap partials).

- libs/mintplayer-ng-bootstrap/ng-package.json — adds
  `lib.sass.silenceDeprecations` for the umbrella primary entry point.

- libs/mintplayer-ng-bootstrap/ng-package.secondary.cjs — new shared
  module exporting `secondaryEntry(extra)` that builds each secondary
  entry point's ng-package config with the same silenced-deprecations
  list. Single source of truth for the 94 secondaries.

- 94 secondary entry-point configs renamed via `git mv` from
  `ng-package.json` to `ng-package.js` (the only ng-package extension
  ng-packagr supports for JS modules — `.cjs` is not scanned). Each
  is a two-liner: `module.exports = require('<rel>/ng-package.secondary.cjs').secondaryEntry();`
  with optional override for `styleIncludePaths` on `datatable` and
  `virtual-datatable`.

Net effect: `nx build ng-bootstrap-demo` goes from 1538 deprecation
warnings (incl. the per-entry-point "N repetitive deprecation warnings
omitted" rollups) to 0.

Removal trigger: when the bootstrap peer dep bumps to v6, drop the
`silenceDeprecations` arrays from all four locations above (the
shared `.cjs` comment lists them).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(scss): use node_modules/ form for bootstrap @imports + Some Sass setup

Component SCSS files used a fragile, depth-dependent relative path to pull
in Bootstrap partials (e.g. `@import "../../../../../node_modules/bootstrap/scss/functions"`).
Replace those with the workspace-relative `@import "node_modules/bootstrap/scss/..."`
form so the path is stable regardless of where the file lives.

To keep both this new form AND the existing `bootstrap/scss/...` form (used
by the published `_bootstrap.scss`, which consumers import as a public API)
resolving at Sass-compile time:

- libs/mintplayer-ng-bootstrap/ng-package.secondary.cjs — `secondaryEntry()`
  now seeds `styleIncludePaths` with both `REPO_ROOT` (workspace root) and
  `NODE_MODULES`. Per-entry-point overrides still merge on top.
- tools/scripts/build-web-components.mjs — sass.compile() `loadPaths` extended
  with `repoRoot` for the codegen-wc pipeline.
- apps/ng-bootstrap-demo/project.json — `stylePreprocessorOptions.includePaths`
  now lists `.` and `node_modules` (the `libs/mintplayer-ng-bootstrap/datatable/src`
  entry was no longer needed once node_modules resolution is in place).

34 library `*.component.scss` files (190 @imports) and the demo's
`swiper.component.scss` (5 @imports) were rewritten by a one-shot regex.
`has-overlay.component.scss` got the same treatment for its `@angular/cdk`
import. The published `_bootstrap.scss` was deliberately reverted — it
ships to consumers, who expect the Node-style `bootstrap/scss/...` form.

VS Code Ctrl+click on the new imports requires the Some Sass extension —
its link provider is what understands workspace load paths:

- .vscode/extensions.json — recommends `somewhatstationery.some-sass`.
- .vscode/settings.json — sets `somesass.workspace.loadPaths` to `[".", "node_modules"]`
  and turns on `somesass.scss.links.enabled` + `somesass.sass.links.enabled`
  (both default to `false`; without them, Ctrl+click falls back to VS Code's
  built-in relative-from-current-file resolution which breaks for our form).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(datatable): @use 'datatable-sort' breaks when demo build resolves library SCSS from source

The demo's Angular sass plugin compiles `datatable.component.scss` and
`virtual-datatable.component.scss` directly from source (TypeScript path
mapping points the library imports at the workspace tree, not the
ng-packagr dist). It uses `apps/ng-bootstrap-demo/project.json`'s
`stylePreprocessorOptions.includePaths` for load paths, which no longer
lists `libs/mintplayer-ng-bootstrap/datatable/src` — so the bare
`@use 'datatable-sort'` couldn't resolve.

Switch the two import sites to the workspace-relative form
`@use 'libs/mintplayer-ng-bootstrap/datatable/src/datatable-sort'`, which
resolves the same way in both build paths:

- Demo build: via the demo's `.` include path (workspace root).
- ng-packagr (datatable + virtual-datatable entry points): via the
  `REPO_ROOT` load path seeded by `secondaryEntry()` in
  `ng-package.secondary.cjs`.

That makes the per-entry-point `styleIncludePaths` overrides in
datatable/ng-package.js (`'src'`) and virtual-datatable/ng-package.js
(`'../datatable/src'`) redundant — both files now use the bare
`secondaryEntry()` like the other 92 secondaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(scss): use plain relative paths for the datatable-sort @use

Simpler than the workspace-relative form: `../datatable-sort` from the
datatable component, `../../../datatable/src/datatable-sort` from the
virtual-datatable component. Both Ctrl+click natively in any editor
without needing a load-path-aware language server.

The cross-entry-point dependency in virtual-datatable is unchanged — it
already shared this partial via the old `styleIncludePaths: ['../datatable/src']`
override that was dropped in the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(navbar): noscript SSR menu empty since #280 signal migration

The RxJS → signals refactor in #280 changed showNavs() to return false
eagerly when windowWidth is null (SSR), which makes [@slideUpDown] bake
style="height: 0" into the SSR HTML. The existing noscript CSS reveal
rules only fire on :has(:checked) / :focus-within, so noscript users saw
an empty menu at every viewport on first paint.

Restore wide-mode visibility with a new @each $grid-breakpoints loop in
the .navbar.noscript SCSS block that overrides height + overflow at
@media (min-width: navbar-expand-bp). Small-mode click reveal
(:has(:checked)) and small-mode keyboard reveal (:focus-within) paths
are unchanged.

Spec captured at docs/prd/navbar-noscript.md.

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

* docs(datatable): PRD — virtual+regular merge and Vidyano-style selection

Trims the broader draft on PR #314 (origin/docs/prd-datatable-tree-and-selection,
commit 71ae51c) to the scope agreed for issues/#326: merge virtual-datatable
into datatable behind a [virtualScroll] switch, unify [(data)] and [dataSource]
under a single [fetch] callback input, and add Vidyano-style checkbox selection
(no select-all; deselect-all header checkbox appears checked when >=1 row is
selected; selection stores objects compared via consumer-supplied compareWith
to survive pagination/virtual-scroll re-fetches).

Tree mode and the apps/api ASP.NET Core demo backend from the broader draft
are deferred to follow-up PRDs.

Tracks #306

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

* test(tooltip): import BsTooltipDirective in test harness component

Silence NG0303 ("Can't bind to 'bsTooltip' since it isn't a known
property of 'div'") that fired in stderr on every test run. The harness
component is standalone, so the directive must appear in its own
`imports`, not just in the TestBed module's `imports`.

The test itself was already passing; only the stderr noise is removed.

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

* feat(datatable): merge virtual-datatable in + Vidyano-style selection (#306)

Folds <bs-virtual-datatable> into <bs-datatable> behind a [virtualScroll]
input switch. Both modes now share the same data contract:

  fetch = input.required<(req: PaginationRequest) => Promise<PaginationResponse<T>>>();

replacing both [(data)] (paginated) and [dataSource] (virtual). The
component owns the call cadence: one fetch per settings change in
paginated mode; viewport-driven page fetches in virtual mode.

Adds checkbox selection per the Vidyano UX:
- [selectable]='none' | 'single' | 'multiple'
- [(selection)]: TData[] — stores object refs as fetched
- [compareWith]: (a, b) => boolean — required for non-'none' modes so
  selection survives cross-page / cross-scroll re-fetches where object
  identity isn't preserved
- Header cell is empty when selection is empty. When >=1 row is selected
  a single deselect-all toggle appears, rendered already-checked. Any
  toggle event on it clears the entire selection.
- Per-row + header cells use <bs-toggle-button type="checkbox"> for the
  Bootstrap-themed check style.

Row template normalises to <ng-template bsRowTemplate let-row> with
<td> cells inside (the old [bsRowTemplateOf]="data()" feed-from-input
shape is gone; the component owns the data). The virtual-only
bsVirtualRowTemplate directive is dropped.

The @mintplayer/ng-bootstrap/virtual-datatable secondary entry-point
is deleted entirely. The aria-rowcount/-rowindex coverage from its
spec is ported under datatable/.

Demo migrated: apps/ng-bootstrap-demo/.../datatables.component is now a
single <bs-datatable> driven by a 'pagination' | 'virtualScroll' <bs-select>
toggle, with multi-select wired to ArtistService and compareWith on id.

Tree mode and the apps/api ASP.NET Core backend from the broader draft
on PR #314 are intentionally deferred — see PRD.

Tracks #306. PRD: docs/prd/datatable-virtual-merge-and-selection.md.

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

* fix(datatable): center select toggles + reserve header space (no layout shift)

Two regressions from the bs-toggle-button swap:

1. The toggle's <label class="form-check"> was top-aligned inside its
   inline-block host because Bootstrap's form-check has a 2px
   margin-bottom that left an unused band below the label, and the
   row's <td> had Bootstrap's default `vertical-align: top`.
   Fix: vertical-align: middle on .select-col cells, inline-flex +
   align-items:center on the host bs-toggle-button, and ::ng-deep
   strip the label's margin-bottom.

2. The header's deselect-all toggle was conditionally rendered via
   @if (hasAnySelection()), so the <th> grew the moment the first
   row was checked — every column below jumped down by a row.
   Fix: render the toggle unconditionally and gate visibility with
   [class.invisible]="!hasAnySelection()". The toggle still
   participates in layout when hidden, so no shift.

The deselect-all handler now guards on `value === false` because the
two-way [isToggled]="hasAnySelection()" binding triggers the change
event on parent-side writes too — only the user-driven false
transition should clear the selection.

Verified at /advanced/datatables:
- Header th height before vs after first-row selection: 42.5px → 42.5px
  (heightDelta 0).
- Input vertical center vs td vertical center: 258 vs 257.

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

* fix(datatable): re-wire scroll + column-width sync when virtual mode activates

The ported `setupScrollSync` and `setupColumnWidthSync` ran in
`ngAfterViewInit` — once, at component init. With the unified
component the user can flip into virtual mode *later*, by which point
ngAfterViewInit has already run. The `cdk-virtual-scroll-viewport`
that the setup methods need to query doesn't exist in the DOM until
the `@if (virtualScroll())` branch renders, so the queries returned
null and the sync never wired. Result: header columns sized to header
text, body columns sized to row content, no alignment.

Move the setup into an `effect()` that fires whenever `virtualScroll()`
is true, defers one frame for the viewport DOM to mount, and cleans up
its own listeners + MutationObserver on the next toggle-off (or on
component destroy). `setupScrollSync` / `setupColumnWidthSync` now take
a `teardowns` array parameter instead of pushing to a single
component-lifetime cleanup list, so a pagination→virtual→pagination
cycle doesn't leak listeners.

The width-sync logic itself is unchanged (still matches master's
`6a537c19` reference: maxWidths[] accumulator, save/restore scrollLeft,
width:max-content override during measurement, apply min-widths to all
visible rows).

Verified at /advanced/datatables:
- First switch into virtual mode: header/body column-left offsets match
  pixel-perfect (max misalignment 0px across 4 columns).
- After scroll into a not-yet-fetched page: still 0px.
- After pagination→virtual→pagination→virtual cycle: still 0px.

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

* test(e2e): regression test for datatable virtual-mode column alignment

Three Playwright scenarios that lock in the fix from fd2d1f1:

1. Paginated → virtual: after flipping the mode select, header column
   left positions must match the first body row's column lefts within
   1px (sub-pixel rounding tolerance).
2. Pagination ↔ virtual cycle: the second entry into virtual mode used
   to miss the sync wiring because ngAfterViewInit had already fired.
3. Vertical scroll into a not-yet-fetched page: alignment must hold
   after the MutationObserver-driven re-measure runs.

The mintplayer.com artist API is mocked via page.route() so the spec
is deterministic and offline-friendly. Names are deliberately wider
than the "Artist" column header to force the alignment problem.

Verified by reverting the fix locally: the first scenario fails with
"Received: 216" (216px misalignment) on both Chromium and Firefox.
With the fix in place, all 6 cases (3 scenarios × 2 browsers) pass.

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

* docs(datatable): PRD — measure-once resizable columns

Adds a "Resizable columns — measure-once" section to the existing
datatable PRD and updates the overview + component API table.

Spec at a glance:
- Each column has three runtime states: Pristine → Auto-sized →
  User-locked. Transitions are one-way; content-driven re-measure
  never fires again once a column is sized.
- Source of truth: `columnWidths = signal<Map<string, number>>(new
  Map())` keyed by `BsDatatableColumnDirective.name()`. Position-
  indexed accumulators are gone.
- Initial sizing runs once on the first row-mount mutation (virtual
  mode) or first response render (paginated mode): measures each
  column as `max(header, …visible td)` using the existing
  `width: max-content !important` trick, then disconnects the
  MutationObserver.
- Resize handle: 6px vertical bar absolutely positioned at the right
  edge of each <th>, role="separator", touch-action: none, focusable,
  pointerdown + setPointerCapture + pointermove + pointerup with
  stopPropagation() so the sort click underneath doesn't fire.
- Keyboard: Arrow ±10px, Shift+Arrow ±1px. aria-valuenow tracks live
  pixel width.
- No adjacent-column compensation — only the dragged column moves.

New component inputs: `resizableColumns: boolean = true`,
`minColumnWidth: number = 40`.

Replaces the current `setupColumnWidthSync` maxWidths "only grows,
never shrinks" loop, which had to be removed to honour the
"widths remain the same across page/scroll" promise.

Out of scope for v1: persistence beyond component lifetime,
double-click-to-fit, drag-to-reorder, per-column min-width override.

Tracks #306.

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

* docs(datatable): PRD — promote double-click-to-fit into scope

Adds a "Double-click to fit" subsection inside the resizable-columns
section. `dblclick` on the resize handle re-measures the column from
its currently visible content (the same `measureColumnWidth(name)`
helper used for initial auto-sizing) and pins the result, transitioning
the column to (or keeping it in) User-locked.

In virtual mode the measurement only sees the rendered range, so
fitting after scrolling can yield a different width if the new
viewport's content varies; that's the intended behaviour.

Keyboard equivalent: `Home` on the focused handle triggers the same
fit path so the gesture isn't pointer-only.

Markup snippet updated to include the `(dblclick)` binding for
internal consistency.

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

* feat(datatable): measure-once resizable columns

Implements the resizable-columns spec from
docs/prd/datatable-virtual-merge-and-selection.md § Resizable columns.

Behaviour
- Three-state lifecycle per column (Pristine → Auto-sized → User-locked);
  transitions are one-way and content-driven re-measurement never fires
  again once a column is sized. The previous "only grows, never shrinks"
  maxWidths[] accumulator in setupColumnWidthSync is gone.
- Source of truth: `columnWidths = signal<ReadonlyMap<string, number>>`
  keyed by `BsDatatableColumnDirective.name()`. Position-based indexing
  was fragile under column reorder.
- Initial auto-size runs on the first row-mount mutation (virtual) or
  first non-empty response (paginated) via an effect that short-circuits
  once every column has a width. Disconnects the MutationObserver after
  the first apply; subsequent re-applies on virtual recycle just write
  the locked pixel value to the new cell.
- table-layout: fixed + width / min-width / max-width all set to the
  locked value pins the column exactly. With table-layout: auto, body
  content wider than the pinned width would have stretched the column
  past the user's chosen value; fixed honours the width and clips via
  overflow:hidden + text-overflow:ellipsis on the cell.

Gestures (handle is a 6px vertical `<div role="separator">` at the right
edge of each <th>):
- Pointer: pointerdown + setPointerCapture + pointermove (rAF-coalesced)
  + pointerup/cancel. Drag updates only the dragged column; no
  adjacent-column compensation.
- Double-click: re-measures the column from currently visible content
  and pins the result. In virtual mode this is the rendered range only.
- Keyboard: ArrowLeft/Right ±10px (Shift = ±1px), Home = fit.
  Enter/Space are consumed no-ops so the handle is a real focus stop
  instead of bubbling to the <th>'s sort handler.
- Click on the handle is consumed (stopPropagation) so it can't trigger
  a sort. pointerdown also stopPropagation's.

Component API:
- New input `resizableColumns: boolean = true`
- New input `minColumnWidth: number = 40` (drag clamp + keyboard floor)

Demo + aria spec fix
The `name` input on `BsDatatableColumnDirective` is aliased to
`bsDatatableColumn`, so `<ng-template bsDatatableColumn name="Name">`
silently doesn't bind (all columns ended up with name() === '' and a
single Map entry). Switched the demo + datatable.aria.spec harness to
the working `<ng-template bsDatatableColumn="Name">` syntax.

Verified live at /advanced/datatables in virtual mode:
- Drag −50px: header & body both shrink 50px, alignment preserved.
- Keyboard ArrowRight ×5: column grows 50px (5 × 10px step).
- Home: column re-measures to natural max(header, body) width.
- Click on the handle does NOT toggle sort (aria-sort stays 'none').
- All 6 alignment regression tests (datatable-virtual.spec.ts) still
  pass on Chromium + Firefox.

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

* refactor(datatable): rename column.templateRef → headerTemplateRef

Clarifies that the `<ng-template bsDatatableColumn="Name">…</ng-template>`
content is the *header* template (rendered inside the <th>) and that
the directive's value is the data-property name (matched against the
row JSON, used as `SortColumn.property`).

Internal rename only — the public directive surface (selector,
inputs) is unchanged. Updates the two template outlet sites and the
matching PRD snippet. Adds a JSDoc on the directive explaining the
two roles.

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

* revert(datatable): keep templateRef name (rename broke header rendering)

The previous rename (templateRef → headerTemplateRef) was meant to be
internal-only, but it left the headers empty in both modes — direct
inspection via window.ng shows the directive instance does have a
TemplateRef under headerTemplateRef, yet `<ng-container
*ngTemplateOutlet="column.headerTemplateRef">` rendered nothing. Stale
Vite cache vs. directive-side rename, most likely.

Revert to the working `templateRef` property name; keep the JSDoc on
the directive that documents the contract (value = data property
name; template content = header template). The "column header
template" naming lives in the doc comment now.

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

* fix(datatable): pad measured column widths by 0.25rem to prevent clipping

Sub-pixel rendering rounds offsetWidth down by 1-3px. With
table-layout: fixed + overflow: hidden, pinning the column to that
exact value squeezed the content and clipped the last few px of long
labels — most noticeable on double-click-to-fit.

Add a 4px (0.25rem at default 16px base) safety pad to every measured
width in `measureWidths`. Applies to both initial auto-sizing and the
dblclick / Home fit gestures.

Verified at /advanced/datatables, virtual mode:
- Auto-sized Artist column: 196 → 200, content uncropped (scrollWidth
  equals clientWidth across visible rows).
- Shrink → dblclick fit roundtrip: returns 40 → 200, matches initial.

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

* fix(scheduler): never pan from mouse; mouse-on-event always drags

Mouse on an event tile incorrectly entered pan mode after a >10px move
within 600ms, hijacking the gesture from the drag state machine. Panning
is a touch-only affordance; with a mouse the user should be able to drag
events immediately and navigate via scroll / wheel / nav controls.

Strips the mouse pan-candidate state and branches from InputHandler so
mouse handlers forward straight to the drag manager. Touch path
(short-move → pan, hold 600ms → drag/create) is unchanged.

PRD: docs/prd/scheduler-improve-pan.md

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

* refactor(datatable): extract shared header + row-cell templates

Pulls the duplicated <thead> subtree and per-row <td> rendering out of the
virtual and paginated branches into #headerCells / #rowCells ng-templates
referenced via *ngTemplateOutlet. Trims the compiled component by ~1.8 KB
raw (gzip is unchanged) and removes the maintenance hazard of keeping the
two header copies in sync.

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

* chore(demo): drop unused BsFormControlDirective import in tile-manager

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

* feat(toggle-button): mirror aria-* attributes from host onto inner <input>

The host element is a plain Angular tag (no shadow DOM), so screen readers
compute the focused control's accessible name from the inner <input> only.
`aria-label` / `aria-labelledby` / `aria-describedby` set on <bs-toggle-button>
never reached AT. ngAfterViewInit now copies every aria-* attribute to the
input and a MutationObserver keeps the mirror in sync with [attr.aria-…]
bindings that change at runtime. SSR-safe; observer torn down via DestroyRef.

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

* feat(datatable): SSR-safe rAF + row checkbox aria-label + resize aria-valuemax

- Guard the virtual-mode wire-up effect and the initial-auto-size effect
  with isPlatformServer(PLATFORM_ID) so requestAnimationFrame doesn't run
  during SSR.
- Add [selectionLabel]="(row) => …" input; row checkbox aria-label is
  "Select <label>" or "Select row N" fallback when omitted. Row index
  is now threaded through the #rowCells template context.
- Observe host width via ResizeObserver to drive aria-valuemax on the
  resize-handle separators; previously only valuenow/valuemin were set,
  leaving role=separator's value scale ambiguous.

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

* docs(prd): split <bs-toggle-button> into <bs-checkbox> and <bs-radio>

PRD for the multi-step refactor that introduces two narrowed per-family
public components and reduces <bs-toggle-button> to a styling-only wrapper.
Covers component hierarchy, type unions, input contracts, value accessor
split, group directives with exportAs + explicit-vs-ancestor [group]
resolution, ARIA mirroring relocation, ReactiveFormsModule integration,
demo page scenarios with <bs-code-snippet> blocks, and the migration
table for all 15 current consumers.

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

* refactor(toggle-button): reduce to styling wrapper

Per PRD docs/prd/toggle-button-split.md § 5 commit 1. <bs-toggle-button>
becomes a passive component whose only job is to scope the Bootstrap
form-check SCSS via :host ::ng-deep onto descendant content. No template
file, no inputs, no value accessor, no group directive, no type union.

This commit alone breaks every consumer of the old API (datatable,
multiselect, color-picker, 12 demo pages). The subsequent commits in the
chain add <bs-checkbox> and <bs-radio> and migrate every consumer.

Removed exports (breaking):
- BsToggleButtonValueAccessor
- BsToggleButtonGroupDirective
- BsCheckStyle

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

* feat(checkbox): add BsCheckboxComponent + group + accessor

Per PRD docs/prd/toggle-button-split.md § 5 commit 2. New package at
@mintplayer/ng-bootstrap/checkbox exporting:

- BsCheckboxComponent — type 'checkbox' | 'switch' | 'toggle_button'.
  Template wraps <bs-toggle-button> (from @mintplayer/ng-bootstrap/toggle-button)
  around the <label>/<input>; the wrapper's :host ::ng-deep SCSS provides
  Bootstrap form-check styling. ARIA host→input mirroring preserved
  (MutationObserver, SSR-guarded). Resolves [group] as explicit input
  ?? DI-injected ancestor (skipSelf:true).
- BsCheckboxGroupDirective — [bsCheckboxGroup] with exportAs and a
  [name] input. Implements ControlValueAccessor for multi-mode: emits
  string[] of checked values; writeValue sets isToggled on matching
  children.
- BsCheckboxValueAccessor — host directive on bs-checkbox for single-mode
  (standalone) boolean form binding.
- BsCheckboxType — narrowed type union.

Name resolution is mode-aware: standalone uses the component's own
[name]; in-group uses the group's [name] with [] suffix auto-applied.

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

* feat(radio): add BsRadioComponent + group + accessor

Per PRD docs/prd/toggle-button-split.md § 5 commit 3. New package at
@mintplayer/ng-bootstrap/radio exporting:

- BsRadioComponent — type 'radio' | 'toggle_button'. Mirrors bs-checkbox's
  structure (template wraps <bs-toggle-button>, ARIA host→input mirroring,
  resolvedGroup = explicit input ?? DI ancestor). NO [name] input — name
  comes from the resolved group. NO per-instance value accessor — radios
  always bind through the group.
- BsRadioGroupDirective — [bsRadioGroup] with exportAs, [name] input, and
  ControlValueAccessor implementation. Emits the selected radio's value
  on change; writeValue walks children and sets isToggled on the match.
- BsRadioType — narrowed type union.

Standalone <bs-radio> (no group resolved) renders but doesn't bind — by
design; radios exist to express one-of-N within a group.

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

* refactor(datatable): migrate from <bs-toggle-button> to <bs-checkbox>

Per PRD docs/prd/toggle-button-split.md § 5 commit 4. Two sites:

- datatable.component.html: <bs-toggle-button type="checkbox"> Deselect-all
  header toggle + per-row select checkbox both swap to <bs-checkbox>. The
  [isToggled] / (isToggledChange) bindings carry over unchanged; aria-label
  and [attr.aria-label] still mirror to the inner <input> via the new
  component's ARIA mirroring.
- datatable.component.ts: imports flip from BsToggleButtonComponent to
  BsCheckboxComponent.

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

* refactor(multiselect, color-picker): migrate to <bs-checkbox>

Per PRD docs/prd/toggle-button-split.md § 5 commit 5.

- multiselect: in-dropdown item toggle (was the implicit-default checkbox)
  swaps to <bs-checkbox>; [ngModel] binding carries over.
- color-picker: accessibility-toggle for the hue/saturation sliders
  (type="switch") swaps to <bs-checkbox type="switch">.

Both component .ts files update their imports.

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

* refactor(demo): migrate non-showcase toggle-button consumers

Per PRD docs/prd/toggle-button-split.md § 5 commit 6. Swaps every demo
page that uses <bs-toggle-button> (except the dedicated showcase, which
is deleted in the next commit). 13 .html files + matching .ts imports
+ spec mocks updated.

Checkbox-style migrations (12 pages): basic/color-picker,
basic/forms/range, basic/forms/multi-range, basic/forms/select,
basic/placeholder, basic/table, animations/color-transition,
advanced/navigation-lock, advanced/pipes/has-property,
advanced/pipes/linify, advanced/tile-manager, advanced/splitter,
overlay/offcanvas — all swap to <bs-checkbox>.

Radio-style migration (1 page): overlay/shell's three radio_toggle_button
toggles become <bs-radio type="toggle_button"> inside a
<bs-button-group bsRadioGroup name="shellState" [(ngModel)]="shellState">.

Drive-by cleanup: tab-control.component.spec.ts had dead MockComponent /
MockDirective imports for BsToggleButtonComponent + BsToggleButtonGroupDirective
that its template never used; both removed.

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

* feat(demo): add /forms/checkbox + /forms/radio, delete /forms/toggle-button

Per PRD docs/prd/toggle-button-split.md § 5 commit 7 and § 6 demo
scenarios. Two new routes replace the old `/forms/toggle-button`
showcase; no redirect.

/forms/checkbox covers 7 scenarios per § 6.1:
1. Single template-driven ([(ngModel)] + boolean)
2. Single reactive ([formControl] + boolean)
3. type="switch"
4. type="toggle_button" (standalone)
5. Group adjacent template-driven (string[] via [bsCheckboxGroup])
6. Group adjacent reactive
7. Group non-adjacent (table row pattern with exportAs + [group])

/forms/radio covers 4 scenarios per § 6.2:
1. Group adjacent template-driven (string via [bsRadioGroup])
2. Group adjacent reactive
3. Group with type="toggle_button" (radio-style button toggle)
4. Group non-adjacent (table row pattern)

Each scenario is rendered as a live demo paired with <bs-code-snippet>
blocks (TS + HTML) per the existing /forms/multi-range pattern.
forms.routes.ts and the nav menu add `/forms/checkbox` + `/forms/radio`
in place of `/forms/toggle-button`. The /forms/toggle-button folder is
deleted; the demo app's bundle restores cleanly after this commit.

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

* docs(changelog): document <bs-toggle-button> split

Per PRD docs/prd/toggle-button-split.md § 5 commit 8. Records the new
@mintplayer/ng-bootstrap/checkbox and /radio packages under Added, plus
the public-API removals (BsToggleButtonValueAccessor,
BsToggleButtonGroupDirective, BsCheckStyle) and the full migration table
under Breaking.

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

* fix(checkbox, radio): sync writeValue + radio auto-uncheck via DOM truth

Two bugs caught via playwright smoke test of the new demo pages and the
migrated shell route:

1. BsRadioGroupDirective.onChildChange read stale isToggled() signals.
   The browser auto-unchecks sibling radios in the same `name` group
   without firing change events on them, so a previously-checked sibling's
   isToggled cache stayed true. The group's `find(r => r.isToggled())`
   returned the stale match instead of the freshly-clicked one, so
   ngModel/formControl kept emitting the old value. Re-sync every child's
   isToggled signal from `input.checked` on every change, then emit.

2. Both group directives' writeValue fired before contentChildren had
   populated, so an initial ngModel/formControl value (e.g. shell's
   shellState='auto') never reflected on the DOM — none of the children
   were visible to writeValue at call time. Introduce a currentValue
   signal plus an effect that syncs each child's isToggled whenever
   currentValue OR the children set changes. The effect runs after view
   init regardless of timing.

Demo: rename the two `name="fruit"` radio groups in /forms/radio to
"fruitTemplate" / "fruitReactive" so they're separate HTML radio groups
(the browser was treating them as one and cross-unchecking).

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

* fix(datatable): rows span host width via trailing spacer cell

When pinned column widths sum to less than the host width, the rows
visually under-filled their container. The naive fix (min-width: 100%
on the table) either stretches all pinned columns proportionally — which
breaks the resize-freeze semantics — or, at narrow viewports, blows the
body past the viewport because isResponsive=false leaves no scroll
wrapper.

This change appends an aria-hidden .bs-datatable-spacer cell to every
header and body row. Under table-layout: fixed the unsized spacer
absorbs leftover width without redistributing it, so pinned widths stay
frozen. The CSS pairs width: max-content with min-width: 100% (the
inverse order triggers a CDK virtual-scroll wrapper layout loop). The
bs-table wrapper also gets unconditional overflow-x: auto in resizable
mode so wider-than-host pinned widths scroll inside the table region
instead of expanding the page body. Footer colspan bumped by 1.

Closes the deferred column-fill PRD via a CSS+DOM hybrid rather than
the originally-recommended JS proportional-distribution algorithm —
browser table-layout does the math, no scrollLeft preservation needed,
no ResizeObserver for stretch math.

Regression: datatable.spacer.spec.ts asserts the spacer DOM contract
(tag, class, position, aria-hidden) and footer colspan across paginated
(with and without selection) and virtual modes.

* fix(checkbox, radio): wrap contentChildren predicate in forwardRef

`BsRadioGroupDirective` queries `BsRadioComponent` children via
`contentChildren(BsRadioComponent, ...)`; the component imports back
to inject the directive, so the two modules form an ES-module cycle.
Whichever side webpack/vite evaluates first sees `undefined` for the
other's exports at field-initialiser time, the recorded query predicate
becomes null, and ng-mocks' `MockDirective` fails directive-metadata
extraction with "Can't construct a query for the property 'radios' of
'BsRadioGroupDirective2' since the query selector wasn't defined" —
breaking the shell.component.spec.ts in the demo app, which is what
the CI workflow tripped on.

Same latent bug exists between `BsCheckboxGroupDirective` and
`BsCheckboxComponent`; nothing was mocking that pair yet but the
trap was set. Fixed both: predicate wrapped in `forwardRef(...)` with
the generic type argument supplied at the `contentChildren<T>(...)`
call site so we keep the typed signal without an `as Type<T>` cast.

* chore(ng-package): drop dead lib.sass block from umbrella ng-package.json

The umbrella `ng-package.json` only configures the primary entry, whose
`src/index.ts` exports enums/providers/types and pulls in no Bootstrap
SCSS (the only `*.scss` files under `src/` are utilities that just
`@use "sass:map"`). The `lib.sass.silenceDeprecations` block therefore
had nothing to silence.

The same list lives in `ng-package.secondary.cjs` for the secondary
entries, where it does the actual work — that is where the Bootstrap
5.3 deprecation noise is actually generated. Updated its cleanup-on-v6
comment to drop the now-stale "AND in the umbrella ng-package.json"
breadcrumb so a future bootstrap-v6 bump doesn't go hunting for a place
that no longer exists.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dart-sass 1.80.0+ throwing a lot of deprecations New deprecations in Sass 1.79

4 participants