@danxbot/ui 3.4.3 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +9948 -9289
- package/dist/index.js.map +1 -1
- package/dist/types/components/AppShell.d.ts +32 -2
- package/dist/types/components/Board.d.ts +131 -0
- package/dist/types/components/Callout.d.ts +163 -0
- package/dist/types/components/RecordCard.d.ts +145 -1
- package/dist/types/components/board/context.d.ts +60 -0
- package/dist/types/components/board/recipe.d.ts +52 -0
- package/dist/types/components/kanban/KanbanBoard.d.ts +1 -1
- package/dist/types/components/kanban/types.d.ts +10 -0
- package/dist/types/index.d.ts +5 -1
- package/dist/types/lib/board/index.d.ts +14 -0
- package/dist/types/lib/board/model.d.ts +43 -0
- package/dist/types/lib/board/move.d.ts +162 -0
- package/dist/types/lib/board/types.d.ts +158 -0
- package/dist/types/lib/board/use-board.d.ts +47 -0
- package/package.json +3 -2
- package/dist/types/components/kanban/resolve.d.ts +0 -31
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `lib/board` module's own barrel — model.ts, move.ts and use-board.ts
|
|
3
|
+
* (this agent's three files) plus a re-export of the sibling `types.ts` this
|
|
4
|
+
* module is built against. Mirrors `lib/dnd/index.ts`'s own shape for the
|
|
5
|
+
* same reason: a consumer reaching for anything board-related should have
|
|
6
|
+
* one import path, not one per file.
|
|
7
|
+
*
|
|
8
|
+
* NOT wired into the package's top-level `src/index.ts` — that barrel is
|
|
9
|
+
* owned by the contract's author, not this agent (see this round's brief).
|
|
10
|
+
*/
|
|
11
|
+
export type { BoardAccessors, BoardColumnDef, BoardColumnId, BoardColumnModel, BoardItemId, BoardMove, BoardMoveRequest, BoardMoveSource, BoardMoveVerdict, UseBoardOptions, } from "./types";
|
|
12
|
+
export { buildBoardModel, type BoardModel } from "./model";
|
|
13
|
+
export { applyBoardMoves, resolveMove, type ResolveMoveContext, type ResolvedMove, } from "./move";
|
|
14
|
+
export { useBoard, type UseBoardResult } from "./use-board";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ==== BOARD MODEL ====
|
|
3
|
+
*
|
|
4
|
+
* Pure derivation: `items` + column defs + accessors -> one `BoardColumnModel`
|
|
5
|
+
* per declared column, ready to render. No React, no mutation, no I/O other
|
|
6
|
+
* than the diagnostic `console.warn` below — the same inputs always produce
|
|
7
|
+
* the same output, which is what makes this testable without a browser and
|
|
8
|
+
* safe to call on every render (`use-board.ts` wraps it in a `useMemo`, it
|
|
9
|
+
* does not need to defend itself).
|
|
10
|
+
*
|
|
11
|
+
* WHY AN ITEM THAT MATCHES NO COLUMN IS NEVER DROPPED. GitHub Projects has a
|
|
12
|
+
* live, cited bug (RES-GAME / tracker) where grouping by parent silently
|
|
13
|
+
* drops sub-issues that carry a valid link — the item is real, the grouping
|
|
14
|
+
* key is merely stale, and the user has no way to discover the card still
|
|
15
|
+
* exists. A board built the same way inherits the identical bug the moment a
|
|
16
|
+
* column is renamed or retired out from under live data, which is routine
|
|
17
|
+
* here (a status gets retired, a card's derived status hasn't caught up to a
|
|
18
|
+
* board's `columns` prop yet, a consumer passes a filtered `columns` list).
|
|
19
|
+
* So an item whose `getColumnId` names no declared column is never
|
|
20
|
+
* discarded — it is routed into `unassigned`, a structurally present field
|
|
21
|
+
* of the model rather than a side channel a caller has to remember to check.
|
|
22
|
+
* Every item that goes in comes out somewhere: either inside exactly one
|
|
23
|
+
* `columns[].items`, or inside `unassigned`. That invariant is asserted by
|
|
24
|
+
* the "floor" test in board-core.test.ts, so a future edit that starts
|
|
25
|
+
* quietly filtering can't pass silently either.
|
|
26
|
+
*
|
|
27
|
+
* A single summarising `console.warn` also fires when any item orphans,
|
|
28
|
+
* matching the `[danxbot] …` prefix `ErrorBoundary.tsx` already uses (and
|
|
29
|
+
* unguarded, as that one is — this library has no dev-only convention). It is
|
|
30
|
+
* a diagnostic ONLY, never the sole signal, since a console message is easy to
|
|
31
|
+
* miss and impossible to act on from inside a pure function. The `data` itself
|
|
32
|
+
* is what must never be lost; the warning just makes the gap visible before a
|
|
33
|
+
* consumer has built an "Unsorted" affordance for `unassigned`.
|
|
34
|
+
*/
|
|
35
|
+
import type { BoardAccessors, BoardColumnDef, BoardColumnModel } from "./types";
|
|
36
|
+
/** The full derivation result. See the file header for why `unassigned` exists. */
|
|
37
|
+
export interface BoardModel<T> {
|
|
38
|
+
/** One entry per declared column, in the SAME order as the `columns` input. */
|
|
39
|
+
columns: readonly BoardColumnModel<T>[];
|
|
40
|
+
/** Items whose `getColumnId` matched no entry in `columns`. Never dropped. */
|
|
41
|
+
unassigned: readonly T[];
|
|
42
|
+
}
|
|
43
|
+
export declare function buildBoardModel<T>(items: readonly T[], columns: readonly BoardColumnDef[], accessors: BoardAccessors<T>): BoardModel<T>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ==== MOVE ====
|
|
3
|
+
*
|
|
4
|
+
* The one validated pipeline every `BoardMoveRequest` passes through, no
|
|
5
|
+
* matter which of the four `BoardMoveSource` values produced it. `types.ts`
|
|
6
|
+
* names this the load-bearing rule: pointer drag, keyboard, the mandatory
|
|
7
|
+
* "Move to…" menu (WCAG 2.5.7) and bulk selection all construct a request
|
|
8
|
+
* and call the SAME function here. A per-source branch that skips a step is
|
|
9
|
+
* exactly how the accessible paths rot — the drag path gets exercised daily
|
|
10
|
+
* and the menu path silently stops agreeing with it, taking the 2.5.7
|
|
11
|
+
* alternative down with it and nobody notices until an audit.
|
|
12
|
+
*
|
|
13
|
+
* TWO RESPONSIBILITIES, DELIBERATELY SEPARATE FUNCTIONS:
|
|
14
|
+
*
|
|
15
|
+
* `resolveMove` decides WHETHER a request may commit. It is the only thing
|
|
16
|
+
* `use-board.ts`'s `requestMove` calls, and it never touches `T[]` — it
|
|
17
|
+
* returns a verdict plus the subset of moves worth writing.
|
|
18
|
+
*
|
|
19
|
+
* `applyBoardMoves` decides WHAT the resulting `items` array looks like,
|
|
20
|
+
* given moves already known to be valid (typically `resolveMove(...).commit`).
|
|
21
|
+
* It does no validation of its own — that would duplicate `resolveMove` — and
|
|
22
|
+
* it is exported separately because `onMove` on `UseBoardOptions` returns
|
|
23
|
+
* `void`: this module hands the consumer a verdict, not a next state, so a
|
|
24
|
+
* consumer's own `onMove` needs a correct way to turn "these moves are real"
|
|
25
|
+
* into "here is my next `items` array." Without it, every consumer either
|
|
26
|
+
* re-derives the exact index arithmetic `applyMove` exists to remove, or
|
|
27
|
+
* worse, folds it over a bulk request and gets the trap below.
|
|
28
|
+
*
|
|
29
|
+
* WHY `resolveMove` DOES ITS OWN STRUCTURAL CHECKS BEFORE EVER CALLING THE
|
|
30
|
+
* CALLER'S `validateMove`. `validateMove` is optional and, per `types.ts`,
|
|
31
|
+
* "Absent means every move is allowed" — so nothing upstream is guaranteed to
|
|
32
|
+
* have checked that `itemId` names a real item or that a `columnId` names a
|
|
33
|
+
* declared column. A malformed request must never reach a consumer's domain
|
|
34
|
+
* rule carrying numbers that don't correspond to reality, and a consumer's
|
|
35
|
+
* `validateMove` should never have to re-derive checks this module can prove
|
|
36
|
+
* from its own inputs for free. So structural validity runs FIRST, always on,
|
|
37
|
+
* not configurable — before `validateMove`, before no-op filtering.
|
|
38
|
+
*
|
|
39
|
+
* WHY NO-OP FILTERING IS PER-MOVE BUT VALIDITY IS ALL-OR-NOTHING. These are
|
|
40
|
+
* different questions. Atomicity ("did the batch survive as one unit")
|
|
41
|
+
* governs whether the request is safe to commit AT ALL — RES-GAME names this
|
|
42
|
+
* exactly: "moving five selected cards should not accept the first two and
|
|
43
|
+
* reject the remaining three." One bad move rejects the whole batch, full
|
|
44
|
+
* stop. But once a batch is known-good, a card that happens to already be
|
|
45
|
+
* exactly where it is going is not an error — it is nothing to write.
|
|
46
|
+
* Dropping that ONE entry from the committed set changes nothing about the
|
|
47
|
+
* other four having moved correctly, and `onMove`'s own doc on
|
|
48
|
+
* `UseBoardOptions` names the cost of not doing this: "the previous board
|
|
49
|
+
* wrote its ordering field on every release that landed where it started."
|
|
50
|
+
*
|
|
51
|
+
* NO-OP IS A PER-MOVE, SYNTACTIC CHECK — REUSING `isNoOp` FROM `lib/dnd`,
|
|
52
|
+
* NOT A FULL SIMULATION OF FINAL POSITION. `isNoOp(from, to)` says "same
|
|
53
|
+
* column, same index" for exactly one move, taken at face value. For a bulk
|
|
54
|
+
* request whose moves interact (two items landing in the same column), a
|
|
55
|
+
* move's own `{column, index}` pair can be untouched while its ABSOLUTE final
|
|
56
|
+
* position shifts because a sibling move inserted ahead of it. This module
|
|
57
|
+
* does not attempt to detect that: `to.index` is the caller's stated INTENT,
|
|
58
|
+
* so a move that literally says "column A index 2 to column A index 2" is
|
|
59
|
+
* declaring no intended change regardless of what its bulk-mates do, and
|
|
60
|
+
* that is the same per-item promise the drag engine's own `DragEndEvent.moved`
|
|
61
|
+
* makes — it never reasoned about siblings either, because a single drag
|
|
62
|
+
* gesture never has any.
|
|
63
|
+
*
|
|
64
|
+
* THE BULK INDEX CONVENTION, AND WHY IT HAS TO BE STATED EXPLICITLY. The
|
|
65
|
+
* engine's single-item convention — `to.index` counts the destination list
|
|
66
|
+
* WITH THE DRAGGED ITEM ALREADY REMOVED — has no obvious generalisation to N
|
|
67
|
+
* items removed from possibly-different columns at once, and `types.ts`
|
|
68
|
+
* does not pick one; that is this module's decision to make and document.
|
|
69
|
+
* THE RULE: every move's `to.index`, bulk or not, counts its destination
|
|
70
|
+
* column's items with EVERY item named ANYWHERE in the same request already
|
|
71
|
+
* removed — not just itself. For a single-move request that is identical to
|
|
72
|
+
* the engine's own rule (nothing else is being removed). For a bulk request
|
|
73
|
+
* it means a UI constructing the request reasons about one shared reference
|
|
74
|
+
* frame per target column ("insert these three selected cards starting at
|
|
75
|
+
* position two among the cards that are staying put"), not about where its
|
|
76
|
+
* OTHER selected cards will land — which is the only version of "bulk move
|
|
77
|
+
* to a position" an operator selecting five cards can sensibly form an
|
|
78
|
+
* intention about in the first place.
|
|
79
|
+
*
|
|
80
|
+
* THE TRAP THIS CONVENTION EXISTS TO AVOID: SEQUENTIAL applyMove FOLDING
|
|
81
|
+
* SILENTLY CORRUPTS A BULK REORDER. Applying move 1 then move 2 via
|
|
82
|
+
* `applyMove` one at a time is NOT equivalent to applying them together,
|
|
83
|
+
* because move 1's splice shifts every index move 2 was computed against —
|
|
84
|
+
* and worse, if move 2's stored index is blindly reused against the
|
|
85
|
+
* ALREADY-SHIFTED array (rather than re-resolved), it can silently pick up
|
|
86
|
+
* the wrong item's position entirely. `applyBoardMoves` never folds moves
|
|
87
|
+
* sequentially over a mutating array for this reason. Instead it resolves
|
|
88
|
+
* every move against ONE shared snapshot — the residual list per column with
|
|
89
|
+
* every moved item already removed — and inserts all of a column's incoming
|
|
90
|
+
* items in a single deterministic pass: sorted by `to.index` ascending
|
|
91
|
+
* (ties broken by the item's original position in `moves`, i.e. request
|
|
92
|
+
* order), each inserted at `to.index + (insertions already made into this
|
|
93
|
+
* column)`. That running offset is exactly what accounts for earlier
|
|
94
|
+
* insertions shifting the positions later ones land at, computed once
|
|
95
|
+
* instead of re-derived per move. board-core.test.ts pins a concrete bulk
|
|
96
|
+
* case where the naive sequential-splice answer and this one visibly differ,
|
|
97
|
+
* so the property is proven rather than incidentally true.
|
|
98
|
+
*
|
|
99
|
+
* WHY `applyBoardMoves` CALLS THE SHARED `applyMove` FOR THE ONE CASE WHERE
|
|
100
|
+
* IT APPLIES, AND HAND-SPLICES EVERYWHERE ELSE. `applyMove(list, from, to)`
|
|
101
|
+
* splices ONE array — it has no way to express "remove from list A, insert
|
|
102
|
+
* into list B," because a cross-column move is inherently a two-list
|
|
103
|
+
* operation. The design system's own `DragDropPage` demo shows this exact
|
|
104
|
+
* split already: `applyMove` for the same-container branch, a manual
|
|
105
|
+
* remove-then-insert for the cross-container one. This module follows the
|
|
106
|
+
* identical rule for the single-move, same-column case (delegates to
|
|
107
|
+
* `applyMove` outright — see the equivalence test, which proves the general
|
|
108
|
+
* residual-based path used for every other case produces byte-identical
|
|
109
|
+
* output to `applyMove` when it degenerates to N=1, same column). Every
|
|
110
|
+
* other shape — cross-column, or any bulk request — has no single shared
|
|
111
|
+
* array to splice, so it goes through the residual-and-insert pass above.
|
|
112
|
+
*/
|
|
113
|
+
import type { BoardAccessors, BoardColumnDef, BoardMove, BoardMoveRequest, BoardMoveVerdict } from "./types";
|
|
114
|
+
/** Everything `resolveMove` reads to prove a request is structurally real. */
|
|
115
|
+
export interface ResolveMoveContext<T> {
|
|
116
|
+
items: readonly T[];
|
|
117
|
+
columns: readonly BoardColumnDef[];
|
|
118
|
+
accessors: BoardAccessors<T>;
|
|
119
|
+
/** Consulted after structural validity holds. See the file header for order. */
|
|
120
|
+
validateMove?: (request: BoardMoveRequest) => BoardMoveVerdict;
|
|
121
|
+
}
|
|
122
|
+
export interface ResolvedMove {
|
|
123
|
+
verdict: BoardMoveVerdict;
|
|
124
|
+
/**
|
|
125
|
+
* The subset of the request's moves worth committing, in original order.
|
|
126
|
+
* Empty whenever there is nothing to write — either every move was a
|
|
127
|
+
* no-op, or `verdict.ok` is false. Only ever meaningful when `verdict.ok`.
|
|
128
|
+
*/
|
|
129
|
+
commit: readonly BoardMove[];
|
|
130
|
+
}
|
|
131
|
+
export declare function resolveMove<T>(context: ResolveMoveContext<T>, request: BoardMoveRequest): ResolvedMove;
|
|
132
|
+
/**
|
|
133
|
+
* Turn a set of already-valid moves into the consumer's next `items` array.
|
|
134
|
+
* Call this with moves already proven valid — typically `resolveMove(...).commit`
|
|
135
|
+
* — never with raw, unvalidated input: this function does no checking of its
|
|
136
|
+
* own, on purpose, so it stays a pure array transform with no notion of
|
|
137
|
+
* "reject." Handles N=1 and N>1 (same-column or cross-column, mixed) with the
|
|
138
|
+
* one algorithm described in the file header.
|
|
139
|
+
*
|
|
140
|
+
* Reorders which COLUMN each item's block appears in within the returned
|
|
141
|
+
* flat array (columns are emitted in first-seen order, any brand-new target
|
|
142
|
+
* column appended after); WITHIN a column, order is always exactly what the
|
|
143
|
+
* moves + original relative order produce. Between-column interleaving in
|
|
144
|
+
* the input `items` array is not preserved because nothing in this module's
|
|
145
|
+
* contract depends on it — `BoardAccessors.getColumnId` is what determines
|
|
146
|
+
* column membership on every subsequent `buildBoardModel` call, and
|
|
147
|
+
* `buildBoardModel` only ever reads WITHIN-column order.
|
|
148
|
+
*
|
|
149
|
+
* NEVER WRITES A FIELD ON ANY ITEM — ONLY REPOSITIONS. `BoardAccessors` has
|
|
150
|
+
* no setter, on purpose (the file header on `types.ts` — items are opaque),
|
|
151
|
+
* so this function CANNOT change what `getColumnId` reports for a moved
|
|
152
|
+
* item; every returned item is the exact same reference it was given, just
|
|
153
|
+
* possibly in a different slot. For a CROSS-column move this means the
|
|
154
|
+
* repositioning alone does nothing observable until the caller ALSO updates
|
|
155
|
+
* the moved item's own underlying field (a `status`, a `columnId`, whatever
|
|
156
|
+
* the consumer's domain calls it) — that write is the consumer's, entirely
|
|
157
|
+
* outside this module, because only the consumer's own accessor knows how to
|
|
158
|
+
* produce it. The usual order is: update the field(s) first, THEN call this
|
|
159
|
+
* to fix array position — see board-core.test.ts's "what it does and does
|
|
160
|
+
* not mutate" tests, which pin both halves of this split.
|
|
161
|
+
*/
|
|
162
|
+
export declare function applyBoardMoves<T>(items: readonly T[], accessors: BoardAccessors<T>, moves: readonly BoardMove[]): T[];
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board contract. One model shape, one movement pipeline, read by the
|
|
3
|
+
* headless core and by the React slot layer above it.
|
|
4
|
+
*
|
|
5
|
+
* WHAT A BOARD IS HERE. Columns of items, where the items are OPAQUE to us. The
|
|
6
|
+
* board never learns what an item means — no `title`, no `priority`, no
|
|
7
|
+
* `assignee`, no status vocabulary. It knows an item's identity, which column
|
|
8
|
+
* holds it, and nothing else; everything visible is rendered by the consumer.
|
|
9
|
+
* That is not squeamishness about coupling, it is the only way the same
|
|
10
|
+
* component serves a second application: the moment a named prop describes a
|
|
11
|
+
* domain field, every consumer whose domain differs has to fight it.
|
|
12
|
+
*
|
|
13
|
+
* WHY ACCESSORS RATHER THAN A REQUIRED ITEM SHAPE. Demanding
|
|
14
|
+
* `{id, columnId}` forces every consumer to map their data into our shape and
|
|
15
|
+
* back out again, and that mapping is where identity gets lost — a remap that
|
|
16
|
+
* regenerates ids breaks drag mid-gesture, and it breaks silently. Reading the
|
|
17
|
+
* consumer's own objects through accessor functions means their array IS the
|
|
18
|
+
* source of truth and no copy exists to drift.
|
|
19
|
+
*
|
|
20
|
+
* THE MOVEMENT PIPELINE IS SINGULAR, AND THAT IS THE LOAD-BEARING RULE.
|
|
21
|
+
* Pointer drag, keyboard, the action menu and a bulk move all terminate in the
|
|
22
|
+
* SAME validated `onMove`. The alternative — a code path per input — is exactly
|
|
23
|
+
* how the accessible paths rot: the drag path gets exercised daily and the menu
|
|
24
|
+
* path silently stops agreeing with it, taking the WCAG 2.5.7 alternative
|
|
25
|
+
* (below) down with it. There is one funnel, so a fix or a bug reaches every
|
|
26
|
+
* input at once.
|
|
27
|
+
*
|
|
28
|
+
* WCAG 2.2 SC 2.5.7 IS WHY A MENU PATH EXISTS AT ALL, AND KEYBOARD DOES NOT
|
|
29
|
+
* SATISFY IT. Verbatim from the W3C's own understanding document: "achieving
|
|
30
|
+
* keyboard equivalence for a dragging operation does not automatically meet
|
|
31
|
+
* this success criterion, unless that equivalent keyboard operation also
|
|
32
|
+
* provides controls that can be clicked or tapped with a pointer." A touchscreen
|
|
33
|
+
* user may have no keyboard at all. So a single-pointer, non-dragging route to
|
|
34
|
+
* every drag outcome is mandatory rather than a courtesy, and `"menu"` is a
|
|
35
|
+
* first-class `BoardMoveSource` for that reason.
|
|
36
|
+
*
|
|
37
|
+
* INDICES COUNT THE LIST WITH THE MOVED ITEM ALREADY REMOVED. Inherited
|
|
38
|
+
* deliberately from `lib/dnd`'s `DropTarget` (see its header): it makes
|
|
39
|
+
* same-column and cross-column moves arithmetically identical and removes the
|
|
40
|
+
* whole off-by-one class. Every index in this file means that. Restating it
|
|
41
|
+
* here rather than assuming it, because a board that re-derives its own
|
|
42
|
+
* convention alongside the engine's is how the two drift.
|
|
43
|
+
*/
|
|
44
|
+
import type { ContainerId, DragId } from "../dnd";
|
|
45
|
+
/**
|
|
46
|
+
* Identities are the engine's, not parallel types. A board column IS a drag
|
|
47
|
+
* container and a board item IS a draggable, so aliasing them keeps a value
|
|
48
|
+
* from having to be cast on its way between the two layers — the kind of cast
|
|
49
|
+
* that compiles forever and is wrong once.
|
|
50
|
+
*/
|
|
51
|
+
export type BoardColumnId = ContainerId;
|
|
52
|
+
export type BoardItemId = DragId;
|
|
53
|
+
/**
|
|
54
|
+
* How the board reads the consumer's own objects. `getId` and `getColumnId` are
|
|
55
|
+
* required because they are the two facts a board cannot function without;
|
|
56
|
+
* everything else about an item is the consumer's business.
|
|
57
|
+
*/
|
|
58
|
+
export interface BoardAccessors<T> {
|
|
59
|
+
/** Stable across renders. Never an array index — see the engine's `DragId`. */
|
|
60
|
+
getId: (item: T) => BoardItemId;
|
|
61
|
+
getColumnId: (item: T) => BoardColumnId;
|
|
62
|
+
}
|
|
63
|
+
export interface BoardColumnDef {
|
|
64
|
+
id: BoardColumnId;
|
|
65
|
+
/**
|
|
66
|
+
* Spoken in every announcement about this column and shown in the move menu.
|
|
67
|
+
* Required, unlike the engine's optional `label`: a column whose accessible
|
|
68
|
+
* name falls back to an id reads as "column c-3-todo" to a screen reader,
|
|
69
|
+
* which is speakable and useless. The board always has a real name available
|
|
70
|
+
* because the consumer had to name the column to render its header anyway.
|
|
71
|
+
*/
|
|
72
|
+
label: string;
|
|
73
|
+
/**
|
|
74
|
+
* Soft cap on item count. Exceeding it is a VISUAL event, not a blocked move
|
|
75
|
+
* — a WIP limit exists to interrupt a human, and a board that refuses the
|
|
76
|
+
* drop instead just teaches people to work around the board. The core reports
|
|
77
|
+
* `overWip`; the view layer is responsible for making it loud.
|
|
78
|
+
*/
|
|
79
|
+
wipLimit?: number;
|
|
80
|
+
}
|
|
81
|
+
/** One item's move. `to.index` counts the destination list without this item. */
|
|
82
|
+
export interface BoardMove {
|
|
83
|
+
itemId: BoardItemId;
|
|
84
|
+
from: {
|
|
85
|
+
columnId: BoardColumnId;
|
|
86
|
+
index: number;
|
|
87
|
+
};
|
|
88
|
+
to: {
|
|
89
|
+
columnId: BoardColumnId;
|
|
90
|
+
index: number;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Which input produced the move. Kept on the request rather than inferred
|
|
95
|
+
* downstream because the three that are not `"pointer"` are the ones with no
|
|
96
|
+
* visible symptom when they break: a consumer's telemetry, and our own tests,
|
|
97
|
+
* need to assert that all four routes actually reach the funnel.
|
|
98
|
+
*/
|
|
99
|
+
export type BoardMoveSource = "pointer" | "keyboard" | "menu" | "bulk";
|
|
100
|
+
/**
|
|
101
|
+
* `moves.length > 1` only for `"bulk"`, and then it is ALL-OR-NOTHING.
|
|
102
|
+
* Partially applying a multi-select move — accepting the first two cards and
|
|
103
|
+
* rejecting the rest — leaves the operator with a board state they did not ask
|
|
104
|
+
* for and cannot infer, which is worse than a clean refusal.
|
|
105
|
+
*/
|
|
106
|
+
export interface BoardMoveRequest {
|
|
107
|
+
moves: readonly BoardMove[];
|
|
108
|
+
source: BoardMoveSource;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A rejection MUST carry a reason, and that is why this is a verdict object
|
|
112
|
+
* rather than a boolean. A bare refusal is the failure mode GitHub Actions is
|
|
113
|
+
* still generating support threads over — a job sits in "queued" and the UI
|
|
114
|
+
* cannot say whether it is a concurrency limit, an approval or capacity. The
|
|
115
|
+
* reason is surfaced to the user and to the live-region announcer, so it is
|
|
116
|
+
* written for a person to read, not logged.
|
|
117
|
+
*/
|
|
118
|
+
export type BoardMoveVerdict = {
|
|
119
|
+
ok: true;
|
|
120
|
+
} | {
|
|
121
|
+
ok: false;
|
|
122
|
+
reason: string;
|
|
123
|
+
};
|
|
124
|
+
/** A column as the core computes it, ready to render. */
|
|
125
|
+
export interface BoardColumnModel<T> {
|
|
126
|
+
id: BoardColumnId;
|
|
127
|
+
label: string;
|
|
128
|
+
items: readonly T[];
|
|
129
|
+
/** Count is on the model because the view needs it before it renders rows. */
|
|
130
|
+
count: number;
|
|
131
|
+
wipLimit: number | undefined;
|
|
132
|
+
/** `true` when `count` exceeds `wipLimit`. Always false with no limit set. */
|
|
133
|
+
overWip: boolean;
|
|
134
|
+
}
|
|
135
|
+
export interface UseBoardOptions<T> {
|
|
136
|
+
items: readonly T[];
|
|
137
|
+
columns: readonly BoardColumnDef[];
|
|
138
|
+
accessors: BoardAccessors<T>;
|
|
139
|
+
/**
|
|
140
|
+
* Consulted before ANY move commits, from any of the four sources. Absent
|
|
141
|
+
* means every move is allowed — the common case, and not something a consumer
|
|
142
|
+
* should have to write boilerplate to say.
|
|
143
|
+
*/
|
|
144
|
+
validateMove?: (request: BoardMoveRequest) => BoardMoveVerdict;
|
|
145
|
+
/**
|
|
146
|
+
* The single commit point. Fires only for a move that is both valid and not a
|
|
147
|
+
* no-op: the engine's `DragEndEvent.moved` exists because the previous board
|
|
148
|
+
* wrote its ordering field on every release that landed where it started, and
|
|
149
|
+
* a write that changes nothing still costs a request, a re-render and an audit
|
|
150
|
+
* row. That filtering happens in the core so no consumer has to remember it.
|
|
151
|
+
*/
|
|
152
|
+
onMove: (request: BoardMoveRequest) => void;
|
|
153
|
+
/** Selection is board state because bulk atomicity is a board concern. */
|
|
154
|
+
selection?: {
|
|
155
|
+
selected: readonly BoardItemId[];
|
|
156
|
+
onChange: (selected: readonly BoardItemId[]) => void;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ==== USE BOARD ====
|
|
3
|
+
*
|
|
4
|
+
* The React binding over the two pure modules. Deliberately thin: every claim
|
|
5
|
+
* this hook makes has already been proven in model.ts and move.ts without a
|
|
6
|
+
* browser, so the only thing left for React-specific code to get wrong is
|
|
7
|
+
* memoisation and the selection wiring — logic that lived INSIDE a hook
|
|
8
|
+
* instead of a pure function would be logic no test in board-core.test.ts
|
|
9
|
+
* could reach without mounting a component. `renderHook` here exists only to
|
|
10
|
+
* prove the composition wires up, not to re-prove validation or bulk
|
|
11
|
+
* atomicity, which are already pinned in the pure-module tests.
|
|
12
|
+
*
|
|
13
|
+
* `requestMove` IS THE SINGLE FUNNEL. Pointer drag's `onDragEnd`, a keyboard
|
|
14
|
+
* handler, the mandatory "Move to…" menu (WCAG 2.5.7 — see `types.ts`), and a
|
|
15
|
+
* bulk-selection action all construct a `BoardMoveRequest` and call this ONE
|
|
16
|
+
* function. It never mutates `items` itself — `UseBoardOptions.onMove` returns
|
|
17
|
+
* `void` by contract, so the actual next `items` array is the CONSUMER's to
|
|
18
|
+
* produce, typically via `applyBoardMoves` from `./move`. `requestMove` only
|
|
19
|
+
* decides whether that consumer callback fires at all, and returns the verdict
|
|
20
|
+
* synchronously so the caller (e.g. the menu) can show a rejection reason
|
|
21
|
+
* immediately rather than guessing from a UI that just didn't change.
|
|
22
|
+
*
|
|
23
|
+
* SELECTION IS FULLY CONTROLLED, NOT A SECOND SOURCE OF TRUTH. `selection` on
|
|
24
|
+
* `UseBoardOptions` supplies BOTH `selected` and `onChange` — this hook never
|
|
25
|
+
* keeps its own copy of which ids are selected, for the same reason `items`
|
|
26
|
+
* is never copied: a second copy is a copy that can drift. What this hook
|
|
27
|
+
* "owns" is the BEHAVIOUR built on top of that controlled pair (toggle one,
|
|
28
|
+
* replace the set, clear) so every input source computes a selection change
|
|
29
|
+
* the same way, mirroring why `requestMove` exists as a single funnel for
|
|
30
|
+
* moves rather than one per source.
|
|
31
|
+
*/
|
|
32
|
+
import type { BoardColumnModel, BoardItemId, BoardMoveRequest, BoardMoveVerdict, UseBoardOptions } from "./types";
|
|
33
|
+
export interface UseBoardResult<T> {
|
|
34
|
+
/** One entry per declared column, in `columns` order, ready to render. */
|
|
35
|
+
columns: readonly BoardColumnModel<T>[];
|
|
36
|
+
/** Items whose `getColumnId` matched no declared column. See model.ts. */
|
|
37
|
+
unassigned: readonly T[];
|
|
38
|
+
/** The current controlled selection, or `[]` when `selection` was not supplied. */
|
|
39
|
+
selected: readonly BoardItemId[];
|
|
40
|
+
/** No-ops when `selection` was not supplied — see the file header. */
|
|
41
|
+
toggleSelected: (id: BoardItemId) => void;
|
|
42
|
+
setSelected: (ids: readonly BoardItemId[]) => void;
|
|
43
|
+
clearSelection: () => void;
|
|
44
|
+
/** The single funnel every input source calls. See the file header. */
|
|
45
|
+
requestMove: (request: BoardMoveRequest) => BoardMoveVerdict;
|
|
46
|
+
}
|
|
47
|
+
export declare function useBoard<T>(options: UseBoardOptions<T>): UseBoardResult<T>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danxbot/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Danxbot — a domain-agnostic React design system with motion as a first-class primitive.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
"build:types": "tsc -p tsconfig.build.json",
|
|
64
64
|
"build:demo": "vite build",
|
|
65
65
|
"build": "npm run build:lib && npm run build:demo",
|
|
66
|
-
"verify": "npm run typecheck && npm run test && npm run build && npm run typecheck:previews && npm run audit:previews",
|
|
66
|
+
"verify": "npm run typecheck && npm run test && npm run build && npm run typecheck:previews && npm run audit:previews && npm run audit:visual-stale",
|
|
67
|
+
"audit:visual-stale": "node scripts/audit-visual-stale.mjs",
|
|
67
68
|
"preview": "vite preview",
|
|
68
69
|
"preview:built": "vite preview --outDir demo-dist --port 5801 --strictPort",
|
|
69
70
|
"gates": "npm run shoot && npm run a11y && node scripts/shoot-overlays.mjs && npm run probe",
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { DragEndEvent } from "../../lib/dnd";
|
|
2
|
-
import type { KanbanLane, KanbanMove, KanbanRefusal } from "./types";
|
|
3
|
-
export type KanbanDrop =
|
|
4
|
-
/** Nothing happened: the card was put back where it came from. */
|
|
5
|
-
{
|
|
6
|
-
kind: "none";
|
|
7
|
-
} | {
|
|
8
|
-
kind: "refused";
|
|
9
|
-
refusal: KanbanRefusal;
|
|
10
|
-
} | {
|
|
11
|
-
kind: "move";
|
|
12
|
-
move: KanbanMove;
|
|
13
|
-
};
|
|
14
|
-
export declare function resolveKanbanDrop<T>(lanes: readonly KanbanLane<T>[], event: DragEndEvent): KanbanDrop;
|
|
15
|
-
export interface KanbanDropHandlers {
|
|
16
|
-
onMove: (move: KanbanMove) => void;
|
|
17
|
-
onRefuse?: (refusal: KanbanRefusal) => void;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Resolve a drop and hand it to exactly one of the two callbacks.
|
|
21
|
-
*
|
|
22
|
-
* The dispatch is here rather than inline in the component because it is the
|
|
23
|
-
* other half of the same decision, and the half a resolver test cannot see: a
|
|
24
|
-
* board that resolved "refused" correctly and then called `onMove` anyway would
|
|
25
|
-
* keep every test in `resolveKanbanDrop` green while writing the move the
|
|
26
|
-
* refusal was supposed to stop. Found by mutation — the inline version was the
|
|
27
|
-
* one thing in this component nothing failed on.
|
|
28
|
-
*
|
|
29
|
-
* EXACTLY ONE, OR NEITHER. Never both.
|
|
30
|
-
*/
|
|
31
|
-
export declare function handleKanbanDrop<T>(lanes: readonly KanbanLane<T>[], event: DragEndEvent, handlers: KanbanDropHandlers): KanbanDrop;
|