diff --git a/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.html b/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.html
index 90a10ba5f..2c9e16906 100644
--- a/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.html
+++ b/apps/ng-bootstrap-demo/src/app/pages/additional-samples/tree-select-drag-drop/tree-select-drag-drop.component.html
@@ -1,11 +1,18 @@
Tree-select with Drag-drop
- Combine bs-tree-select (in multiple mode) with Angular CDK's
- cdkDropList + cdkDrag to let users reorder the selected items by
- dragging. The tree-select renders its chips inside its own shadow DOM, so the draggable
- list lives alongside it: pick items in the dropdown, then drag to reorder. The new
- order is pushed back into [value], so the chips re-render in the chosen order.
+ Add the reorderable attribute to a multiple /
+ checkbox bs-tree-select to let users reorder the selected chips
+ directly inside the component — drag a chip with the pointer, or focus it and
+ press M then the arrow keys. The new order is emitted through
+ valueChange, so the form value stays in sync.
+
+
+
+ Reordering is opt-in : import BsTreeSelectReorderDirective from
+ @mintplayer/ng-bootstrap/tree-select/reorder and add it to your component's
+ imports. Without that import the drag-drop code is tree-shaken out and
+ reorderable stays inert.
Basic usage
@@ -13,6 +20,7 @@ Basic usage
Basic usage
-
- @for (item of selected(); track item.id) {
-
- ☰
- {{ item.label }}
- ×
-
- } @empty {
-
Nothing selected yet — pick a few items above.
+
+ Current order:
+ @if (selected().length) {
+ {{ orderLabels() }}
+ } @else {
+ nothing selected yet; pick a few items above.
}
-
+
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/docs/prd/tree-select-chip-reorder.md b/docs/prd/tree-select-chip-reorder.md
new file mode 100644
index 000000000..456d1f378
--- /dev/null
+++ b/docs/prd/tree-select-chip-reorder.md
@@ -0,0 +1,289 @@
+# PRD — Tree-select chip reorder + reusable drag-drop primitive
+
+**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`),
+`@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.
diff --git a/libs/mintplayer-ng-bootstrap/tree-select/reorder/index.ts b/libs/mintplayer-ng-bootstrap/tree-select/reorder/index.ts
new file mode 100644
index 000000000..8420b1093
--- /dev/null
+++ b/libs/mintplayer-ng-bootstrap/tree-select/reorder/index.ts
@@ -0,0 +1 @@
+export * from './src';
diff --git a/libs/mintplayer-ng-bootstrap/tree-select/reorder/ng-package.json b/libs/mintplayer-ng-bootstrap/tree-select/reorder/ng-package.json
new file mode 100644
index 000000000..19e3f2bcb
--- /dev/null
+++ b/libs/mintplayer-ng-bootstrap/tree-select/reorder/ng-package.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
+ "lib": {
+ "entryFile": "index.ts"
+ }
+}
diff --git a/libs/mintplayer-ng-bootstrap/tree-select/reorder/src/index.ts b/libs/mintplayer-ng-bootstrap/tree-select/reorder/src/index.ts
new file mode 100644
index 000000000..773d66206
--- /dev/null
+++ b/libs/mintplayer-ng-bootstrap/tree-select/reorder/src/index.ts
@@ -0,0 +1 @@
+export * from './tree-select-reorder.directive';
diff --git a/libs/mintplayer-ng-bootstrap/tree-select/reorder/src/tree-select-reorder.directive.ts b/libs/mintplayer-ng-bootstrap/tree-select/reorder/src/tree-select-reorder.directive.ts
new file mode 100644
index 000000000..727c321db
--- /dev/null
+++ b/libs/mintplayer-ng-bootstrap/tree-select/reorder/src/tree-select-reorder.directive.ts
@@ -0,0 +1,29 @@
+import { Directive } from '@angular/core';
+import { enableTreeSelectReorder } from '@mintplayer/web-components/tree-select-reorder';
+
+/**
+ * Opt-in enabler for `` chip drag/keyboard reorder.
+ *
+ * Importing this directive is what pulls the framework-agnostic drag-drop
+ * sortable implementation into your bundle and registers it with the web
+ * component. Add it to a standalone component's `imports` next to
+ * `BsTreeSelectComponent`:
+ *
+ * ```ts
+ * import { BsTreeSelectComponent } from '@mintplayer/ng-bootstrap/tree-select';
+ * import { BsTreeSelectReorderDirective } from '@mintplayer/ng-bootstrap/tree-select/reorder';
+ *
+ * @Component({ imports: [BsTreeSelectComponent, BsTreeSelectReorderDirective], ... })
+ * ```
+ *
+ * Consumers that never import it tree-shake the entire drag-drop module away,
+ * and `[reorderable]` on the component stays inert.
+ */
+@Directive({
+ selector: 'bs-tree-select[reorderable]',
+})
+export class BsTreeSelectReorderDirective {
+ constructor() {
+ enableTreeSelectReorder();
+ }
+}
diff --git a/libs/mintplayer-ng-bootstrap/tree-select/src/index.ts b/libs/mintplayer-ng-bootstrap/tree-select/src/index.ts
index 73b10143b..382d2269e 100644
--- a/libs/mintplayer-ng-bootstrap/tree-select/src/index.ts
+++ b/libs/mintplayer-ng-bootstrap/tree-select/src/index.ts
@@ -7,6 +7,7 @@ export type {
TreeSelectVariant,
TreeSelectProvider,
TreeSelectChangeEventDetail,
+ TreeSelectReorderEventDetail,
NodePage,
NodeRequest,
} from '@mintplayer/web-components/tree-select';
diff --git a/libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.html b/libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.html
index 2110f2419..a8d7a9d1f 100644
--- a/libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.html
+++ b/libs/mintplayer-ng-bootstrap/tree-select/src/tree-select/tree-select.component.html
@@ -2,6 +2,7 @@
#el
class="bs-tree-select"
(value-change)="onValueChange($event)"
+ (reorder)="onReorder($event)"
(open)="onOpen()"
(close)="onClose()"
(clear)="onClear()"
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 2aae92577..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,
@@ -26,6 +27,7 @@ import {
type TreeSelectChangeEventDetail,
type TreeSelectMode,
type TreeSelectProvider,
+ type TreeSelectReorderEventDetail,
type TreeSelectVariant,
} from '@mintplayer/web-components/tree-select';
@@ -75,12 +77,21 @@ export class BsTreeSelectComponent implements ControlValueAccessor, AfterViewIni
readonly panelScrollHeight = input('300px');
readonly searchDebounceMs = input(200);
readonly disabled = input(false);
+ /**
+ * Enables drag/keyboard reordering of selected chips (multiple/checkbox).
+ * This input only forwards a flag to the WC — it contains no drag code. The
+ * reorder implementation is bundled separately: import
+ * `BsTreeSelectReorderDirective` from `@mintplayer/ng-bootstrap/tree-select/reorder`
+ * (which registers it). Without that import, this flag is inert.
+ */
+ readonly reorderable = input(false, { transform: booleanAttribute });
readonly value = model(null);
readonly opened = output();
readonly closed = output();
readonly cleared = output();
+ readonly reordered = output();
readonly elementRef = viewChild>('el');
@@ -123,6 +134,7 @@ export class BsTreeSelectComponent implements ControlValueAccessor, AfterViewIni
this.bindProp((el) => (el.panelScrollHeight = this.panelScrollHeight()));
this.bindProp((el) => (el.searchDebounceMs = this.searchDebounceMs()));
this.bindProp((el) => (el.disabled = this.effectiveDisabled()));
+ this.bindProp((el) => (el.reorderable = this.reorderable()));
this.bindProp((el) => (el.provider = this.provider()));
this.bindProp((el) => (el.value = this.value()));
@@ -154,6 +166,14 @@ export class BsTreeSelectComponent implements ControlValueAccessor, AfterViewIni
this.onChange(detail.value);
}
+ onReorder(event: Event): void {
+ const detail = (event as CustomEvent).detail;
+ // Reorder changes the selection order; flow it back through the form value.
+ this.value.set(detail.value);
+ this.onChange(detail.value);
+ this.reordered.emit(detail);
+ }
+
onOpen(): void {
this.opened.emit();
}
diff --git a/libs/mintplayer-react-bootstrap/tree-select-reorder/index.ts b/libs/mintplayer-react-bootstrap/tree-select-reorder/index.ts
new file mode 100644
index 000000000..8420b1093
--- /dev/null
+++ b/libs/mintplayer-react-bootstrap/tree-select-reorder/index.ts
@@ -0,0 +1 @@
+export * from './src';
diff --git a/libs/mintplayer-react-bootstrap/tree-select-reorder/src/index.ts b/libs/mintplayer-react-bootstrap/tree-select-reorder/src/index.ts
new file mode 100644
index 000000000..ff022df46
--- /dev/null
+++ b/libs/mintplayer-react-bootstrap/tree-select-reorder/src/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Opt-in for ` ` chip reordering.
+ *
+ * Importing this entry registers the framework-agnostic drag-drop sortable
+ * implementation (side effect) and re-exports `enableTreeSelectReorder` for an
+ * explicit call. Pull it in once in your app:
+ *
+ * ```ts
+ * import '@mintplayer/react-bootstrap/tree-select-reorder';
+ * ```
+ *
+ * Apps that never import it tree-shake the whole drag-drop module away, leaving
+ * `reorderable` inert.
+ */
+export { enableTreeSelectReorder } from '@mintplayer/web-components/tree-select-reorder';
diff --git a/libs/mintplayer-react-bootstrap/tree-select/src/BsTreeSelect.tsx b/libs/mintplayer-react-bootstrap/tree-select/src/BsTreeSelect.tsx
index 0f4a2fabd..971c62f78 100644
--- a/libs/mintplayer-react-bootstrap/tree-select/src/BsTreeSelect.tsx
+++ b/libs/mintplayer-react-bootstrap/tree-select/src/BsTreeSelect.tsx
@@ -1,6 +1,10 @@
import * as React from 'react';
import { createComponent, type EventName } from '@lit/react';
-import { MpTreeSelect, type TreeSelectChangeEventDetail } from '@mintplayer/web-components/tree-select';
+import {
+ MpTreeSelect,
+ type TreeSelectChangeEventDetail,
+ type TreeSelectReorderEventDetail,
+} from '@mintplayer/web-components/tree-select';
/**
* React wrapper for ``. Side-effect-registers the WC.
@@ -18,6 +22,7 @@ export const BsTreeSelect = createComponent({
elementClass: MpTreeSelect,
events: {
onValueChange: 'value-change' as EventName>,
+ onReorder: 'reorder' as EventName>,
onOpen: 'open' as EventName>,
onClose: 'close' as EventName>,
onClear: 'clear' as EventName>,
diff --git a/libs/mintplayer-react-bootstrap/tree-select/src/index.ts b/libs/mintplayer-react-bootstrap/tree-select/src/index.ts
index 29c7e4968..ef53e1d7e 100644
--- a/libs/mintplayer-react-bootstrap/tree-select/src/index.ts
+++ b/libs/mintplayer-react-bootstrap/tree-select/src/index.ts
@@ -6,6 +6,7 @@ export type {
TreeSelectVariant,
TreeSelectProvider,
TreeSelectChangeEventDetail,
+ TreeSelectReorderEventDetail,
NodePage,
NodeRequest,
} from '@mintplayer/web-components/tree-select';
diff --git a/libs/mintplayer-vue-bootstrap/tree-select-reorder/index.ts b/libs/mintplayer-vue-bootstrap/tree-select-reorder/index.ts
new file mode 100644
index 000000000..8420b1093
--- /dev/null
+++ b/libs/mintplayer-vue-bootstrap/tree-select-reorder/index.ts
@@ -0,0 +1 @@
+export * from './src';
diff --git a/libs/mintplayer-vue-bootstrap/tree-select-reorder/src/index.ts b/libs/mintplayer-vue-bootstrap/tree-select-reorder/src/index.ts
new file mode 100644
index 000000000..556cc233c
--- /dev/null
+++ b/libs/mintplayer-vue-bootstrap/tree-select-reorder/src/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Opt-in for ` ` chip reordering.
+ *
+ * Importing this entry registers the framework-agnostic drag-drop sortable
+ * implementation (side effect) and re-exports `enableTreeSelectReorder` for an
+ * explicit call. Pull it in once in your app:
+ *
+ * ```ts
+ * import '@mintplayer/vue-bootstrap/tree-select-reorder';
+ * ```
+ *
+ * Apps that never import it tree-shake the whole drag-drop module away, leaving
+ * `reorderable` inert.
+ */
+export { enableTreeSelectReorder } from '@mintplayer/web-components/tree-select-reorder';
diff --git a/libs/mintplayer-vue-bootstrap/tree-select/src/BsTreeSelect.vue b/libs/mintplayer-vue-bootstrap/tree-select/src/BsTreeSelect.vue
index f3b9f432a..164423bcc 100644
--- a/libs/mintplayer-vue-bootstrap/tree-select/src/BsTreeSelect.vue
+++ b/libs/mintplayer-vue-bootstrap/tree-select/src/BsTreeSelect.vue
@@ -5,6 +5,7 @@ import type {
TreeNode,
TreeSelectMode,
TreeSelectProvider,
+ TreeSelectReorderEventDetail,
TreeSelectVariant,
} from '@mintplayer/web-components/tree-select';
import { h, onBeforeUnmount, onMounted, ref, render, useSlots, watch } from 'vue';
@@ -25,6 +26,7 @@ const props = withDefaults(
panelScrollHeight?: string;
searchDebounceMs?: number;
disabled?: boolean;
+ reorderable?: boolean;
}>(),
{
mode: 'single',
@@ -35,9 +37,12 @@ const props = withDefaults(
panelScrollHeight: '300px',
searchDebounceMs: 200,
disabled: false,
+ reorderable: false,
},
);
+const emit = defineEmits<{ reorder: [TreeSelectReorderEventDetail] }>();
+
const model = defineModel({ default: null });
const el = ref(null);
@@ -52,6 +57,7 @@ const syncConfig = () => {
e.panelScrollHeight = props.panelScrollHeight;
e.searchDebounceMs = props.searchDebounceMs;
e.disabled = props.disabled;
+ e.reorderable = props.reorderable;
e.provider = props.provider;
};
@@ -149,6 +155,7 @@ watch(
props.panelScrollHeight,
props.searchDebounceMs,
props.disabled,
+ props.reorderable,
props.provider,
],
syncConfig,
@@ -164,6 +171,14 @@ function onValueChange(ev: Event) {
const detail = (ev as CustomEvent<{ value: TreeNode | TreeNode[] | null }>).detail;
model.value = detail?.value ?? null;
}
+
+function onReorder(ev: Event) {
+ const detail = (ev as CustomEvent).detail;
+ if (!detail) return;
+ // Reorder changes the selection order; flow it back through v-model.
+ model.value = detail.value;
+ emit('reorder', detail);
+}
@@ -171,5 +186,6 @@ function onValueChange(ev: Event) {
ref="el"
v-bind="$attrs"
@value-change="onValueChange"
+ @reorder="onReorder"
/>
diff --git a/libs/mintplayer-vue-bootstrap/tree-select/src/index.ts b/libs/mintplayer-vue-bootstrap/tree-select/src/index.ts
index 215fbb756..41f785d2d 100644
--- a/libs/mintplayer-vue-bootstrap/tree-select/src/index.ts
+++ b/libs/mintplayer-vue-bootstrap/tree-select/src/index.ts
@@ -6,6 +6,7 @@ export type {
TreeSelectVariant,
TreeSelectProvider,
TreeSelectChangeEventDetail,
+ TreeSelectReorderEventDetail,
NodePage,
NodeRequest,
} from '@mintplayer/web-components/tree-select';
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;
+}
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 =
'' +
@@ -48,6 +51,7 @@ export class MpTreeSelect extends LitElement {
'scroll-height',
'disabled',
'search-debounce-ms',
+ 'reorderable',
];
}
@@ -60,6 +64,7 @@ export class MpTreeSelect extends LitElement {
private _scrollHeight = '300px';
private _disabled = false;
private _searchDebounceMs = 200;
+ private _reorderable = false;
// ---- render callbacks (property-only) ----------------------------------
itemTemplate?: NodeTemplate;
@@ -89,6 +94,10 @@ export class MpTreeSelect extends LitElement {
// ---- infrastructure ----------------------------------------------------
private _abort?: AbortController;
private _debounceTimer = 0;
+ /** Lazily built once a sortable factory is registered (see sortable-registry). */
+ private _sortableHandle?: TreeSelectSortableHandle;
+ private _reorderWarned = false;
+ private readonly announcer = new LiveAnnouncerController(this);
private readonly overlay = new OverlayController(this, {
anchor: () => this.renderRoot.querySelector('.ts-anchor'),
panel: () => this.renderRoot.querySelector('.ts-panel'),
@@ -205,6 +214,19 @@ export class MpTreeSelect extends LitElement {
this._searchDebounceMs = Math.max(0, Number(value) || 0);
}
+ /**
+ * Enables drag/keyboard reordering of the selected chips (`multiple` /
+ * `checkbox` modes). Inert unless a sortable implementation has been
+ * registered via an opt-in import — see {@link getTreeSelectSortable}.
+ */
+ get reorderable(): boolean {
+ return this._reorderable;
+ }
+ set reorderable(value: boolean) {
+ this._reorderable = !!value;
+ this.requestUpdate();
+ }
+
override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
super.attributeChangedCallback(name, oldValue, newValue);
switch (name) {
@@ -232,6 +254,9 @@ export class MpTreeSelect extends LitElement {
case 'search-debounce-ms':
this.searchDebounceMs = Number(newValue);
break;
+ case 'reorderable':
+ this.reorderable = newValue !== null;
+ break;
}
}
@@ -556,7 +581,73 @@ export class MpTreeSelect extends LitElement {
// ---- render ------------------------------------------------------------
override render(): TemplateResult {
- return html`${this.renderTrigger()}${this.renderPanel()}`;
+ return html`${this.renderTrigger()}${this.renderPanel()}${this.announcer.template()}`;
+ }
+
+ override updated(changed: Map): void {
+ super.updated(changed);
+ this.maybeWireReorder();
+ }
+
+ /**
+ * Lazily attach chip reordering once it's enabled, the mode shows chips, and a
+ * sortable factory has been registered by an opt-in import. The controller is
+ * built once; `attach()` is idempotent across re-renders.
+ */
+ private maybeWireReorder(): void {
+ if (!this._reorderable || this._mode === 'single') return;
+ if (!this._sortableHandle) {
+ const factory = getTreeSelectSortable();
+ if (!factory) {
+ if (!this._reorderWarned) {
+ this._reorderWarned = true;
+ console.warn(
+ '[mp-tree-select] `reorderable` is set but no reorder implementation is registered. ' +
+ "Import '@mintplayer/web-components/tree-select-reorder' (or your framework wrapper's " +
+ 'reorder directive) to enable chip reordering.',
+ );
+ }
+ return;
+ }
+ this._sortableHandle = factory(this, {
+ items: () => [...this._selected.values()],
+ itemId: (node) => node.id,
+ label: (node) => node.label,
+ announce: (message) => this.announcer.announce(message),
+ onDrop: ({ previousIndex, currentIndex }) => this.onReorderDrop(previousIndex, currentIndex),
+ });
+ }
+ const container =
+ this.renderRoot.querySelector('.ts-control') ??
+ this.renderRoot.querySelector('.ts-button-body');
+ if (container) this._sortableHandle.attach(container);
+ }
+
+ /** Apply a chip reorder to the selection, then emit `value-change` + `reorder`. */
+ private onReorderDrop(previousIndex: number, currentIndex: number): void {
+ const entries = [...this._selected.entries()];
+ if (
+ previousIndex < 0 ||
+ currentIndex < 0 ||
+ previousIndex >= entries.length ||
+ currentIndex >= entries.length ||
+ previousIndex === currentIndex
+ ) {
+ return;
+ }
+ const [moved] = entries.splice(previousIndex, 1);
+ entries.splice(currentIndex, 0, moved);
+ this._selected = new Map(entries);
+ // value-change keeps forms/CVA in sync with the new order (no add/remove).
+ this.emitChange(undefined, undefined);
+ this.dispatchEvent(
+ new CustomEvent('reorder', {
+ detail: { value: this.value, previousIndex, currentIndex },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ this.requestUpdate();
}
private get hasSelection(): boolean {
@@ -624,11 +715,20 @@ export class MpTreeSelect extends LitElement {
private renderChips(): unknown {
if (this._mode === 'single' || !this.hasSelection) return nothing;
+ const draggable = this._reorderable;
return repeat(
[...this._selected.values()],
(node) => node.id,
(node) => html`
-
+ e.stopPropagation() : nothing}
+ >
+ ${draggable
+ ? html`⠿ `
+ : nothing}
${this.itemTemplate ? this.itemTemplate(node, this._query) : node.label}
diff --git a/libs/mintplayer-web-components/tree-select/src/components/sortable-registry.ts b/libs/mintplayer-web-components/tree-select/src/components/sortable-registry.ts
new file mode 100644
index 000000000..e39965fa0
--- /dev/null
+++ b/libs/mintplayer-web-components/tree-select/src/components/sortable-registry.ts
@@ -0,0 +1,42 @@
+import type { ReactiveControllerHost } from 'lit';
+import type { TreeNode } from '../types';
+
+/** Minimal handle the WC needs from a registered sortable implementation. */
+export interface TreeSelectSortableHandle {
+ /** Wire pointer/keyboard reordering to the element containing the chips. Idempotent. */
+ attach(container: Element): void;
+}
+
+/** Options the WC hands the factory when it wants to enable chip reordering. */
+export interface TreeSelectSortableOptions {
+ items: () => readonly TreeNode[];
+ itemId: (node: TreeNode) => string;
+ onDrop: (event: { previousIndex: number; currentIndex: number }) => void;
+ label?: (node: TreeNode) => string;
+ announce?: (message: string) => void;
+}
+
+export type TreeSelectSortableFactory = (
+ host: ReactiveControllerHost & HTMLElement,
+ options: TreeSelectSortableOptions,
+) => TreeSelectSortableHandle;
+
+/**
+ * Registration seam that keeps the drag-drop code out of the base bundle. The
+ * base `` imports only `getTreeSelectSortable()` (a getter — no
+ * drag code). The heavy `SortableController` is reachable only after a consumer
+ * opts in by importing an artifact that calls `registerTreeSelectSortable(...)`
+ * (e.g. `@mintplayer/web-components/tree-select-reorder` or the Angular
+ * `BsTreeSelectReorderDirective`). With nothing registered, `reorderable` is
+ * inert — so tree-shaking can drop the whole drag-drop module for consumers that
+ * never reorder.
+ */
+let factory: TreeSelectSortableFactory | undefined;
+
+export function registerTreeSelectSortable(register: TreeSelectSortableFactory): void {
+ factory = register;
+}
+
+export function getTreeSelectSortable(): TreeSelectSortableFactory | undefined {
+ return factory;
+}
diff --git a/libs/mintplayer-web-components/tree-select/src/index.ts b/libs/mintplayer-web-components/tree-select/src/index.ts
index 27f72dff6..edf14e4fa 100644
--- a/libs/mintplayer-web-components/tree-select/src/index.ts
+++ b/libs/mintplayer-web-components/tree-select/src/index.ts
@@ -1,5 +1,14 @@
export { MpTreeSelect } from './components';
export type { NodeTemplate, ValueTemplate, PanelTemplate } from './components';
+export {
+ registerTreeSelectSortable,
+ getTreeSelectSortable,
+} from './components/sortable-registry';
+export type {
+ TreeSelectSortableHandle,
+ TreeSelectSortableOptions,
+ TreeSelectSortableFactory,
+} from './components/sortable-registry';
export type {
TreeSelectMode,
TreeSelectVariant,
@@ -7,6 +16,7 @@ export type {
NodeRequest,
TreeSelectProvider,
TreeSelectChangeEventDetail,
+ TreeSelectReorderEventDetail,
TreeNode,
} from './types';
export { InMemoryTreeSelectProvider } from './providers/in-memory-provider';
diff --git a/libs/mintplayer-web-components/tree-select/src/styles/tree-select.styles.scss b/libs/mintplayer-web-components/tree-select/src/styles/tree-select.styles.scss
index 5e49f9878..c1043a10f 100644
--- a/libs/mintplayer-web-components/tree-select/src/styles/tree-select.styles.scss
+++ b/libs/mintplayer-web-components/tree-select/src/styles/tree-select.styles.scss
@@ -130,6 +130,30 @@ button {
white-space: nowrap;
}
+.ts-chip--draggable {
+ // touch-action:none lets the long-press drag take over without the browser
+ // hijacking the gesture for scrolling (the gesture is frozen at touchstart).
+ touch-action: none;
+ user-select: none;
+ cursor: grab;
+}
+
+.ts-chip--draggable:active {
+ cursor: grabbing;
+}
+
+.ts-chip--draggable:focus-visible {
+ outline: 2px solid var(--bs-primary, #0d6efd);
+ outline-offset: 1px;
+}
+
+.ts-chip-grip {
+ display: inline-flex;
+ align-items: center;
+ line-height: 1;
+ color: var(--bs-secondary-color, #6c757d);
+}
+
.ts-chip-remove,
.ts-clear {
display: inline-flex;
diff --git a/libs/mintplayer-web-components/tree-select/src/types/index.ts b/libs/mintplayer-web-components/tree-select/src/types/index.ts
index da37cbb89..38a5d5413 100644
--- a/libs/mintplayer-web-components/tree-select/src/types/index.ts
+++ b/libs/mintplayer-web-components/tree-select/src/types/index.ts
@@ -5,5 +5,6 @@ export type {
NodeRequest,
TreeSelectProvider,
TreeSelectChangeEventDetail,
+ TreeSelectReorderEventDetail,
TreeNode,
} from './tree-select';
diff --git a/libs/mintplayer-web-components/tree-select/src/types/tree-select.ts b/libs/mintplayer-web-components/tree-select/src/types/tree-select.ts
index 4c196ccad..077bafebc 100644
--- a/libs/mintplayer-web-components/tree-select/src/types/tree-select.ts
+++ b/libs/mintplayer-web-components/tree-select/src/types/tree-select.ts
@@ -55,4 +55,14 @@ export interface TreeSelectChangeEventDetail {
removed?: TreeNode;
}
+/** Detail of the `reorder` event (multiple/checkbox chip drag/keyboard reorder). */
+export interface TreeSelectReorderEventDetail {
+ /** The full selection in its new order. */
+ value: TreeNode | TreeNode[] | null;
+ /** Index the moved chip came from. */
+ previousIndex: number;
+ /** Index the moved chip landed at. */
+ currentIndex: number;
+}
+
export type { TreeNode };