Skip to content

TreeSelect: light-DOM chip/value slots + custom-template DX#378

Open
PieterjanDeClippel wants to merge 5 commits into
masterfrom
issues/#342-light-dom-templates
Open

TreeSelect: light-DOM chip/value slots + custom-template DX#378
PieterjanDeClippel wants to merge 5 commits into
masterfrom
issues/#342-light-dom-templates

Conversation

@PieterjanDeClippel

@PieterjanDeClippel PieterjanDeClippel commented May 30, 2026

Copy link
Copy Markdown
Member

What type of PR is this? (check all applicable)

  • 🍕 Feature
  • 🐛 Bug Fix
  • 📝 Documentation Update
  • 🧑‍💻 Code Refactor
  • 🔥 Performance Improvements
  • ✅ Test
  • 🤖 Build
  • 📦 Chore (Release)

Description

Follow-up to #342. The original itemTemplate/suggestionTemplate were render-callbacks whose DOM was inserted into the web component's shadow — which meant consumer content couldn't be styled by Bootstrap (styles don't cross the shadow boundary) or host Angular directives like cdkDrag. This reworks the selected-item (chip) and single-value templating to light-DOM <slot> projection (the Timeline bsTimelineContent pattern), so consumer content stays in the light DOM where CSS and directives work. No back-compat: the itemTemplate callback is removed.

Library

  • mp-tree-select: chips + single value render through <slot name="chips"> / <slot name="value"> (built-in chips as fallback); added public removeById(id). suggestionTemplate stays a callback because dropdown rows render inside the nested mp-treeview shadow (a second boundary; not changed).
  • Angular: keeps the structural-directive DX — *bsTreeSelectItem renders per selected node into slot="chips" via ngTemplateOutlet (context: node + remove()); <ng-content> also allows direct projection. bsTreeSelectSuggestion unchanged.
  • React: JSX children passthrough (project slot="chips" children). Vue: default <slot/> passthrough.

Demos

  • tree-select-drag-drop reworked into two connected bs-tree-selects whose custom chips (with a cdkDragHandle) drag between them (cdkDropListConnectedTo + transferArrayItem). Drag wiring is consumer-owned, not baked into the library.
  • The main /basic/tree-select page gains a Custom templates section showing *bsTreeSelectItem + *bsTreeSelectSuggestion.

Tests: WC unit 750/750; new Playwright drag-transfer spec (stable on chromium + firefox); custom-templates e2e; openTreeSelect now waits for the wrapper to wire the element (provider pushed) then calls open() — removing a click-vs-hydration race. Version bumps: web-components 1.5.0, ng 21.47.0, react 19.5.0, vue 3.5.0.

Related Tickets & Documents

Screenshots/Recordings

[Placeholder — voeg een opname toe van het tussen-twee-lijsten slepen van chips (tree-select-drag-drop) en van de 'Custom templates' sectie, in het Nederlands]

Checklist

  • Ran Synchronize N/A — no model/entity changes
  • Verified new functionality for the correct app roles N/A — no authorization changes
  • Ensured there are no database indexing errors N/A — no database changes

Added to documentation?

  • 📜 README.md
  • 📜 SERVICES.md
  • 📜 specific docs/ file
  • 🙅 no documentation needed

Inline doc comments on the new slot/template contracts; CLAUDE.md already covers the WC + wrapper architecture.

[optional] Are there any post-deployment tasks we need to perform?

None.

🤖 Generated with Claude Code


Update (commit 2 — demo styling): the light-DOM-vs-shadow design is unchanged — chips/value stay light-DOM <slot>s (so cdkDrag + page CSS apply); only the dropdown rows remain in the mp-treeview shadow. The basic-demo "Custom templates" chip is styled with page CSS (.ts-tpl-chip), not Bootstrap's .badge/.btn-close: those component classes aren't global in the ng demo app (they live inside the bs-* components — only reboot/utilities/grid/forms/buttons are global). The suggestion row renders in the treeview shadow, so it uses intrinsic elements (<strong>/<small>) instead of page classes.

)

Selected chips and the single-value display now project through light-DOM
<slot name="chips"> / <slot name="value"> instead of a shadow-DOM render
callback, so consumer content lives in the light DOM — Bootstrap styles and
directives (Angular CDK cdkDrag/cdkDragHandle) work. No back-compat: the
itemTemplate callback is gone.

- WC mp-tree-select: chips/value via slots (default chips as fallback);
  public removeById(). suggestionTemplate stays a callback (treeview shadow).
- Angular: keep the structural-directive DX — *bsTreeSelectItem renders per
  selected node into slot=chips via ngTemplateOutlet (ctx: node + remove());
  added <ng-content> for direct projection. suggestionTemplate unchanged.
- React: JSX children passthrough. Vue: default <slot/> passthrough.
- Demo: tree-select-drag-drop reworked into TWO connected bs-tree-selects whose
  custom chips (cdkDragHandle) drag between them (cdkDropListConnectedTo +
  transferArrayItem). Main demo gains a 'Custom templates' section showing
  *bsTreeSelectItem + *bsTreeSelectSuggestion.
