Skip to content

Commit 7483441

Browse files
serpentbladeclaude
andcommitted
fix(sortable-list): expose part="list" + wire ::part cross-shadow grid on Lit (SortableListNested)
SortableListNestedDemo's outer kanban grid (`.board :deep(.rozie-sortable-list)`) laid out columns side-by-side on the 5 light-DOM targets but rendered STACKED on Lit: `:deep()` is a descendant selector and cannot pierce the child <rozie-sortable-list>'s shadow root. Phase 17 shipped `::part()` as the standards-track cross-shadow answer and even documented this exact `SortableList::part(list)` pattern in sortable-list.md — but the producer was never tagged `part="list"` and the demo never adopted the rule, so the documented pattern was dead and the Lit column grid never applied. - packages/ui/sortable-list/src/SortableList.rozie: tag the list element `part="list"` (a no-op standard HTML attr on the 5 non-Lit targets; the cross-shadow handle on Lit). Regenerates all 6 @rozie-ui leaves. - examples/demos/SortableListNestedDemo.rozie: add `SortableList::part(list)` alongside the existing `:deep()` (Phase 17 "coexist" — `:deep` for the 5 light-DOM targets, `::part` for Lit). Both carry identical grid declarations. - tests/visual-regression: register SortableListNested as a matrix cell (settle on 9 `.rozie-sortable-item` = 3 columns + 6 cards) + land the Linux-rendered shared baseline. All 6 targets now diff byte-identically against SortableListNested.png — Lit's column grid matches the other 5 (previously the documented stacked divergence). One cell now validates nested two-level drag, distinct SortableJS groups, slot scope params, cross-component `cards-change` sync, and the Lit `::part` cross-shadow grid. Gates: turbo test 59/59, typecheck 70/70, VR matrix 6/6 vs new baseline (+ Solid behavioral drag specs), solid-vite nested e2e. No dist-parity drift (SortableList is sourced from packages/ui, not a dist-parity fixture). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c7c7fd3 commit 7483441

10 files changed

Lines changed: 49 additions & 18 deletions

File tree

examples/demos/SortableListNestedDemo.rozie

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,18 +133,28 @@ header code {
133133
}
134134

