TreeSelect: light-DOM chip/value slots + custom-template DX#378
TreeSelect: light-DOM chip/value slots + custom-template DX#378PieterjanDeClippel wants to merge 5 commits into
Conversation
) 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.
There was a problem hiding this comment.
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.
| /** 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), | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| /** 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), | |
| }, | |
| })); | |
| }); |
| @if (itemTpl(); as tpl) { | ||
| @for (node of chipNodes(); track node.id) { | ||
| <ng-container | ||
| [ngTemplateOutlet]="tpl.templateRef" | ||
| [ngTemplateOutletContext]="itemCtx(node)" | ||
| ></ng-container> | ||
| } | ||
| } |
…#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).
What type of PR is this? (check all applicable)
Description
Follow-up to #342. The original
itemTemplate/suggestionTemplatewere 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 likecdkDrag. This reworks the selected-item (chip) and single-value templating to light-DOM<slot>projection (the TimelinebsTimelineContentpattern), so consumer content stays in the light DOM where CSS and directives work. No back-compat: theitemTemplatecallback is removed.Library
mp-tree-select: chips + single value render through<slot name="chips">/<slot name="value">(built-in chips as fallback); added publicremoveById(id).suggestionTemplatestays a callback because dropdown rows render inside the nestedmp-treeviewshadow (a second boundary; not changed).*bsTreeSelectItemrenders per selected node intoslot="chips"viangTemplateOutlet(context:node+remove());<ng-content>also allows direct projection.bsTreeSelectSuggestionunchanged.slot="chips"children). Vue: default<slot/>passthrough.Demos
tree-select-drag-dropreworked into two connectedbs-tree-selects whose custom chips (with acdkDragHandle) drag between them (cdkDropListConnectedTo+transferArrayItem). Drag wiring is consumer-owned, not baked into the library./basic/tree-selectpage 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;
openTreeSelectnow waits for the wrapper to wire the element (provider pushed) then callsopen()— 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 SynchronizeN/A — no model/entity changesVerified new functionality for the correct app rolesN/A — no authorization changesEnsured there are no database indexing errorsN/A — no database changesAdded to documentation?
[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 (socdkDrag+ page CSS apply); only the dropdown rows remain in themp-treeviewshadow. 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 thebs-*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.