- e2e: new drag-transfer spec (stable, chromium+firefox); custom-templates
  coverage; openTreeSelect waits for the wrapper to wire provider then calls
  open() (no click-vs-hydration race).
- version bumps: web-components 1.5.0, ng 21.47.0, react 19.5.0, vue 3.5.0.

WC unit 750/750; ng/react/vue + ng-demo build green; tree-select e2e 8/8 ×2.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the tree-select component across Angular, React, and Vue wrappers to support custom chips and single-value templates projected via light-DOM slots (slot="chips" and slot="value"), enabling seamless integration with Bootstrap classes and drag-and-drop libraries. Feedback on the changes highlights an Angular performance anti-pattern in tree-select.component.ts where calling itemCtx(node) directly in the template expression returns a new object reference on every change detection cycle. It is recommended to optimize this by computing and caching stable template contexts using a computed signal.

Comment on lines +93 to +106
/** Selected nodes as chips (multiple / checkbox modes; single mode has none). */
protected readonly chipNodes = computed<TreeNode[]>(() => {
const v = this.value();
return Array.isArray(v) ? v : [];
});

/** Context for a per-chip template instance; `remove` deselects via the WC. */
protected itemCtx(node: TreeNode): BsTreeSelectItemContext {
return {
$implicit: node,
query: '',
remove: () => this.elementRef()?.nativeElement.removeById(node.id),
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Calling a method like itemCtx(node) directly in the template expression [ngTemplateOutletContext] is an Angular performance anti-pattern. Because it returns a new object reference on every change detection cycle, it forces ngTemplateOutlet to re-evaluate and trigger change detection on all projected chips unnecessarily (e.g., when typing in the search box).

Instead, we can leverage Angular Signals by computing and caching the stable template contexts using a computed signal that only re-evaluates when the selected value actually changes.

Suggested change
/** Selected nodes as chips (multiple / checkbox modes; single mode has none). */
protected readonly chipNodes = computed<TreeNode[]>(() => {
const v = this.value();
return Array.isArray(v) ? v : [];
});
/** Context for a per-chip template instance; `remove` deselects via the WC. */
protected itemCtx(node: TreeNode): BsTreeSelectItemContext {
return {
$implicit: node,
query: '',
remove: () => this.elementRef()?.nativeElement.removeById(node.id),
};
}
/** Selected nodes as chips with stable template contexts (multiple / checkbox modes). */
protected readonly chipContexts = computed<{ node: TreeNode; ctx: BsTreeSelectItemContext }[]>(() => {
const v = this.value();
const nodes = Array.isArray(v) ? v : [];
const el = this.elementRef();
return nodes.map((node) => ({
node,
ctx: {
$implicit: node,
query: '',
remove: () => el?.nativeElement.removeById(node.id),
},
}));
});

Comment on lines +13 to +20
@if (itemTpl(); as tpl) {
@for (node of chipNodes(); track node.id) {
<ng-container
[ngTemplateOutlet]="tpl.templateRef"
[ngTemplateOutletContext]="itemCtx(node)"
></ng-container>
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Update the template to use the optimized chipContexts signal instead of calling itemCtx(node) on every change detection cycle.

  @if (itemTpl(); as tpl) {
    @for (item of chipContexts(); track item.node.id) {
      <ng-container
        [ngTemplateOutlet]=

…#342)

The 'Custom templates' demo used Bootstrap component classes (.badge,
.btn-close, .text-bg-*) which are NOT global in this repo (they live inside
<bs-badge> etc.), so the projected chip rendered unstyled. The light-DOM slot
projection itself works fine (verified via Playwright MCP: the chip is a
light-DOM child of mp-tree-select).

- Chip: use a page-defined .ts-tpl-chip class (light DOM → page CSS applies).
- Suggestion row: renders inside the nested mp-treeview shadow where page CSS
  can't reach, so use intrinsic elements (<strong>/<small>) instead of classes.
- e2e: assert the chip via .ts-tpl-chip and the suggestion row via <strong>.

tree-select e2e 8/8 (chromium + firefox).
…tent (#342)

- Slotted <slot> content stays light-DOM → page CSS styles it (e.g. chips).
- Content the WC inserts into its shadow (render-callbacks like suggestionTemplate
  into the treeview shadow) is shadow content → page CSS can't reach it; use
  ::slotted, CSS custom properties, inheritable props, inline styles, or WC classes.
- Also: Bootstrap component classes (.badge/.btn-close/.card) aren't global in the
  ng demo app (they live in bs-* components); react/vue demos import full bootstrap.
Captures PR #378 as-built (light-DOM chip/value slots, custom-template DX,
drag-between-lists demo), the slotted-vs-shadow styling model, and the agreed
forward direction: make every customizable region a light-DOM slot with
wrapper-provided default templates + styles; decide the suggestion-row approach
(slot-forward / mp-tree-select renders rows / keep callback).
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.

1 participant