135135
/* Reach into the producer SortableList to lay its outer list out as a
136-
grid of columns. `:deep()` is the cross-component CSS escape hatch
137-
(quick task 260526-mk4): the outer compound (`.board`) is scoped to
138-
this demo, but `.rozie-sortable-list` is lifted OUT of the scope so
139-
it matches the SortableList-rendered DOM directly. The inner
140-
SortableList(s) inside each KanbanColumn keep their own
141-
`display: block` cascade.
142-
143-
Lit consumers render columns stacked rather than gridded because
144-
each <rozie-sortable-list> custom element lives in its own shadow
145-
root, and `:deep()` does not cross shadow-DOM boundaries. Cross-
146-
shadow styling is `::part()` territory (planned, not v1). */
136+
grid of columns. Two coexisting cross-component CSS escape hatches,
137+
one per shadow model (Phase 17 Q1 "coexist"):
138+
139+
- `:deep()` (quick task 260526-mk4) — the light-DOM path for the 5
140+
non-Lit targets. The outer compound (`.board`) is scoped to this
141+
demo, but `.rozie-sortable-list` is lifted OUT of the scope so it
142+
matches the SortableList-rendered DOM directly.
143+
144+
- `SortableList::part(list)` (Phase 17) — the shadow-DOM path for Lit.
145+
Each <rozie-sortable-list> lives in its own shadow root, so `:deep()`
146+
(a descendant selector) cannot pierce it; the W3C `::part()` mechanism
147+
can. The producer tags its list element with `part="list"`; this rule
148+
compiles to `rozie-sortable-list[data-rozie-s-<hash>]::part(list)` on
149+
Lit and is stripped as a no-op on the other 5 targets.
150+
151+
Both rules carry identical declarations, so all six targets render the
152+
columns as a 3-up grid. Only the OUTER list is gridded: the consumer's
153+
styles reach its direct-child <rozie-sortable-list> (the outer list);
154+
the inner lists live in each KanbanColumn's own shadow root and keep
155+
their `display: block` cascade. */
147156
.board :deep(.rozie-sortable-list) { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
157+
SortableList::part(list) { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
148158

149159
.controls {
150160
display: flex;

packages/ui/sortable-list/packages/angular/src/SortableList.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ interface DefaultCtx {
1717
template: `
1818
1919
<div class="rozie-sortable-wrap" #__rozieRoot #rozieSpread_0 #rozieListenersTarget_1>
20-
<div class="rozie-sortable-list" #listEl>
20+
<div class="rozie-sortable-list" #listEl part="list">
2121
@for (item of items(); track keyFor(item, index); let index = $index) {
2222
<div class="rozie-sortable-item" [ngClass]="{ 'rozie-sortable-item-lifted': liftedIndex() === index }" role="listitem" tabindex="0" (keydown)="onRowKeyDown($event, index)">
2323
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: { $implicit: { item: item, index: index }, item: item, index: index }" />

packages/ui/sortable-list/packages/lit/src/SortableList.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ private __rozieFirstUpdateDone = false;
201201
render() {
202202
return html`
203203
<div class="rozie-sortable-wrap" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="__rozieRoot" data-rozie-s-0af24eae>
204-
<div class="rozie-sortable-list" data-rozie-ref="listEl" data-rozie-s-0af24eae>${keyed(this._rozieReconcileSeq ?? 0, html`
204+
<div class="rozie-sortable-list" part="list" data-rozie-ref="listEl" data-rozie-s-0af24eae>${keyed(this._rozieReconcileSeq ?? 0, html`
205205
${repeat<any>(this.items, (item, index) => this.keyFor(item, index), (item, index) => html`<div class="${Object.entries({ "rozie-sortable-item": true, 'rozie-sortable-item-lifted': this._liftedIndex.value === index }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieDisplay(this.keyFor(item, index))} role="listitem" tabindex="0" @keydown=${($event: Event) => { this.onRowKeyDown($event, index); }} data-rozie-s-0af24eae>
206206
${this.__rozieDefaultSlot__ !== undefined ? this.__rozieDefaultSlot__({item: item, index: index}) : html`<slot data-rozie-params=${(() => { try { return JSON.stringify({item: item, index: index}); } catch { return '{}'; } })()}></slot>`}
207207
</div>`)}

packages/ui/sortable-list/packages/react/src/SortableList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ export default function SortableList(_props: SortableListProps): JSX.Element {
269269
return (
270270
<>
271271
<div ref={__rozieRoot} {...attrs} className={clsx("rozie-sortable-wrap", (attrs.className as string | undefined))} data-rozie-s-0af24eae="">
272-
<div className={"rozie-sortable-list"} ref={listEl} data-rozie-s-0af24eae="">
272+
<div className={"rozie-sortable-list"} ref={listEl} part="list" data-rozie-s-0af24eae="">
273273
{items.map((item, index) => <div key={keyFor(item, index)} className={clsx("rozie-sortable-item", { "rozie-sortable-item-lifted": liftedIndex === index })} role="listitem" tabIndex={0} onKeyDown={($event) => { onRowKeyDown($event, index); }} data-rozie-s-0af24eae="">
274274
{typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ item, index }) : (props.children ?? props.slots?.[''])}
275275
</div>)}

packages/ui/sortable-list/packages/solid/src/SortableList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export default function SortableList(_props: SortableListProps): JSX.Element {
232232
return (
233233
<>
234234
<div ref={(el) => { __rozieRootRef = el as HTMLElement; }} {...attrs} class={"rozie-sortable-wrap" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-0af24eae="">
235-
<div class={"rozie-sortable-list"} ref={(el) => { listElRef = el as HTMLElement; }} data-rozie-s-0af24eae="">
235+
<div class={"rozie-sortable-list"} ref={(el) => { listElRef = el as HTMLElement; }} part="list" data-rozie-s-0af24eae="">
236236
<For each={items()}>{(item, index) => <div role="listitem" class={"rozie-sortable-item"} classList={{ 'rozie-sortable-item-lifted': liftedIndex() === index() }} tabIndex={0} onKeyDown={($event) => { onRowKeyDown($event, index()); }} data-rozie-s-0af24eae="">
237237
{typeof local.children === 'function' ? (local.children as (s: any) => any)({ item, index: index() }) : resolved()}
238238
</div>}</For>

packages/ui/sortable-list/packages/svelte/src/SortableList.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ $effect(() => { const __watchVal = (() => easing)(); untrack(() => { if (__rozie
259259

260260

261261
<div bind:this={__rozieRoot} {...__rozieAttrs} class={["rozie-sortable-wrap", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-0af24eae>
262-
<div class="rozie-sortable-list" bind:this={listEl} data-rozie-s-0af24eae>
262+
<div class="rozie-sortable-list" bind:this={listEl} part="list" data-rozie-s-0af24eae>
263263
{#each items as item, index (keyFor(item, index))}<div class={["rozie-sortable-item", { 'rozie-sortable-item-lifted': liftedIndex === index }]} role="listitem" tabindex="0" onkeydown={($event) => { onRowKeyDown($event, index); }} data-rozie-s-0af24eae>
264264
{@render children?.({ item, index })}
265265
</div>{/each}

packages/ui/sortable-list/packages/vue/src/SortableList.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
22

33
<div class="rozie-sortable-wrap" ref="__rozieRootRef" v-bind="$attrs">
4-
<div class="rozie-sortable-list" ref="listElRef">
4+
<div class="rozie-sortable-list" ref="listElRef" part="list">
55
<div v-for="(item, index) in items" :key="keyFor(item, index)" :class="['rozie-sortable-item', { 'rozie-sortable-item-lifted': liftedIndex === index }]" role="listitem" tabindex="0" @keydown="onRowKeyDown($event, index)">
66
<slot :item="item" :index="index"></slot>
77
</div>

packages/ui/sortable-list/src/SortableList.rozie

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ $watch(() => $props.easing, (v) => instance?.option('easing', v))
229229

230230
<template>
231231
<div class="rozie-sortable-wrap">
232-
<div class="rozie-sortable-list" ref="listEl" r-external>
232+
<div class="rozie-sortable-list" ref="listEl" r-external part="list">
233233
<div
234234
r-for="item, index in $props.items"
235235
:key="keyFor(item, index)"
29.8 KB
Loading

tests/visual-regression/specs/matrix.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ const EXAMPLES = [
100100
'SortableListClone',
101101
'SortableListFilter',
102102
'SortableListShowcase',
103+
// SortableListNested (Kanban) — nested two-level drag dogfood. Validates a
104+
// dense bundle of features at once: outer-list column reorder + inner-list
105+
// card drag (distinct SortableJS groups), slot scope params, cross-component
106+
// `cards-change` two-way sync, AND the Lit cross-shadow `::part(list)` grid
107+
// layout (the consumer grids the outer list via `SortableList::part(list)`,
108+
// reaching the child's `part="list"` element across the shadow boundary; the
109+
// other 5 targets grid it via the coexisting `:deep()` light-DOM rule). Per
110+
// D-10 all 6 targets diff against the same shared `SortableListNested.png`
111+
// baseline — with the ::part fix the Lit column grid now matches the other 5
112+
// (previously Lit rendered stacked, the documented :deep cross-shadow gap).
113+
// Baseline-gates to `test.fixme` via `baselineExists()` until the Linux-Docker
114+
// baseline regen lands the PNG (`feedback_vr_linux_baselines`).
115+
'SortableListNested',
103116
'Flatpickr',
104117
'Uppy',
105118
'TipTap',
@@ -277,6 +290,14 @@ async function settleExample(
277290
if (example === 'SortableListShowcase') {
278291
await expect(page.locator('[class*="rozie-sortable-item"]')).toHaveCount(8);
279292
}
293+
// SortableListNested (Kanban): reset() seeds 3 columns; the OUTER SortableList
294+
// renders one `.rozie-sortable-item` per column (3) and each column's INNER
295+
// SortableList renders one per card (3 + 2 + 1 = 6) → 9 total. Playwright's
296+
// CSS locator pierces the nested shadow roots on Lit; `[class*=...]` survives
297+
// React CSS-Modules hashing (same rationale as the SortableList branch above).
298+
if (example === 'SortableListNested') {
299+
await expect(page.locator('[class*="rozie-sortable-item"]')).toHaveCount(9);
300+
}
280301
// Flatpickr: the Flatpickr wrapper renders `<input class="rozie-flatpickr">`;
281302
// the flatpickr engine attaches to it on `$onMount`. Wait for the input to
282303
// be visible — its presence proves the wrapper template painted and the

0 commit comments

Comments
 (0)