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 = ' + 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 }; From 96d7003f180f13720cb5a8f31ceb57bc7c04b9a7 Mon Sep 17 00:00:00 2001 From: PieterjanDeClippel Date: Tue, 30 Jun 2026 14:27:04 +0200 Subject: [PATCH 4/7] feat(ng-bootstrap/tree-select): reorderable input + reorder opt-in directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base BsTreeSelectComponent gains a `reorderable` flag passthrough and a `reordered` output (reorder also flows through the form value via CVA) — no drag code in the base entry. New @mintplayer/ng-bootstrap/tree-select/reorder secondary entry ships BsTreeSelectReorderDirective (selector bs-tree-select[reorderable]); its constructor calls enableTreeSelectReorder() to register the sortable factory. Importing the directive is the opt-in that bundles the drag-drop code. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tree-select/reorder/index.ts | 1 + .../tree-select/reorder/ng-package.json | 6 ++++ .../tree-select/reorder/src/index.ts | 1 + .../src/tree-select-reorder.directive.ts | 29 +++++++++++++++++++ .../tree-select/src/index.ts | 1 + .../tree-select/tree-select.component.html | 1 + .../src/tree-select/tree-select.component.ts | 19 ++++++++++++ 7 files changed, 58 insertions(+) create mode 100644 libs/mintplayer-ng-bootstrap/tree-select/reorder/index.ts create mode 100644 libs/mintplayer-ng-bootstrap/tree-select/reorder/ng-package.json create mode 100644 libs/mintplayer-ng-bootstrap/tree-select/reorder/src/index.ts create mode 100644 libs/mintplayer-ng-bootstrap/tree-select/reorder/src/tree-select-reorder.directive.ts 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..ca9e822b7 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 @@ -26,6 +26,7 @@ import { type TreeSelectChangeEventDetail, type TreeSelectMode, type TreeSelectProvider, + type TreeSelectReorderEventDetail, type TreeSelectVariant, } from '@mintplayer/web-components/tree-select'; @@ -75,12 +76,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); readonly value = model(null); readonly opened = output(); readonly closed = output(); readonly cleared = output(); + readonly reordered = output(); readonly elementRef = viewChild>('el'); @@ -123,6 +133,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 +165,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(); } From f9e24966e2b9d01ffe69dcdd08ba46582f58cd6b Mon Sep 17 00:00:00 2001 From: PieterjanDeClippel Date: Tue, 30 Jun 2026 14:37:10 +0200 Subject: [PATCH 5/7] feat(react,vue/tree-select): reorder event + reorderable passthrough React adds an onReorder event mapping; Vue adds a reorderable prop, a reorder emit, and flows the reordered order back through v-model. Both gain a thin `tree-select-reorder` opt-in entry that re-exports enableTreeSelectReorder so consumers pull in the drag-drop module only when they want it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tree-select-reorder/index.ts | 1 + .../tree-select-reorder/src/index.ts | 15 +++++++++++++++ .../tree-select/src/BsTreeSelect.tsx | 7 ++++++- .../tree-select/src/index.ts | 1 + .../tree-select-reorder/index.ts | 1 + .../tree-select-reorder/src/index.ts | 15 +++++++++++++++ .../tree-select/src/BsTreeSelect.vue | 16 ++++++++++++++++ .../tree-select/src/index.ts | 1 + 8 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 libs/mintplayer-react-bootstrap/tree-select-reorder/index.ts create mode 100644 libs/mintplayer-react-bootstrap/tree-select-reorder/src/index.ts create mode 100644 libs/mintplayer-vue-bootstrap/tree-select-reorder/index.ts create mode 100644 libs/mintplayer-vue-bootstrap/tree-select-reorder/src/index.ts 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); +} 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'; From 7e9f64a11b91b5dbb1525fbcf68a8b874f10e496 Mon Sep 17 00:00:00 2001 From: PieterjanDeClippel Date: Tue, 30 Jun 2026 15:07:38 +0200 Subject: [PATCH 6/7] feat(demo): tree-select chips reorder in place; drop the cdkDropList bar Rewrites additional-samples/tree-select-drag-drop to use the new `reorderable` attribute + BsTreeSelectReorderDirective opt-in instead of the separate CDK drop-list below the component. Also makes the wrapper's `reorderable` input accept a bare boolean attribute (booleanAttribute transform) so `` typechecks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tree-select-drag-drop.component.html | 39 +++++++-------- .../tree-select-drag-drop.component.scss | 48 +++---------------- .../tree-select-drag-drop.component.ts | 48 +++++-------------- .../src/tree-select/tree-select.component.ts | 3 +- 4 files changed, 41 insertions(+), 97 deletions(-) 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/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`),