From 48b9e3eb37e6d96c03f4b1303c421f216786c6b9 Mon Sep 17 00:00:00 2001
From: PieterjanDeClippel
Date: Tue, 30 Jun 2026 13:58:26 +0200
Subject: [PATCH 1/7] docs(tree-select): PRD for chip reorder + reusable
drag-drop primitive
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/prd/tree-select-chip-reorder.md | 289 +++++++++++++++++++++++++++
1 file changed, 289 insertions(+)
create mode 100644 docs/prd/tree-select-chip-reorder.md
diff --git a/docs/prd/tree-select-chip-reorder.md b/docs/prd/tree-select-chip-reorder.md
new file mode 100644
index 000000000..e6bfa8ca7
--- /dev/null
+++ b/docs/prd/tree-select-chip-reorder.md
@@ -0,0 +1,289 @@
+# PRD — Tree-select chip reorder + reusable drag-drop primitive
+
+**Status:** Planned
+**Owner:** pieterjan@2sky.be
+**Created:** 2026-06-30
+**Affected libs:** `@mintplayer/web-components` (new `drag-drop` primitive + `tree-select`),
+`@mintplayer/ng-bootstrap`, `@mintplayer/react-bootstrap`, `@mintplayer/vue-bootstrap`,
+`apps/ng-bootstrap-demo`
+
+---
+
+## 1. Context & problem
+
+The demo page at `additional-samples/tree-select-drag-drop` was meant to let users **reorder the
+selected items inside the `BsTreeSelect` component itself**. The shipped implementation instead
+renders a **separate `cdkDropList` bar below** the component: items are picked in `bs-tree-select`,
+then reordered in an unrelated Angular CDK list, and the new order is pushed back through `[value]`.
+That is a workaround, not the intended feature.
+
+The workaround exists for a concrete technical reason. In `multiple` / `checkbox` mode the selected
+items render as `` chips **inside the web component's shadow DOM**
+(`libs/mintplayer-web-components/tree-select/src/components/mp-tree-select.ts` → `renderChips()`).
+Angular CDK's `cdkDrag` / `cdkDropList` are Angular directives that can only bind to **light-DOM**
+elements Angular owns; they cannot reach into a Lit shadow root, so the chips themselves cannot be
+made CDK-draggable.
+
+### Goal
+
+Let users **drag-reorder the selected chips in place, inside `bs-tree-select`**, and remove the
+separate bar. Because reorder must happen inside the shadow DOM, it is implemented with
+**pointer events owned by the web component** — and that mechanism is **generalized into a reusable,
+CDK-flavored drag-drop primitive** in the web-components library so other components can adopt it.
+
+### Decisions (confirmed with stakeholder)
+
+| # | Decision |
+|---|----------|
+| 1 | **Scope** = reorder the **selected chips in the trigger** (`multiple` / `checkbox` mode), in place. Reordering tree nodes in the dropdown panel is **out of scope**. |
+| 2 | **Mechanism** = **WC-native pointer drag**, not Angular CDK. This matches repo conventions (WC owns UI; pointer events over HTML5 DnD; React/Vue get the feature for free). |
+| 3 | **Generalize**: build a reusable, CDK-flavored drag-drop primitive in `mintplayer-web-components` rather than a one-off. Other components (`dock`, `tile-manager`, `query-builder`, `scheduler`) each roll their own drag today and could later consolidate onto it. |
+| 4 | **Reorder is opt-in and tree-shakeable**: the drag-drop code must be **excluded from the bundle** for consumers that don't reorder. Achieved via a **registration seam** in the WC + an **explicit opt-in import/directive** the consumer adds — not a static import in the base component (see §2.1). |
+
+### Why not Angular CDK directly
+
+CDK was evaluated and rejected for this feature: keeping it literally would require exposing the
+chip area as a light-DOM ``, rendering chips in the Angular wrapper, and projecting them in.
+That is Angular-only (React/Vue can't reuse it), duplicates chip rendering, and breaks the
+"web component is the single source of UI truth" convention. The pointer-drag primitive achieves
+genuine in-component reorder for all three frameworks at once.
+
+---
+
+## 2. The reusable primitive — `@mintplayer/web-components/drag-drop`
+
+A new **framework-agnostic** shared sub-entry, mirroring the shape of existing primitives
+`overlay` and `a11y` (just `index.ts` + `src/`, no `ng-package.js` / `package.json`; auto-resolved
+by the `@mintplayer/web-components/*` tsconfig wildcard and `vite.config.mts`, imported as
+`@mintplayer/web-components/drag-drop`).
+
+```
+libs/mintplayer-web-components/drag-drop/
+ index.ts → export * from './src'
+ src/
+ index.ts → public API barrel
+ sortable-controller.ts → SortableController (Lit ReactiveController)
+ move-item.ts → moveItemInArray / transferArrayItem (CDK-parity helpers)
+ types.ts → SortDropEvent, SortableOptions, SortAxis
+ sortable-controller.spec.ts
+```
+
+### Scope (v1)
+
+Single-list **sortable reorder** — the subset CDK covers with `cdkDropList` + `moveItemInArray`.
+The API is CDK-flavored and extensible; **cross-list transfer** (`transferArrayItem`) and
+**drop-zone / tree-reparent** drags are noted as future extensions and **not built now**. The
+tree-reparent case already exists in `query-builder/src/dnd/drag-controller.ts`; consolidating it
+onto this primitive is a follow-up, out of scope here.
+
+### Public API (CDK-flavored, Lit-idiomatic)
+
+```ts
+class SortableController implements ReactiveController {
+ constructor(host: ReactiveControllerHost & LitElement, opts: {
+ items: () => readonly T[]; // current order (read at drag start)
+ itemId: (item: T) => string; // track-by; matched against data-sortable-id
+ onDrop: (e: SortDropEvent) => void; // { previousIndex, currentIndex } — host reorders + re-renders
+ axis?: 'horizontal' | 'vertical' | 'both'; // 'both' = wrap layout (chips). default 'both'
+ handleSelector?: string; // optional drag-handle selector within an item
+ dragThresholdPx?: number; // mouse slop; default 5 (scheduler precedent)
+ longPressMs?: number; // touch arming; default 600 (dock/tile-manager precedent)
+ disabled?: () => boolean;
+ });
+ attach(container: Element): void; // wire from a Lit ref directive on the item container
+}
+
+function moveItemInArray(arr: readonly T[], from: number, to: number): T[]; // returns a new array
+function transferArrayItem(/* declared; future use */): void;
+```
+
+### Responsibilities (hidden inside the controller — a deep module)
+
+- **Gesture state machine** `idle → arming(touch) → dragging → dropping`: 5px slop for mouse,
+ 600ms + 10px touch long-press — reusing thresholds proven in `dock` and `tile-manager`.
+- **Hit-testing** via `elementsFromPoint`, filtered to `[data-sortable-id]` elements **within the
+ host's shadow root** (works because the listeners and items share the same root). Each draggable
+ item must carry `data-sortable-id=${itemId(item)}`.
+- **Visual feedback**: a **ghost/preview** clone (`position: fixed; pointer-events: none`) tracking
+ the pointer, plus a **placeholder gap** insertion indicator at the live drop index. Any settle
+ transition respects `prefers-reduced-motion`.
+- **Touch**: `touch-action: none` on draggable items; never `preventDefault()` a touch
+ `pointerdown` (it suppresses the synthesized click).
+- **Keyboard reorder** (accessibility): `M` to grab a focused item, Arrow keys to move,
+ `Enter` / `M` to drop, `Escape` to cancel — matching the dock/tile-manager keymap (`M` for
+ move-mode, not Space). Moves are announced via `@mintplayer/web-components/a11y` live-announcer.
+- **Data ownership stays with the host**: the controller emits one `onDrop({previousIndex,
+ currentIndex})`; the host mutates its own data and re-renders. The primitive imposes no behavior
+ on the data model.
+
+### 2.1 Opt-in / tree-shaking architecture
+
+The drag chips live in `mp-tree-select`'s shadow DOM, so the `SortableController` must be
+instantiated **inside** the WC (only it holds a ref to the chip container). To keep the heavy code
+out of the bundle for consumers that don't reorder, the WC must **not statically import** the
+controller. Instead:
+
+**(a) Generic registration seam in the WC.** A tiny module
+`tree-select/src/components/sortable-registry.ts` holds a `register` / `get` pair:
+
+```ts
+export type TreeSelectSortableFactory = (
+ host: LitElement & ReactiveControllerHost,
+ opts: { items: () => readonly TreeNode[]; itemId: (n: TreeNode) => string;
+ onDrop: (e: SortDropEvent) => void },
+) => { attach(container: Element): void; detach(): void };
+
+let _factory: TreeSelectSortableFactory | undefined;
+export const registerTreeSelectSortable = (f: TreeSelectSortableFactory) => { _factory = f; };
+export const getTreeSelectSortable = () => _factory;
+```
+
+`mp-tree-select` imports **only** this registry (a getter/setter — negligible bytes), never the
+controller. When `reorderable` is set **and** a factory has been registered, it builds + attaches
+the controller; otherwise reorder is **inert** with a one-time dev-mode `console.warn` pointing at
+the opt-in import. The heavy `SortableController` is reachable only through a registered factory.
+
+**(b) Opt-in artifacts** — each pulls in the primitive **and** registers the factory, so the
+bundler tree-shakes all of it away unless the consumer imports it:
+
+- **Plain WC / framework-agnostic** — a side-effect registrar module that imports
+ `SortableController` from `@mintplayer/web-components/drag-drop` and calls
+ `registerTreeSelectSortable(...)`. Consumer opts in with a single import line.
+- **Angular (primary path)** — a standalone `BsTreeSelectReorderDirective` shipped from a separate
+ secondary entry `@mintplayer/ng-bootstrap/tree-select/reorder`. Selector `bs-tree-select[reorderable]`:
+ applying the `reorderable` attribute activates the directive, which registers the factory and sets
+ `el.reorderable = true`. The directive is bundled **only** if the consumer imports it; without the
+ import, `reorderable` is an inert plain attribute. (Keeps drag-drop out of the base
+ `@mintplayer/ng-bootstrap/tree-select` entry entirely.)
+- **React / Vue** — equivalent opt-in: a side-effect/registrar import (or a tiny
+ `enableTreeSelectReorder()` helper) re-exported from each wrapper package.
+
+**Implementation note:** verify the secondary-entry resolution for the registrar path
+(`@mintplayer/web-components/tree-select/reorder` vs. a sibling sub-entry). The repo auto-discovers
+`/src/index.ts` as a sub-entrypoint; a sub-*sub*-path may need either a sibling top-level
+module or an explicit export-map entry. The Angular `tree-select/reorder` follows the existing
+ng-packagr secondary-entry pattern (its own `ng-package.json`).
+
+---
+
+## 3. `mp-tree-select` integration
+
+- New boolean attribute/property **`reorderable`** (default `false`), declared via the static
+ `observedAttributes` getter pattern (spread `super.observedAttributes`). Only effective in
+ `multiple` / `checkbox` modes.
+- When `reorderable` flips true, call `getTreeSelectSortable()` (§2.1). If a factory is registered,
+ build the controller via the factory with `items = () => [...this._selected.values()]`,
+ `itemId = (n) => n.id`, `axis: 'both'` (chips wrap), and `attach()` it to the chip container ref.
+ If no factory is registered, no-op + one-time dev warning. **No static import of
+ `SortableController` here.**
+- In `renderChips()`: when `reorderable`, stamp each `.ts-chip` with `data-sortable-id=${node.id}`,
+ add a grab affordance / drag handle, and bind the chip container's `ref` to `controller.attach`.
+- `onDrop` → rebuild the insertion-ordered `_selected` Map in the new order, recompute `value`,
+ then emit **two** events:
+ - **`value-change`** with the reordered `value` (`added` / `removed` undefined) — so reactive /
+ template-driven forms and the CVA pick up the new order, just like today's `[value]` round-trip
+ but now internal.
+ - **`reorder`** — a `CustomEvent<{ value, previousIndex, currentIndex }>` semantic event.
+- **Styles** (chip `cursor: grab`, ghost, placeholder gap, handle) go in
+ `tree-select.styles.scss`. SCSS is compiled into the generated `tree-select.styles.ts`, so
+ **`nx run mintplayer-web-components:codegen-wc` must be re-run** after editing it. Re-declare any
+ Bootstrap utility rules needed — they do not cross the shadow boundary.
+
+---
+
+## 4. Framework wrappers
+
+### Angular — `libs/mintplayer-ng-bootstrap/tree-select/`
+
+The **base** `BsTreeSelectComponent` stays free of any drag-drop code (so the base
+`@mintplayer/ng-bootstrap/tree-select` entry never bundles it). It only needs to:
+- surface a `reordered = output()` and bind `(reorder)` in
+ `tree-select.component.html` to emit it (the event is dispatched by the WC regardless of who
+ registered the factory; binding it is free of drag code). `onValueChange` already maps
+ `value-change` → model + `onChange` (CVA), so the reordered order reaches `formControl` /
+ `ngModel` with no extra wiring.
+- re-export the reorder event type from `src/index.ts`.
+
+The opt-in lives in a **new secondary entry** `@mintplayer/ng-bootstrap/tree-select/reorder`
+(own `ng-package.json`, mirroring an existing secondary entry):
+- `BsTreeSelectReorderDirective`, selector `bs-tree-select[reorderable]`.
+- On construction it imports `SortableController` from `@mintplayer/web-components/drag-drop` and
+ calls `registerTreeSelectSortable(...)` (idempotent), then sets `el.reorderable = true` on the
+ host WC element via its `ElementRef`.
+- Bundled **only** when the consumer imports the directive; otherwise tree-shaken out.
+
+**No `@angular/cdk` dependency** anywhere for this feature.
+
+### React & Vue
+
+Reorder is internal to the WC and surfaces through the existing `value` / value-change bridge, so
+the base wrappers only pass the `reorderable` attribute through and optionally expose an `onReorder`
+/ `@reorder` passthrough. The **opt-in** (importing the primitive + registering the factory) is a
+small side-effect/registrar import re-exported per wrapper package, kept out of the base wrapper so
+it tree-shakes. No chip-rendering duplication — "WC owns UI, wrappers stay thin" holds.
+
+---
+
+## 5. Demo rewrite — `apps/ng-bootstrap-demo/.../tree-select-drag-drop/`
+
+- Remove the separate `cdkDropList` bar, the `DragDropModule` import, `onDrop` / `moveItemInArray`,
+ and the `.dnd-*` SCSS.
+- Import `BsTreeSelectReorderDirective` from `@mintplayer/ng-bootstrap/tree-select/reorder` and add
+ it to the page component's `imports` (this is the opt-in that pulls the drag code in).
+- Set `reorderable` on ``; chips reorder in place.
+- Update the prose and `bs-code-snippet`s to show the in-component reorder **and the required
+ opt-in import** (so consumers learn the tree-shakeable pattern). Live demo **before** the snippet
+ (repo convention). Keep the existing route.
+
+---
+
+## 6. Files
+
+**Create**
+- `libs/mintplayer-web-components/drag-drop/{index.ts, src/index.ts, src/sortable-controller.ts, src/move-item.ts, src/types.ts, src/sortable-controller.spec.ts}`
+- `libs/mintplayer-web-components/tree-select/src/components/sortable-registry.ts` (the WC seam, §2.1a)
+- Framework-agnostic registrar (side-effect opt-in) module for the WC (§2.1b — path TBD per the
+ resolution note)
+- Angular reorder secondary entry: `libs/mintplayer-ng-bootstrap/tree-select/reorder/{index.ts, ng-package.json, src/...}` with `BsTreeSelectReorderDirective`
+
+**Modify**
+- `libs/mintplayer-web-components/tree-select/src/components/mp-tree-select.ts` (registry-gated wiring, `reorderable`, `renderChips`, `reorder` event)
+- `libs/mintplayer-web-components/tree-select/src/styles/tree-select.styles.scss` (+ regenerated `.styles.ts`)
+- `libs/mintplayer-web-components/tree-select/src/types/tree-select.ts` + `src/index.ts` (reorder event type + `registerTreeSelectSortable` export)
+- `libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.{ts,html}` + `src/index.ts` (base: `reordered` output only — no drag code)
+- `libs/mintplayer-react-bootstrap/tree-select/**`, `libs/mintplayer-vue-bootstrap/tree-select/**` (base passthrough + separate opt-in registrar)
+- `apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/*`
+
+**Reuse**
+- `@mintplayer/web-components/a11y` (live-announcer for keyboard reorder)
+- Gesture thresholds / state-machine patterns from `dock`, `tile-manager`, `scheduler/src/drag`
+- `repeat` keying already present in `renderChips()`
+
+---
+
+## 7. Verification
+
+1. `npx nx run mintplayer-web-components:codegen-wc` after SCSS edits.
+2. `npx nx test mintplayer-web-components` — unit specs for `moveItemInArray`, drop-index
+ computation, and the keyboard-reorder reducer (pointer-drag is jsdom-limited; test the pure
+ pieces + dispatch synthetic pointer events where feasible).
+3. `npx nx build mintplayer-web-components`, then `nx build mintplayer-ng-bootstrap`
+ (+ react / vue).
+4. **Verify through the running demo apps** (not a standalone harness): `apps/ng-bootstrap-demo` →
+ `additional-samples/tree-select-drag-drop` — pick several items, drag a chip to reorder
+ (mouse + touch), confirm the order persists in `[(ngModel)]` / form value; test keyboard reorder
+ (`M` + arrows); smoke-test in Firefox (fixed-size flex children shrink there). Spot-check the
+ React and Vue demos for the same.
+5. Optional Playwright e2e: pointer-drag a chip and assert the new order.
+6. **Bundle-exclusion check** (the point of §2.1): build a throwaway app that uses
+ `` **without** importing the reorder directive and confirm `SortableController` /
+ the `drag-drop` chunk is absent from the output (grep the bundle for a `SortableController`
+ identifier). Then add the directive and confirm it appears. Verifies reorder is genuinely
+ opt-in, not dead-code that ships anyway.
+
+---
+
+## 8. Out of scope (follow-ups)
+
+- Cross-list `transferArrayItem` drags; tree-node reorder / reparent in the dropdown panel.
+- Migrating `query-builder` / `dock` / `tile-manager` / `scheduler` onto the shared primitive.
From da4687546804d4c7361b7b23ea32450659f0e3ed Mon Sep 17 00:00:00 2001
From: PieterjanDeClippel
Date: Tue, 30 Jun 2026 13:58:36 +0200
Subject: [PATCH 2/7] feat(drag-drop): reusable framework-agnostic sortable
primitive
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New @mintplayer/web-components/drag-drop entrypoint: SortableController
(Lit ReactiveController — pointer drag with mouse threshold / touch
long-press, floating ghost + drop indicator, M-key keyboard reorder),
plus CDK-parity moveItemInArray / transferArrayItem helpers. Data-free:
emits a single onDrop, the host mutates its own model. 14 unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../drag-drop/index.ts | 1 +
.../drag-drop/src/index.ts | 3 +
.../drag-drop/src/move-item.ts | 42 ++
.../drag-drop/src/sortable-controller.spec.ts | 164 +++++++
.../drag-drop/src/sortable-controller.ts | 441 ++++++++++++++++++
.../drag-drop/src/types.ts | 52 +++
6 files changed, 703 insertions(+)
create mode 100644 libs/mintplayer-web-components/drag-drop/index.ts
create mode 100644 libs/mintplayer-web-components/drag-drop/src/index.ts
create mode 100644 libs/mintplayer-web-components/drag-drop/src/move-item.ts
create mode 100644 libs/mintplayer-web-components/drag-drop/src/sortable-controller.spec.ts
create mode 100644 libs/mintplayer-web-components/drag-drop/src/sortable-controller.ts
create mode 100644 libs/mintplayer-web-components/drag-drop/src/types.ts
diff --git a/libs/mintplayer-web-components/drag-drop/index.ts b/libs/mintplayer-web-components/drag-drop/index.ts
new file mode 100644
index 000000000..8420b1093
--- /dev/null
+++ b/libs/mintplayer-web-components/drag-drop/index.ts
@@ -0,0 +1 @@
+export * from './src';
diff --git a/libs/mintplayer-web-components/drag-drop/src/index.ts b/libs/mintplayer-web-components/drag-drop/src/index.ts
new file mode 100644
index 000000000..2cdc28c14
--- /dev/null
+++ b/libs/mintplayer-web-components/drag-drop/src/index.ts
@@ -0,0 +1,3 @@
+export { SortableController } from './sortable-controller';
+export { moveItemInArray, transferArrayItem } from './move-item';
+export type { SortAxis, SortDropEvent, SortableOptions } from './types';
diff --git a/libs/mintplayer-web-components/drag-drop/src/move-item.ts b/libs/mintplayer-web-components/drag-drop/src/move-item.ts
new file mode 100644
index 000000000..a77d4128d
--- /dev/null
+++ b/libs/mintplayer-web-components/drag-drop/src/move-item.ts
@@ -0,0 +1,42 @@
+/**
+ * Immutable CDK-parity array helpers. CDK's `moveItemInArray` mutates in place;
+ * these return a fresh array instead, which suits signal/`@property` setters that
+ * compare by reference. Semantics (clamping, final-index meaning) match CDK so
+ * `SortDropEvent` indices are interchangeable with CDK consumers.
+ */
+
+const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value));
+
+/**
+ * Move an item to a new index, returning a new array. Indices are clamped to the
+ * array bounds (out-of-range never throws — the error is defined out of existence).
+ */
+export function moveItemInArray(array: readonly T[], fromIndex: number, toIndex: number): T[] {
+ const result = array.slice();
+ const from = clamp(fromIndex, result.length - 1);
+ const to = clamp(toIndex, result.length - 1);
+ if (from === to) return result;
+ const [item] = result.splice(from, 1);
+ result.splice(to, 0, item);
+ return result;
+}
+
+/**
+ * Move an item from one array to another, returning fresh copies of both.
+ * Declared for future cross-list drags; not yet wired to a UI consumer.
+ */
+export function transferArrayItem(
+ source: readonly T[],
+ target: readonly T[],
+ fromIndex: number,
+ toIndex: number,
+): { source: T[]; target: T[] } {
+ const src = source.slice();
+ const tgt = target.slice();
+ if (src.length === 0) return { source: src, target: tgt };
+ const from = clamp(fromIndex, src.length - 1);
+ const [item] = src.splice(from, 1);
+ const to = clamp(toIndex, tgt.length); // target can grow, so clamp to length (append allowed)
+ tgt.splice(to, 0, item);
+ return { source: src, target: tgt };
+}
diff --git a/libs/mintplayer-web-components/drag-drop/src/sortable-controller.spec.ts b/libs/mintplayer-web-components/drag-drop/src/sortable-controller.spec.ts
new file mode 100644
index 000000000..fc04a3120
--- /dev/null
+++ b/libs/mintplayer-web-components/drag-drop/src/sortable-controller.spec.ts
@@ -0,0 +1,164 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import type { ReactiveControllerHost } from 'lit';
+import { moveItemInArray, transferArrayItem } from './move-item';
+import { resolveDropIndex, SortableController } from './sortable-controller';
+
+describe('moveItemInArray', () => {
+ it('moves an item forward and returns a new array', () => {
+ const input = ['a', 'b', 'c', 'd'];
+ const out = moveItemInArray(input, 0, 2);
+ expect(out).toEqual(['b', 'c', 'a', 'd']);
+ expect(input).toEqual(['a', 'b', 'c', 'd']); // original untouched
+ });
+
+ it('moves an item backward', () => {
+ expect(moveItemInArray(['a', 'b', 'c', 'd'], 3, 1)).toEqual(['a', 'd', 'b', 'c']);
+ });
+
+ it('is a no-op when indices match', () => {
+ expect(moveItemInArray(['a', 'b', 'c'], 1, 1)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('clamps out-of-range indices instead of throwing', () => {
+ expect(moveItemInArray(['a', 'b', 'c'], 0, 99)).toEqual(['b', 'c', 'a']);
+ expect(moveItemInArray(['a', 'b', 'c'], -5, 0)).toEqual(['a', 'b', 'c']);
+ });
+});
+
+describe('transferArrayItem', () => {
+ it('moves an item between arrays', () => {
+ const { source, target } = transferArrayItem(['a', 'b', 'c'], ['x', 'y'], 1, 1);
+ expect(source).toEqual(['a', 'c']);
+ expect(target).toEqual(['x', 'b', 'y']);
+ });
+
+ it('handles an empty source without throwing', () => {
+ const { source, target } = transferArrayItem([], ['x'], 0, 0);
+ expect(source).toEqual([]);
+ expect(target).toEqual(['x']);
+ });
+});
+
+const box = (left: number, top: number, w: number, h: number) => ({
+ left,
+ top,
+ right: left + w,
+ bottom: top + h,
+});
+
+describe('resolveDropIndex', () => {
+ // single horizontal row of three 100x20 boxes at x = 0,100,200
+ const row = [box(0, 0, 100, 20), box(100, 0, 100, 20), box(200, 0, 100, 20)];
+
+ it('returns 0 when the pointer is left of the first centre (horizontal)', () => {
+ expect(resolveDropIndex(row, 10, 10, 'horizontal')).toBe(0);
+ });
+
+ it('inserts before an item when left of its centre', () => {
+ expect(resolveDropIndex(row, 120, 10, 'horizontal')).toBe(1);
+ });
+
+ it('returns boxes.length past the last centre', () => {
+ expect(resolveDropIndex(row, 290, 10, 'horizontal')).toBe(3);
+ });
+
+ it('uses Y for the vertical axis', () => {
+ const col = [box(0, 0, 100, 20), box(0, 20, 100, 20), box(0, 40, 100, 20)];
+ expect(resolveDropIndex(col, 10, 25, 'vertical')).toBe(1);
+ });
+
+ it('models reading order across rows for both-axis', () => {
+ // row 0: x 0,100 ; row 1: x 0
+ const wrap = [box(0, 0, 100, 20), box(100, 0, 100, 20), box(0, 20, 100, 20)];
+ // pointer on the second row, left of the wrapped item's centre
+ expect(resolveDropIndex(wrap, 10, 30, 'both')).toBe(2);
+ // pointer on first row before the first centre
+ expect(resolveDropIndex(wrap, 10, 10, 'both')).toBe(0);
+ });
+});
+
+function fakeHost(): ReactiveControllerHost & HTMLElement {
+ const el = document.createElement('div');
+ return Object.assign(el, {
+ addController: () => undefined,
+ removeController: () => undefined,
+ requestUpdate: () => undefined,
+ updateComplete: Promise.resolve(true),
+ }) as unknown as ReactiveControllerHost & HTMLElement;
+}
+
+function buildList(ids: string[]): HTMLElement {
+ const container = document.createElement('div');
+ for (const id of ids) {
+ const chip = document.createElement('span');
+ chip.dataset['sortableId'] = id;
+ chip.tabIndex = 0;
+ chip.textContent = id;
+ container.appendChild(chip);
+ }
+ document.body.appendChild(container);
+ return container;
+}
+
+function keydown(target: Element, key: string): void {
+ target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
+}
+
+describe('SortableController keyboard reorder', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('grabs with M and moves with arrow keys, emitting CDK-style indices', () => {
+ let items = ['a', 'b', 'c'];
+ const onDrop = vi.fn((e: { previousIndex: number; currentIndex: number }) => {
+ items = moveItemInArray(items, e.previousIndex, e.currentIndex);
+ });
+ const container = buildList(items);
+ const ctrl = new SortableController(fakeHost(), {
+ items: () => items,
+ itemId: (i) => i,
+ onDrop,
+ });
+ ctrl.attach(container);
+
+ const firstChip = container.querySelector('[data-sortable-id="a"]')!;
+ keydown(firstChip, 'm');
+ keydown(firstChip, 'ArrowRight');
+
+ expect(onDrop).toHaveBeenCalledWith({ previousIndex: 0, currentIndex: 1 });
+ expect(items).toEqual(['b', 'a', 'c']);
+ });
+
+ it('does not move before grabbing', () => {
+ const onDrop = vi.fn();
+ const items = ['a', 'b', 'c'];
+ const container = buildList(items);
+ const ctrl = new SortableController(fakeHost(), {
+ items: () => items,
+ itemId: (i) => i,
+ onDrop,
+ });
+ ctrl.attach(container);
+
+ keydown(container.querySelector('[data-sortable-id="a"]')!, 'ArrowRight');
+ expect(onDrop).not.toHaveBeenCalled();
+ });
+
+ it('clamps at the ends', () => {
+ const items = ['a', 'b', 'c'];
+ const onDrop = vi.fn();
+ const container = buildList(items);
+ const ctrl = new SortableController(fakeHost(), {
+ items: () => items,
+ itemId: (i) => i,
+ onDrop,
+ });
+ ctrl.attach(container);
+
+ const first = container.querySelector('[data-sortable-id="a"]')!;
+ keydown(first, 'm');
+ keydown(first, 'ArrowLeft'); // already at index 0 -> no move
+ expect(onDrop).not.toHaveBeenCalled();
+ });
+});
diff --git a/libs/mintplayer-web-components/drag-drop/src/sortable-controller.ts b/libs/mintplayer-web-components/drag-drop/src/sortable-controller.ts
new file mode 100644
index 000000000..2bc0c5716
--- /dev/null
+++ b/libs/mintplayer-web-components/drag-drop/src/sortable-controller.ts
@@ -0,0 +1,441 @@
+import type { ReactiveController, ReactiveControllerHost } from 'lit';
+import type { SortableOptions, SortDropEvent, SortAxis } from './types';
+
+type HostElement = ReactiveControllerHost & HTMLElement;
+
+interface BoxLike {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+}
+
+/**
+ * Resolve the final index a dragged item should land at, given the bounding
+ * boxes of the *other* items (source excluded), in their current order. Pure and
+ * geometry-only so it can be unit-tested without a DOM. Returns 0..boxes.length;
+ * the value is the resting index in the array after the source is reinserted.
+ *
+ * For `'both'` it models reading order: an item sits *after* the pointer when the
+ * pointer is on an earlier row, or on the same row but left of the item's centre.
+ */
+export function resolveDropIndex(
+ boxes: readonly BoxLike[],
+ pointerX: number,
+ pointerY: number,
+ axis: SortAxis,
+): number {
+ for (let i = 0; i < boxes.length; i++) {
+ const b = boxes[i];
+ const centreX = (b.left + b.right) / 2;
+ const centreY = (b.top + b.bottom) / 2;
+ let pointerIsBefore: boolean;
+ if (axis === 'vertical') {
+ pointerIsBefore = pointerY < centreY;
+ } else if (axis === 'horizontal') {
+ pointerIsBefore = pointerX < centreX;
+ } else {
+ pointerIsBefore = pointerY < b.top || (pointerY <= b.bottom && pointerX < centreX);
+ }
+ if (pointerIsBefore) return i;
+ }
+ return boxes.length;
+}
+
+// Computed-style properties copied onto the floating ghost so it still looks like
+// the source chip after it leaves the host's shadow root (where the real CSS lives).
+const GHOST_STYLE_PROPS = [
+ 'font',
+ 'color',
+ 'background',
+ 'border',
+ 'borderRadius',
+ 'padding',
+ 'boxSizing',
+ 'lineHeight',
+ 'textAlign',
+] as const;
+
+type Phase = 'idle' | 'pending' | 'dragging';
+
+/**
+ * Framework-agnostic single-list sortable reorder for Lit web components, driven
+ * by pointer events (mouse/pen drag-threshold, touch long-press) plus a keyboard
+ * move-mode. The drag chrome (floating ghost + drop indicator) is rendered into
+ * `document.body` so it is never disturbed by the host's Lit re-render, and so it
+ * works regardless of the host's `overflow`/stacking context.
+ *
+ * The controller is intentionally data-free: it emits one {@link SortableOptions.onDrop}
+ * and the host mutates its own model (typically via `moveItemInArray`) and re-renders.
+ */
+export class SortableController implements ReactiveController {
+ private readonly host: HostElement;
+ private readonly axis: SortAxis;
+ private readonly dragThresholdPx: number;
+ private readonly longPressMs: number;
+ private readonly touchSlopPx: number;
+ private readonly opts: SortableOptions;
+
+ private container: Element | null = null;
+
+ // --- pointer drag state ---
+ private phase: Phase = 'idle';
+ private pointerId = -1;
+ private isTouch = false;
+ private startX = 0;
+ private startY = 0;
+ private grabOffsetX = 0;
+ private grabOffsetY = 0;
+ private sourceIndex = -1;
+ private sourceEl: HTMLElement | null = null;
+ private sourcePrevOpacity = '';
+ private ghost: HTMLElement | null = null;
+ private indicator: HTMLElement | null = null;
+ private armTimer: ReturnType | null = null;
+ private dropIndex = -1;
+
+ // --- keyboard move-mode state ---
+ private grabbedId: string | null = null;
+
+ constructor(host: HostElement, options: SortableOptions) {
+ this.host = host;
+ this.opts = options;
+ this.axis = options.axis ?? 'both';
+ this.dragThresholdPx = options.dragThresholdPx ?? 5;
+ this.longPressMs = options.longPressMs ?? 600;
+ this.touchSlopPx = options.touchSlopPx ?? 10;
+ host.addController(this);
+ }
+
+ hostDisconnected(): void {
+ this.cancel();
+ this.detach();
+ }
+
+ /** Wire the controller to the element that contains the `[data-sortable-id]` items. Idempotent. */
+ attach(container: Element): void {
+ if (this.container === container) return;
+ this.detach();
+ this.container = container;
+ container.addEventListener('pointerdown', this.onPointerDown as EventListener);
+ container.addEventListener('keydown', this.onKeyDown as EventListener);
+ }
+
+ private detach(): void {
+ if (!this.container) return;
+ this.container.removeEventListener('pointerdown', this.onPointerDown as EventListener);
+ this.container.removeEventListener('keydown', this.onKeyDown as EventListener);
+ this.container = null;
+ }
+
+ private get disabled(): boolean {
+ return this.opts.disabled?.() ?? false;
+ }
+
+ private itemElements(): HTMLElement[] {
+ if (!this.container) return [];
+ return Array.from(this.container.querySelectorAll('[data-sortable-id]'));
+ }
+
+ private indexOfId(id: string): number {
+ return this.opts.items().findIndex((item) => this.opts.itemId(item) === id);
+ }
+
+ // ---------------------------------------------------------------- pointer
+
+ private onPointerDown = (event: PointerEvent): void => {
+ if (this.disabled || this.phase !== 'idle') return;
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
+
+ const target = event.target as Element | null;
+ const itemEl = target?.closest('[data-sortable-id]') ?? null;
+ if (!itemEl || !this.container?.contains(itemEl)) return;
+ if (this.opts.handleSelector && !target?.closest(this.opts.handleSelector)) return;
+
+ const id = itemEl.dataset['sortableId'];
+ if (id == null) return;
+ const index = this.indexOfId(id);
+ if (index < 0) return;
+
+ this.phase = 'pending';
+ this.pointerId = event.pointerId;
+ this.isTouch = event.pointerType === 'touch';
+ this.sourceEl = itemEl;
+ this.sourceIndex = index;
+ this.startX = event.clientX;
+ this.startY = event.clientY;
+ const rect = itemEl.getBoundingClientRect();
+ this.grabOffsetX = event.clientX - rect.left;
+ this.grabOffsetY = event.clientY - rect.top;
+
+ // Note: never preventDefault() a touch pointerdown — it suppresses the
+ // synthesised click. Items must set `touch-action: none` in CSS instead.
+ window.addEventListener('pointermove', this.onPointerMove as EventListener);
+ window.addEventListener('pointerup', this.onPointerUp as EventListener);
+ window.addEventListener('pointercancel', this.onPointerUp as EventListener);
+
+ if (this.isTouch) {
+ this.armTimer = setTimeout(() => {
+ this.armTimer = null;
+ if (this.phase === 'pending') this.beginDrag(this.startX, this.startY);
+ }, this.longPressMs);
+ }
+ };
+
+ private onPointerMove = (event: PointerEvent): void => {
+ if (event.pointerId !== this.pointerId) return;
+ const dx = event.clientX - this.startX;
+ const dy = event.clientY - this.startY;
+
+ if (this.phase === 'pending') {
+ const dist = Math.hypot(dx, dy);
+ if (this.isTouch) {
+ // Movement before the long-press fires reads as a scroll/scrub: abort.
+ if (dist > this.touchSlopPx) this.cancel();
+ } else if (dist > this.dragThresholdPx) {
+ this.beginDrag(event.clientX, event.clientY);
+ }
+ return;
+ }
+
+ if (this.phase === 'dragging') {
+ event.preventDefault();
+ this.updateDrag(event.clientX, event.clientY);
+ }
+ };
+
+ private onPointerUp = (event: PointerEvent): void => {
+ if (event.pointerId !== this.pointerId) return;
+ if (this.phase === 'dragging') {
+ this.finishDrag();
+ } else {
+ this.cancel();
+ }
+ };
+
+ private beginDrag(clientX: number, clientY: number): void {
+ if (!this.sourceEl) return;
+ this.phase = 'dragging';
+ if (this.armTimer) {
+ clearTimeout(this.armTimer);
+ this.armTimer = null;
+ }
+ this.dropIndex = this.sourceIndex;
+
+ const rect = this.sourceEl.getBoundingClientRect();
+ this.ghost = this.buildGhost(this.sourceEl, rect);
+ document.body.appendChild(this.ghost);
+
+ this.indicator = this.buildIndicator();
+ document.body.appendChild(this.indicator);
+
+ this.sourcePrevOpacity = this.sourceEl.style.opacity;
+ this.sourceEl.style.opacity = '0.4';
+
+ this.updateDrag(clientX, clientY);
+ }
+
+ private updateDrag(clientX: number, clientY: number): void {
+ if (this.ghost) {
+ this.ghost.style.left = `${clientX - this.grabOffsetX}px`;
+ this.ghost.style.top = `${clientY - this.grabOffsetY}px`;
+ }
+
+ const others = this.itemElements().filter((el) => el !== this.sourceEl);
+ const boxes = others.map((el) => el.getBoundingClientRect());
+ this.dropIndex = resolveDropIndex(boxes, clientX, clientY, this.axis);
+ this.positionIndicator(others, boxes);
+ }
+
+ private finishDrag(): void {
+ const from = this.sourceIndex;
+ const to = this.dropIndex;
+ this.cleanupDrag();
+ this.phase = 'idle';
+ this.removeWindowListeners();
+ if (to >= 0 && to !== from) {
+ this.emitDrop({ previousIndex: from, currentIndex: to });
+ }
+ }
+
+ private cancel(): void {
+ if (this.phase === 'idle') {
+ this.removeWindowListeners();
+ return;
+ }
+ this.cleanupDrag();
+ this.phase = 'idle';
+ this.removeWindowListeners();
+ }
+
+ private cleanupDrag(): void {
+ if (this.armTimer) {
+ clearTimeout(this.armTimer);
+ this.armTimer = null;
+ }
+ if (this.ghost) {
+ this.ghost.remove();
+ this.ghost = null;
+ }
+ if (this.indicator) {
+ this.indicator.remove();
+ this.indicator = null;
+ }
+ if (this.sourceEl) {
+ this.sourceEl.style.opacity = this.sourcePrevOpacity;
+ }
+ this.sourceEl = null;
+ }
+
+ private removeWindowListeners(): void {
+ window.removeEventListener('pointermove', this.onPointerMove as EventListener);
+ window.removeEventListener('pointerup', this.onPointerUp as EventListener);
+ window.removeEventListener('pointercancel', this.onPointerUp as EventListener);
+ this.pointerId = -1;
+ }
+
+ private buildGhost(source: HTMLElement, rect: DOMRect): HTMLElement {
+ const ghost = source.cloneNode(true) as HTMLElement;
+ ghost.removeAttribute('data-sortable-id');
+ const computed = getComputedStyle(source);
+ for (const prop of GHOST_STYLE_PROPS) {
+ ghost.style[prop] = computed[prop] as string;
+ }
+ Object.assign(ghost.style, {
+ position: 'fixed',
+ left: `${rect.left}px`,
+ top: `${rect.top}px`,
+ width: `${rect.width}px`,
+ height: `${rect.height}px`,
+ margin: '0',
+ pointerEvents: 'none',
+ zIndex: '2147483647',
+ opacity: '0.9',
+ boxShadow: '0 0.25rem 0.75rem rgba(0, 0, 0, 0.3)',
+ } satisfies Partial);
+ ghost.classList.add('mp-sortable-ghost');
+ return ghost;
+ }
+
+ private buildIndicator(): HTMLElement {
+ const el = document.createElement('div');
+ el.className = 'mp-sortable-indicator';
+ Object.assign(el.style, {
+ position: 'fixed',
+ zIndex: '2147483646',
+ background: 'currentColor',
+ pointerEvents: 'none',
+ borderRadius: '1px',
+ } satisfies Partial);
+ return el;
+ }
+
+ private positionIndicator(others: HTMLElement[], boxes: DOMRect[]): void {
+ if (!this.indicator) return;
+ const vertical = this.axis === 'vertical';
+ if (others.length === 0) {
+ this.indicator.style.display = 'none';
+ return;
+ }
+ this.indicator.style.display = '';
+ if (this.dropIndex < boxes.length) {
+ const b = boxes[this.dropIndex];
+ if (vertical) {
+ this.setIndicatorBar(b.left, b.top, b.width, 2, true);
+ } else {
+ this.setIndicatorBar(b.left, b.top, 2, b.height, false);
+ }
+ } else {
+ const b = boxes[boxes.length - 1];
+ if (vertical) {
+ this.setIndicatorBar(b.left, b.bottom, b.width, 2, true);
+ } else {
+ this.setIndicatorBar(b.right, b.top, 2, b.height, false);
+ }
+ }
+ }
+
+ private setIndicatorBar(x: number, y: number, w: number, h: number, horizontalBar: boolean): void {
+ if (!this.indicator) return;
+ Object.assign(this.indicator.style, {
+ left: `${horizontalBar ? x : x - 1}px`,
+ top: `${horizontalBar ? y - 1 : y}px`,
+ width: `${w}px`,
+ height: `${h}px`,
+ });
+ }
+
+ // --------------------------------------------------------------- keyboard
+
+ private onKeyDown = (event: KeyboardEvent): void => {
+ if (this.disabled) return;
+ const target = event.target as Element | null;
+ const itemEl = target?.closest('[data-sortable-id]') ?? null;
+ if (!itemEl) return;
+ const id = itemEl.dataset['sortableId'];
+ if (id == null) return;
+
+ const key = event.key;
+ if (this.grabbedId == null) {
+ if (key === 'm' || key === 'M') {
+ event.preventDefault();
+ this.grabbedId = id;
+ this.announceFor(id, (label, pos, total) => `${label} grabbed. Position ${pos} of ${total}. Use arrow keys to move, Enter to drop.`);
+ }
+ return;
+ }
+
+ // grabbed
+ if (key === 'Escape' || key === 'Enter' || key === 'm' || key === 'M') {
+ event.preventDefault();
+ const droppedId = this.grabbedId;
+ this.grabbedId = null;
+ this.announceFor(droppedId, (label, pos, total) => `${label} dropped. Position ${pos} of ${total}.`);
+ return;
+ }
+
+ const back = key === 'ArrowLeft' || key === 'ArrowUp';
+ const forward = key === 'ArrowRight' || key === 'ArrowDown';
+ if (!back && !forward) return;
+ event.preventDefault();
+
+ const from = this.indexOfId(this.grabbedId);
+ if (from < 0) return;
+ const total = this.opts.items().length;
+ const to = Math.max(0, Math.min(total - 1, from + (forward ? 1 : -1)));
+ if (to === from) return;
+
+ const grabbedId = this.grabbedId;
+ this.emitDrop({ previousIndex: from, currentIndex: to });
+ void this.host.updateComplete.then(() => {
+ const moved = this.container?.querySelector(
+ `[data-sortable-id="${cssEscape(grabbedId)}"]`,
+ );
+ moved?.focus();
+ });
+ this.announceFor(grabbedId, (label, pos, t) => `${label} moved to position ${pos} of ${t}.`);
+ };
+
+ private emitDrop(event: SortDropEvent): void {
+ this.opts.onDrop(event);
+ }
+
+ private announceFor(id: string, make: (label: string, position: number, total: number) => string): void {
+ if (!this.opts.announce) return;
+ const items = this.opts.items();
+ const index = items.findIndex((item) => this.opts.itemId(item) === id);
+ if (index < 0) return;
+ const item = items[index];
+ const label =
+ this.opts.label?.(item) ??
+ this.container?.querySelector(`[data-sortable-id="${cssEscape(id)}"]`)?.textContent?.trim() ??
+ 'Item';
+ this.opts.announce(make(label, index + 1, items.length));
+ }
+}
+
+function cssEscape(value: string): string {
+ const cssApi = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS;
+ if (cssApi?.escape) return cssApi.escape(value);
+ return value.replace(/["\\]/g, '\\$&');
+}
diff --git a/libs/mintplayer-web-components/drag-drop/src/types.ts b/libs/mintplayer-web-components/drag-drop/src/types.ts
new file mode 100644
index 000000000..b56231629
--- /dev/null
+++ b/libs/mintplayer-web-components/drag-drop/src/types.ts
@@ -0,0 +1,52 @@
+/**
+ * Reordering axis. `'both'` (the default) is reading-order aware — it handles a
+ * wrapping flex layout (e.g. chips) where items flow horizontally and wrap onto
+ * new rows. `'horizontal'` / `'vertical'` constrain the drop-index resolution to
+ * a single dimension for strict single-row / single-column lists.
+ */
+export type SortAxis = 'horizontal' | 'vertical' | 'both';
+
+/**
+ * Result of a completed reorder. Indices are CDK-compatible: both are positions
+ * in the *current* array, and `currentIndex` is the final resting index of the
+ * moved item after removal+reinsertion (so `moveItemInArray(arr, previousIndex,
+ * currentIndex)` reproduces the new order).
+ */
+export interface SortDropEvent {
+ previousIndex: number;
+ currentIndex: number;
+}
+
+/**
+ * Configuration for {@link SortableController}. The controller never owns the
+ * data: it reports a single `onDrop` and leaves the host to mutate its own model
+ * (via {@link moveItemInArray}) and re-render.
+ */
+export interface SortableOptions {
+ /** Current item order, read fresh at drag start. Must match the DOM order of `[data-sortable-id]` elements. */
+ items: () => readonly T[];
+ /** Stable id for an item; matched against each draggable element's `data-sortable-id`. */
+ itemId: (item: T) => string;
+ /** Called once when a drag/keyboard move resolves to a new order. */
+ onDrop: (event: SortDropEvent) => void;
+ /** Reading-order model for drop-index resolution. Default `'both'`. */
+ axis?: SortAxis;
+ /**
+ * CSS selector for a drag handle *within* an item. When set, a pointer drag
+ * starts only if the pointerdown landed inside a matching element. Unset =
+ * the whole item is the handle.
+ */
+ handleSelector?: string;
+ /** Pointer movement (px) before a mouse/pen drag begins. Default 5. */
+ dragThresholdPx?: number;
+ /** Touch long-press (ms) before a touch drag arms. Default 600. */
+ longPressMs?: number;
+ /** Movement (px) during the touch long-press window that aborts the drag (treated as a scroll/scrub). Default 10. */
+ touchSlopPx?: number;
+ /** When it returns true, all gestures are ignored. */
+ disabled?: () => boolean;
+ /** Optional human label for an item, used in keyboard-reorder announcements. Falls back to the element's text. */
+ label?: (item: T) => string;
+ /** Optional sink for accessibility announcements (wire to a live-region announcer). */
+ announce?: (message: string) => void;
+}
From b69c0c79bad0724143eb0a8cc00c6f80f749badf Mon Sep 17 00:00:00 2001
From: PieterjanDeClippel
Date: Tue, 30 Jun 2026 14:12:58 +0200
Subject: [PATCH 3/7] feat(tree-select): opt-in chip reorder via registration
seam
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
mp-tree-select gains a `reorderable` attribute and a `reorder` event.
Reordering the selected chips (multiple/checkbox) is wired through a
tiny register/get seam (sortable-registry) so the base bundle imports
no drag code — `reorderable` is inert until a consumer opts in. New
@mintplayer/web-components/tree-select-reorder entry registers the
SortableController; verified the mp-tree-select chunk tree-shakes the
sortable code (0 refs) while the opt-in entry links it. Keyboard
reorder announced via the a11y live-announcer.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../tree-select-reorder/index.ts | 1 +
.../tree-select-reorder/src/index.ts | 29 +++++
.../src/components/mp-tree-select.ts | 104 +++++++++++++++++-
.../src/components/sortable-registry.ts | 42 +++++++
.../tree-select/src/index.ts | 10 ++
.../src/styles/tree-select.styles.scss | 24 ++++
.../tree-select/src/types/index.ts | 1 +
.../tree-select/src/types/tree-select.ts | 10 ++
8 files changed, 219 insertions(+), 2 deletions(-)
create mode 100644 libs/mintplayer-web-components/tree-select-reorder/index.ts
create mode 100644 libs/mintplayer-web-components/tree-select-reorder/src/index.ts
create mode 100644 libs/mintplayer-web-components/tree-select/src/components/sortable-registry.ts
diff --git a/libs/mintplayer-web-components/tree-select-reorder/index.ts b/libs/mintplayer-web-components/tree-select-reorder/index.ts
new file mode 100644
index 000000000..8420b1093
--- /dev/null
+++ b/libs/mintplayer-web-components/tree-select-reorder/index.ts
@@ -0,0 +1 @@
+export * from './src';
diff --git a/libs/mintplayer-web-components/tree-select-reorder/src/index.ts b/libs/mintplayer-web-components/tree-select-reorder/src/index.ts
new file mode 100644
index 000000000..0b4e450a0
--- /dev/null
+++ b/libs/mintplayer-web-components/tree-select-reorder/src/index.ts
@@ -0,0 +1,29 @@
+import { SortableController } from '@mintplayer/web-components/drag-drop';
+import { registerTreeSelectSortable } from '@mintplayer/web-components/tree-select';
+
+/**
+ * Opt-in registrar that wires the framework-agnostic {@link SortableController}
+ * into ``'s chip reordering seam. Importing this module performs
+ * the registration as a side effect — a bare
+ * `import '@mintplayer/web-components/tree-select-reorder';` is enough to make
+ * the `reorderable` attribute live.
+ *
+ * This is the *only* place the base tree-select bundle pulls in drag-drop code,
+ * so consumers that never import it tree-shake the whole sortable implementation
+ * away. Idempotent.
+ */
+export function enableTreeSelectReorder(): void {
+ registerTreeSelectSortable((host, options) => {
+ const controller = new SortableController(host, {
+ items: options.items,
+ itemId: options.itemId,
+ onDrop: options.onDrop,
+ label: options.label,
+ announce: options.announce,
+ axis: 'both',
+ });
+ return { attach: (container) => controller.attach(container) };
+ });
+}
+
+enableTreeSelectReorder();
diff --git a/libs/mintplayer-web-components/tree-select/src/components/mp-tree-select.ts b/libs/mintplayer-web-components/tree-select/src/components/mp-tree-select.ts
index 3531e392e..7a19627e2 100644
--- a/libs/mintplayer-web-components/tree-select/src/components/mp-tree-select.ts
+++ b/libs/mintplayer-web-components/tree-select/src/components/mp-tree-select.ts
@@ -1,6 +1,7 @@
import { LitElement, html, nothing, type TemplateResult } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { OverlayController } from '@mintplayer/web-components/overlay';
+import { LiveAnnouncerController } from '@mintplayer/web-components/a11y';
import '@mintplayer/web-components/treeview';
import type {
MpTreeview,
@@ -15,8 +16,10 @@ import type {
TreeSelectChangeEventDetail,
TreeSelectMode,
TreeSelectProvider,
+ TreeSelectReorderEventDetail,
TreeSelectVariant,
} from '../types';
+import { getTreeSelectSortable, type TreeSelectSortableHandle } from './sortable-registry';
const CARET_SVG =
'
diff --git a/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.scss b/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.scss
index a73dd995a..7928fb273 100644
--- a/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.scss
+++ b/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.scss
@@ -1,46 +1,10 @@
-.dnd-list {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- min-height: 2.5rem;
- padding: 0.5rem;
- border: 1px dashed var(--bs-border-color, #dee2e6);
- border-radius: var(--bs-border-radius, 0.375rem);
+:host {
+ display: block;
}
-.dnd-chip {
- display: inline-flex;
- align-items: center;
- gap: 0.375rem;
- padding: 0.25rem 0.5rem;
+kbd {
+ padding: 0.1rem 0.35rem;
+ font-size: 0.85em;
background: var(--bs-secondary-bg, #e9ecef);
- border-radius: var(--bs-border-radius, 0.375rem);
- cursor: grab;
-}
-
-.dnd-chip.cdk-drag-preview {
- cursor: grabbing;
- box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.2);
-}
-
-.dnd-chip.cdk-drag-placeholder {
- opacity: 0.4;
-}
-
-.drag-handle {
- color: var(--bs-secondary-color, #6c757d);
-}
-
-.dnd-remove {
- border: 0;
- background: transparent;
- color: inherit;
- cursor: pointer;
- line-height: 1;
- padding: 0;
- opacity: 0.7;
-}
-
-.dnd-remove:hover {
- opacity: 1;
+ border-radius: var(--bs-border-radius-sm, 0.25rem);
}
diff --git a/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.ts b/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.ts
index 7fc1b6b27..8a5019732 100644
--- a/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.ts
+++ b/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.ts
@@ -1,5 +1,4 @@
-import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
-import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
+import { Component, ChangeDetectionStrategy, computed, signal } from '@angular/core';
import { BsCodeSnippetComponent } from '@mintplayer/ng-bootstrap/code-snippet';
import { BsFormComponent } from '@mintplayer/ng-bootstrap/form';
import {
@@ -8,6 +7,7 @@ import {
type TreeNode,
type TreeSelectProvider,
} from '@mintplayer/ng-bootstrap/tree-select';
+import { BsTreeSelectReorderDirective } from '@mintplayer/ng-bootstrap/tree-select/reorder';
import { dedent } from 'ts-dedent';
const SAMPLE_TREE: TreeNode[] = [
@@ -34,82 +34,60 @@ const SAMPLE_TREE: TreeNode[] = [
selector: 'demo-tree-select-drag-drop',
templateUrl: './tree-select-drag-drop.component.html',
styleUrls: ['./tree-select-drag-drop.component.scss'],
- imports: [BsCodeSnippetComponent, BsFormComponent, BsTreeSelectComponent, DragDropModule],
+ imports: [BsCodeSnippetComponent, BsFormComponent, BsTreeSelectComponent, BsTreeSelectReorderDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TreeSelectDragDropComponent {
-
protected readonly provider: TreeSelectProvider = new InMemoryTreeSelectProvider(SAMPLE_TREE);
protected readonly selected = signal([]);
+ protected readonly orderLabels = computed(() => this.selected().map((n) => n.label).join(' → '));
+ // value-change fires for selection AND reorder, so this stays in sync with the
+ // chip order shown inside the component.
onValueChange(value: TreeNode | TreeNode[] | null) {
this.selected.set(Array.isArray(value) ? value : value ? [value] : []);
}
- onDrop(event: CdkDragDrop) {
- const items = [...this.selected()];
- moveItemInArray(items, event.previousIndex, event.currentIndex);
- // Push the new order back — the tree-select chips re-render in this order.
- this.selected.set(items);
- }
-
- remove(node: TreeNode) {
- this.selected.set(this.selected().filter((n) => n.id !== node.id));
- }
-
protected readonly snippetBasicHtml = dedent`
-
-
-
- @for (item of selected(); track item.id) {
-
- ☰
- {{ item.label }}
-
-
- }
-
`;
protected readonly snippetBasicTs = dedent`
import { Component, signal } from '@angular/core';
- import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import {
BsTreeSelectComponent,
InMemoryTreeSelectProvider,
type TreeNode,
type TreeSelectProvider,
} from '@mintplayer/ng-bootstrap/tree-select';
+ // Opt-in: importing this directive pulls in the drag-drop code and makes
+ // \`reorderable\` live. Omit it and the reorder code is tree-shaken away.
+ import { BsTreeSelectReorderDirective } from '@mintplayer/ng-bootstrap/tree-select/reorder';
import { BsFormComponent } from '@mintplayer/ng-bootstrap/form';
@Component({
selector: 'my-tree-select-drag-drop-demo',
templateUrl: './my-tree-select-drag-drop-demo.component.html',
- imports: [BsFormComponent, BsTreeSelectComponent, DragDropModule],
+ imports: [BsFormComponent, BsTreeSelectComponent, BsTreeSelectReorderDirective],
})
export class MyTreeSelectDragDropDemoComponent {
protected readonly provider: TreeSelectProvider = new InMemoryTreeSelectProvider(MY_TREE);
protected readonly selected = signal([]);
+ // Reordering chips emits value-change with the new order, so the form
+ // value (and [(ngModel)] / formControl) updates automatically.
onValueChange(value: TreeNode | TreeNode[] | null) {
this.selected.set(Array.isArray(value) ? value : value ? [value] : []);
}
-
- onDrop(event: CdkDragDrop) {
- const items = [...this.selected()];
- moveItemInArray(items, event.previousIndex, event.currentIndex);
- this.selected.set(items);
- }
}
`;
}
diff --git a/libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.ts b/libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.ts
index ca9e822b7..297506d1b 100644
--- a/libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.ts
+++ b/libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.ts
@@ -1,5 +1,6 @@
import {
AfterViewInit,
+ booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
@@ -83,7 +84,7 @@ export class BsTreeSelectComponent implements ControlValueAccessor, AfterViewIni
* `BsTreeSelectReorderDirective` from `@mintplayer/ng-bootstrap/tree-select/reorder`
* (which registers it). Without that import, this flag is inert.
*/
- readonly reorderable = input(false);
+ readonly reorderable = input(false, { transform: booleanAttribute });
readonly value = model(null);
From 490ff4aebcc6428b3554e1cd6c8982b55dd0feba Mon Sep 17 00:00:00 2001
From: PieterjanDeClippel
Date: Tue, 30 Jun 2026 15:08:51 +0200
Subject: [PATCH 7/7] docs(tree-select): mark chip-reorder PRD implemented
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/prd/tree-select-chip-reorder.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/prd/tree-select-chip-reorder.md b/docs/prd/tree-select-chip-reorder.md
index e6bfa8ca7..456d1f378 100644
--- a/docs/prd/tree-select-chip-reorder.md
+++ b/docs/prd/tree-select-chip-reorder.md
@@ -1,6 +1,6 @@
# PRD — Tree-select chip reorder + reusable drag-drop primitive
-**Status:** Planned
+**Status:** Implemented (branch `feature/tree-select-chip-reorder`)
**Owner:** pieterjan@2sky.be
**Created:** 2026-06-30
**Affected libs:** `@mintplayer/web-components` (new `drag-drop` primitive + `tree-select`),