wenay-react2 1.0.32 → 1.0.34

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.
@@ -27,6 +27,7 @@ export * from "./src/components/Modal";
27
27
  export * from "./src/components/Menu";
28
28
  export * from "./src/components/Settings";
29
29
  export * from "./src/components/UiSlot";
30
+ export * from "./src/components/Toolbar";
30
31
  export { MenuBase, TimeNum, MenuElement as LegacyMenuElement } from "./src/menu/menu";
31
32
  export type { tMenuReact, tMenuReactStrictly } from "./src/menu/menu";
32
33
  export * from "./src/menu/menuMouse";
package/lib/common/api.js CHANGED
@@ -40,6 +40,9 @@ export * from "./src/components/Menu";
40
40
  // 11b. Settings dialog + section registry; UI slot with configurable placement
41
41
  export * from "./src/components/Settings";
42
42
  export * from "./src/components/UiSlot";
43
+ // 11c. Customizable toolbar (createToolbar): config persisted like createUiSlot,
44
+ // pure Settings editor works both in the bar's gear popover and in a settings section
45
+ export * from "./src/components/Toolbar";
43
46
  // 12. MENU - depends on components
44
47
  export { MenuBase, TimeNum, MenuElement as LegacyMenuElement } from "./src/menu/menu";
45
48
  export * from "./src/menu/menuMouse";
@@ -0,0 +1,105 @@
1
+ import React from 'react';
2
+ /** createToolbar - a customizable, self-describing toolbar primitive.
3
+ * Three decoupled layers: config (plain serializable data, persisted via
4
+ * staticProps -> Cash, same mechanics as createUiSlot), Bar (renders visible
5
+ * items in config order at config density) and Settings (a pure editor that
6
+ * only reads/writes config) - so the SAME Settings element works both in the
7
+ * Bar's own gear popover and in a global settings section.
8
+ * v1 non-goals: no overflow/"more" menu (an overflow hook would live in Bar,
9
+ * right after the visible-items map), no grouping, no cross-bar drag. */
10
+ export type tToolbarItem = {
11
+ /** stable id (persist key) */
12
+ key: string;
13
+ /** full human name - shown in the Settings list */
14
+ title: string;
15
+ /** short caption for 'label' density (falls back to title) */
16
+ short?: string;
17
+ /** compact glyph/icon for 'icon' density */
18
+ icon: React.ReactNode;
19
+ /** full custom render; default = icon [+ short] by the density registry */
20
+ render?: (density: string) => React.ReactNode;
21
+ /** convenience for plain action buttons; interactive items can also handle clicks inside render() */
22
+ onClick?: (e: React.MouseEvent) => void;
23
+ /** default true */
24
+ defaultVisible?: boolean;
25
+ /** cannot be hidden or reordered (pinned to its index in opts.items) */
26
+ fixed?: boolean;
27
+ };
28
+ export type tToolbarConfig = {
29
+ /** item keys, display order */
30
+ order: string[];
31
+ visible: {
32
+ [key: string]: boolean;
33
+ };
34
+ /** a key from the density registry ('icon' | 'label' | registered extras) */
35
+ density: string;
36
+ };
37
+ export type tToolbarDensity = {
38
+ key: string;
39
+ /** human name for the Settings segmented control */
40
+ name: string;
41
+ /** how one item renders at this density; absent = icon + (short ?? title) */
42
+ renderItem?: (item: tToolbarItem) => React.ReactNode;
43
+ };
44
+ /** Register an extra density level. Re-register with the same key replaces the
45
+ * previous one. The returned function removes exactly this registration. */
46
+ export declare function registerToolbarDensity(d: tToolbarDensity): () => void;
47
+ /** Current density registry (built-ins + registered). */
48
+ export declare function getToolbarDensities(): readonly tToolbarDensity[];
49
+ export declare function createToolbar(opts: {
50
+ /** persistence key (staticProps -> Cash), like createUiSlot */
51
+ key: string;
52
+ /** item descriptors; the config only ever references them by key */
53
+ items: tToolbarItem[];
54
+ /** defaults; missing fields are derived from items */
55
+ def?: Partial<tToolbarConfig>;
56
+ /** the gear button's face: icon/title default to a plain gear svg. The gear
57
+ * renders only when Bar mounts with settings, AND visible['__settings'] is
58
+ * not false - the Settings editor shows a separated toggle row for it. */
59
+ settingsItem?: {
60
+ title?: string;
61
+ icon?: React.ReactNode;
62
+ };
63
+ }): {
64
+ Bar: (p?: {
65
+ className?: string;
66
+ settings?: boolean;
67
+ popAlign?: "left" | "right";
68
+ }) => import("react/jsx-runtime").JSX.Element;
69
+ Settings: (p?: {
70
+ className?: string;
71
+ activeClassName?: string;
72
+ }) => import("react/jsx-runtime").JSX.Element;
73
+ api: {
74
+ useConfig: () => tToolbarConfig;
75
+ useItems: () => {
76
+ item: tToolbarItem;
77
+ density: string;
78
+ content: string | number | bigint | boolean | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | import("react/jsx-runtime").JSX.Element | null | undefined;
79
+ }[];
80
+ getConfig: () => tToolbarConfig;
81
+ setConfig: (next: tToolbarConfig) => void;
82
+ reset: () => void;
83
+ onChange: {
84
+ func: import("wenay-common2").Listener<[tToolbarConfig]>;
85
+ isRun: () => boolean;
86
+ run: () => void;
87
+ close: () => void;
88
+ eventClose: (cb: () => void) => () => void;
89
+ onClose: (cb: () => void) => () => void;
90
+ removeEventClose: (cb: () => void) => void;
91
+ on: import("wenay-common2").ListenOn<[tToolbarConfig]>;
92
+ off: (k: import("wenay-common2").Listener<[tToolbarConfig]> | (string | symbol) | null) => void;
93
+ addListen: (cb: import("wenay-common2").Listener<[tToolbarConfig]>, opts?: {
94
+ cbClose?: () => void;
95
+ key?: string | symbol;
96
+ }) => () => void;
97
+ removeListen: (k: import("wenay-common2").Listener<[tToolbarConfig]> | (string | symbol) | null) => void;
98
+ once: (cb: import("wenay-common2").Listener<[tToolbarConfig]>, opts?: {
99
+ key?: string | symbol;
100
+ }) => () => void;
101
+ count: () => number;
102
+ readonly getAllKeys: (string | symbol)[];
103
+ };
104
+ };
105
+ };
@@ -0,0 +1,210 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { UseListen } from 'wenay-common2';
4
+ import { renderBy, updateBy } from '../../../updateBy';
5
+ import { staticGetAdd, staticMarkDirty } from '../../utils/mapMemory';
6
+ import { DivOutsideClick } from '../../hooks/useOutside';
7
+ import { useReorder } from '../../hooks/useReorder';
8
+ // Module singleton (closure + updateBy subscription, no React context) - same
9
+ // pattern as the settings-section registry. Two built-in levels; a third
10
+ // ("full" text etc.) is just one more registration.
11
+ const densities = { list: [
12
+ { key: 'icon', name: 'Icons', renderItem: itemIconOnly },
13
+ { key: 'label', name: 'Icons + labels' },
14
+ ] };
15
+ function itemIconOnly(item) {
16
+ return item.icon;
17
+ }
18
+ /** Register an extra density level. Re-register with the same key replaces the
19
+ * previous one. The returned function removes exactly this registration. */
20
+ export function registerToolbarDensity(d) {
21
+ const i = densities.list.findIndex(e => e.key == d.key);
22
+ if (i == -1)
23
+ densities.list.push(d);
24
+ else
25
+ densities.list.splice(i, 1, d);
26
+ renderBy(densities);
27
+ return function offToolbarDensity() {
28
+ const j = densities.list.indexOf(d);
29
+ if (j != -1) {
30
+ densities.list.splice(j, 1);
31
+ renderBy(densities);
32
+ }
33
+ };
34
+ }
35
+ /** Current density registry (built-ins + registered). */
36
+ export function getToolbarDensities() {
37
+ return densities.list;
38
+ }
39
+ function itemContent(item, densityKey) {
40
+ if (item.render)
41
+ return item.render(densityKey);
42
+ const d = densities.list.find(e => e.key == densityKey);
43
+ if (d?.renderItem)
44
+ return d.renderItem(item);
45
+ return _jsxs(_Fragment, { children: [_jsx("span", { className: 'wenayTbIcon', children: item.icon }), _jsx("span", { className: 'wenayTbLabel', children: item.short ?? item.title })] });
46
+ }
47
+ /** Reserved visible-map key for the bar's settings (gear) button: not part of
48
+ * order (the gear always sits at the bar edge), but toggleable like an item. */
49
+ const SETTINGS_KEY = '__settings';
50
+ export function createToolbar(opts) {
51
+ const defConfig = () => ({
52
+ order: opts.def?.order?.slice() ?? opts.items.map(i => i.key),
53
+ visible: opts.def?.visible ? { ...opts.def.visible } : Object.fromEntries(opts.items.map(i => [i.key, i.defaultVisible != false])),
54
+ density: opts.def?.density ?? densities.list[0].key,
55
+ });
56
+ const st = staticGetAdd(opts.key, defConfig());
57
+ const [emitChange, onChange] = UseListen();
58
+ /** The persisted state may be stale or partial (older app version, removed
59
+ * items, an unregistered density) - never crash, never drop user data that
60
+ * still applies: unknown keys are filtered out, missing items are appended
61
+ * (default-visible), fixed items are pinned back to their descriptor index. */
62
+ function normalize() {
63
+ const known = new Set(opts.items.map(i => i.key));
64
+ const rawOrder = Array.isArray(st.order) ? st.order : [];
65
+ const order = rawOrder.filter(k => known.has(k) && !opts.items.find(i => i.key == k)?.fixed);
66
+ for (const it of opts.items)
67
+ if (!it.fixed && order.indexOf(it.key) == -1)
68
+ order.push(it.key);
69
+ opts.items.forEach(function pinFixed(it, i) {
70
+ if (it.fixed)
71
+ order.splice(Math.min(i, order.length), 0, it.key);
72
+ });
73
+ const rawVisible = st.visible && typeof st.visible == 'object' ? st.visible : {};
74
+ const visible = {};
75
+ for (const it of opts.items)
76
+ visible[it.key] = it.fixed ? true : (rawVisible[it.key] ?? it.defaultVisible != false);
77
+ visible[SETTINGS_KEY] = typeof rawVisible[SETTINGS_KEY] == 'boolean' ? rawVisible[SETTINGS_KEY] : true;
78
+ const density = typeof st.density == 'string' && densities.list.some(d => d.key == st.density)
79
+ ? st.density : (opts.def?.density ?? densities.list[0].key);
80
+ return { order, visible, density };
81
+ }
82
+ /** Every edit funnels through here: mutate the persisted object in place
83
+ * (identity is the updateBy/renderBy subscription key), announce, mark the
84
+ * cache dirty, emit outward. */
85
+ function setConfig(next) {
86
+ st.order = next.order.slice();
87
+ st.visible = { ...next.visible };
88
+ st.density = next.density;
89
+ renderBy(st);
90
+ staticMarkDirty(opts.key);
91
+ emitChange(normalize());
92
+ }
93
+ const getConfig = () => normalize();
94
+ function useConfig() {
95
+ updateBy(st);
96
+ return normalize();
97
+ }
98
+ /** Headless bar: the ordered, visibility-filtered items plus their rendered
99
+ * content at the current density - build fully custom bar markup on top,
100
+ * while the same Settings editor still drives order/visibility/density.
101
+ * (Refs are the wrong contract here: the ORDER lives in the config, so the
102
+ * consumer re-renders from this list rather than the library re-parenting
103
+ * someone else's nodes.) */
104
+ function useItems() {
105
+ updateBy(st);
106
+ updateBy(densities);
107
+ const cfg = normalize();
108
+ const byKey = new Map(opts.items.map(i => [i.key, i]));
109
+ return cfg.order
110
+ .map(k => byKey.get(k))
111
+ .filter((it) => !!it && cfg.visible[it.key] != false)
112
+ .map(it => ({ item: it, density: cfg.density, content: itemContent(it, cfg.density) }));
113
+ }
114
+ const reset = () => setConfig(defConfig());
115
+ const settingsTitle = () => opts.settingsItem?.title ?? 'Toolbar settings';
116
+ const settingsIcon = () => opts.settingsItem?.icon ??
117
+ _jsx("svg", { width: '14', height: '14', viewBox: '0 0 16 16', "aria-hidden": 'true', children: _jsx("path", { d: 'M8 5.2 A2.8 2.8 0 1 0 8 10.8 A2.8 2.8 0 1 0 8 5.2 M8 1.2 L8.6 3.4 A4.8 4.8 0 0 1 10.6 4.2 L12.8 3.2 L14 5.6 L12.2 7 A4.8 4.8 0 0 1 12.2 9 L14 10.4 L12.8 12.8 L10.6 11.8 A4.8 4.8 0 0 1 8.6 12.6 L8 14.8 L7.4 12.6 A4.8 4.8 0 0 1 5.4 11.8 L3.2 12.8 L2 10.4 L3.8 9 A4.8 4.8 0 0 1 3.8 7 L2 5.6 L3.2 3.2 L5.4 4.2 A4.8 4.8 0 0 1 7.4 3.4 Z', fill: 'none', stroke: 'currentColor', strokeWidth: '1.2', strokeLinejoin: 'round' }) });
118
+ function moveKey(cfg, key, to) {
119
+ const order = cfg.order.slice();
120
+ const from = order.indexOf(key);
121
+ if (from == -1)
122
+ return;
123
+ to = Math.max(0, Math.min(order.length - 1, to));
124
+ if (to == from)
125
+ return;
126
+ order.splice(from, 1);
127
+ order.splice(to, 0, key);
128
+ setConfig({ ...cfg, order });
129
+ }
130
+ /** The live bar. settings=true adds a built-in gear opening Settings in a
131
+ * local popover - optional, some consumers mount Settings only in the
132
+ * global settings menu. popAlign: which bar edge the popover sticks to -
133
+ * 'right' (default, for bars in a top-right corner) or 'left'. */
134
+ function Bar(p = {}) {
135
+ updateBy(st);
136
+ updateBy(densities);
137
+ const cfg = normalize();
138
+ const [open, setOpen] = useState(false);
139
+ const byKey = new Map(opts.items.map(i => [i.key, i]));
140
+ return _jsxs("div", { className: p.className ?? 'wenayTb', children: [cfg.order.map(k => {
141
+ const it = byKey.get(k);
142
+ if (!it || cfg.visible[k] == false)
143
+ return null;
144
+ return _jsx("div", { className: 'wenayTbItem', title: cfg.density == 'icon' ? it.title : undefined, onClick: it.onClick, children: itemContent(it, cfg.density) }, k);
145
+ }), p.settings && cfg.visible[SETTINGS_KEY] != false && _jsxs(DivOutsideClick, { className: 'wenayTbGear', status: open, outsideClick: () => setOpen(false), children: [_jsx("div", { className: 'wenayTbItem', title: settingsTitle(), onClick: () => setOpen(v => !v), children: settingsIcon() }), open && _jsx("div", { className: p.popAlign == 'left' ? 'wenayTbPop wenayTbPopLeft' : 'wenayTbPop', children: _jsx(Settings, {}) })] })] });
146
+ }
147
+ /** The pure editor over config: density segments + one row per item
148
+ * (visibility checkbox, icon preview, title, drag handle). Reorder rides the
149
+ * library's useReorder (this editor is its first consumer): the whole row
150
+ * drags (mouse and touch; the checkbox is excluded), and `move` is the SAME
151
+ * simulated commit as normalize() (splice + fixed pinning), so fixed rows
152
+ * never move in the preview and a drop never lands elsewhere than shown.
153
+ * Plus arrow keys on the focused handle; fixed rows have neither. */
154
+ function Settings(p = {}) {
155
+ updateBy(st);
156
+ updateBy(densities);
157
+ const cfg = normalize();
158
+ const base = p.className ?? 'wenaySegBtn';
159
+ const activeCls = p.activeClassName ?? 'wenaySegBtnActive';
160
+ const byKey = new Map(opts.items.map(i => [i.key, i]));
161
+ /** Simulated commit: splice + the same fixed pinning as normalize(). */
162
+ function movedOrder(order, key, to) {
163
+ const next = order.slice();
164
+ const from = next.indexOf(key);
165
+ if (from == -1)
166
+ return next;
167
+ next.splice(from, 1);
168
+ next.splice(Math.max(0, Math.min(next.length, to)), 0, key);
169
+ const res = next.filter(k => !byKey.get(k)?.fixed);
170
+ opts.items.forEach(function pinFixed(it, i) {
171
+ if (it.fixed)
172
+ res.splice(Math.min(i, res.length), 0, it.key);
173
+ });
174
+ return res;
175
+ }
176
+ const reorder = useReorder({
177
+ order: cfg.order,
178
+ commit: order => setConfig({ ...cfg, order }),
179
+ move: movedOrder,
180
+ canDrag: k => !byKey.get(k)?.fixed,
181
+ });
182
+ function onHandleKey(key, e) {
183
+ const step = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];
184
+ if (!step)
185
+ return;
186
+ e.preventDefault();
187
+ moveKey(cfg, key, cfg.order.indexOf(key) + step);
188
+ }
189
+ return _jsxs("div", { className: 'wenayTbSettings', children: [_jsx("div", { className: 'wenayTbDensity', children: densities.list.map(d => (_jsx("div", { className: d.key == cfg.density ? `${base} ${activeCls}` : base, onClick: () => setConfig({ ...cfg, density: d.key }), children: d.name }, d.key))) }), _jsx("div", { className: 'wenayTbRows', ref: reorder.listRef, children: cfg.order.map(k => {
190
+ const it = byKey.get(k);
191
+ if (!it)
192
+ return null;
193
+ const r = reorder.item(k);
194
+ let cls = it.fixed ? 'wenayTbRow' : 'wenayTbRow wenayTbRowGrab';
195
+ if (r.active)
196
+ cls += r.dragging ? ' wenayTbRowDrag' : ' wenayTbRowShift'; // Shift is transitioned, so displaced rows glide
197
+ return _jsxs("div", { className: cls, style: r.style, onMouseDown: it.fixed ? undefined : e => {
198
+ // the drag hook preventDefaults mousedown, which suppresses native focus
199
+ if (!e.target.closest('input'))
200
+ e.currentTarget.querySelector('.wenayTbHandle')?.focus();
201
+ r.props.onMouseDown(e);
202
+ }, onTouchStart: it.fixed ? undefined : r.props.onTouchStart, children: [_jsx("input", { type: 'checkbox', disabled: it.fixed, checked: cfg.visible[k] != false, onChange: () => setConfig({ ...cfg, visible: { ...cfg.visible, [k]: !(cfg.visible[k] != false) } }) }), _jsx("span", { className: 'wenayTbIcon', children: it.icon }), _jsx("span", { className: 'wenayTbRowTitle', children: it.title }), !it.fixed && _jsx("div", { className: 'wenayTbHandle', tabIndex: 0, title: 'Drag or arrow keys to reorder', onKeyDown: e => onHandleKey(k, e), children: "\u283F" })] }, k);
203
+ }) }), _jsxs("div", { className: 'wenayTbRow wenayTbRowMeta', children: [_jsx("input", { type: 'checkbox', checked: cfg.visible[SETTINGS_KEY] != false, onChange: () => setConfig({ ...cfg, visible: { ...cfg.visible, [SETTINGS_KEY]: !(cfg.visible[SETTINGS_KEY] != false) } }) }), _jsx("span", { className: 'wenayTbIcon', children: settingsIcon() }), _jsx("span", { className: 'wenayTbRowTitle', children: settingsTitle() })] })] });
204
+ }
205
+ return {
206
+ Bar,
207
+ Settings,
208
+ api: { useConfig, useItems, getConfig, setConfig, reset, onChange },
209
+ };
210
+ }
@@ -0,0 +1 @@
1
+ export * from './Toolbar';
@@ -0,0 +1 @@
1
+ export * from './Toolbar';
@@ -1,5 +1,7 @@
1
1
  export * from './useAddDownAnyKey';
2
2
  export * from './useDraggable';
3
+ export * from './useReorder';
4
+ export * from './useReorderBoard';
3
5
  export * from './useObserveStore';
4
6
  export * from './useReplay';
5
7
  export * from './useOutside';
@@ -1,5 +1,7 @@
1
1
  export * from './useAddDownAnyKey';
2
2
  export * from './useDraggable';
3
+ export * from './useReorder';
4
+ export * from './useReorderBoard';
3
5
  export * from './useObserveStore';
4
6
  export * from './useReplay';
5
7
  export * from './useOutside';
@@ -0,0 +1,49 @@
1
+ import React from 'react';
2
+ /** useReorder - a deliberately small reorder-by-drag for keyed blocks laid out
3
+ * by CSS (vertical list, horizontal bar, wrapped grid - the hook never knows
4
+ * which). The DOM order does NOT change mid-drag: the dragged block follows
5
+ * the pointer via transform, the rest glide to their preview position, ONE
6
+ * commit fires on drop. The consumer renders children in `order`, 1:1 with
7
+ * the container's children.
8
+ * NOT a dnd framework: no nesting, no cross-container moves, no spans or
9
+ * collision packing - when that day comes, take a ready-made library. */
10
+ export type tReorderOptions = {
11
+ /** keys in render order; the container's children must correspond 1:1 */
12
+ order: string[];
13
+ /** ONE commit on drop; skipped when nothing moved */
14
+ commit: (next: string[]) => void;
15
+ /** simulated commit - the preview shows EXACTLY what commit will produce.
16
+ * Default: plain splice. Consumers with pinning rules (fixed items etc.)
17
+ * pass their own so preview == drop by construction. */
18
+ move?: (order: string[], key: string, to: number) => string[];
19
+ /** false = this key cannot start a drag (default: all can) */
20
+ canDrag?: (key: string) => boolean;
21
+ /** 'slots' (default): blocks glide between the slot centers measured at
22
+ * drag start - exact when all blocks are equal-sized.
23
+ * 'measure': FLIP - the preview order is applied via CSS `order`, the real
24
+ * layout is read and reverted in one synchronous pass (the intermediate
25
+ * state never paints) - exact for ANY block sizes and wrapping, requires
26
+ * a flex/grid container. */
27
+ preview?: 'slots' | 'measure';
28
+ /** hold before the drag starts (touch-friendly fields); default 0 */
29
+ holdMs?: number;
30
+ };
31
+ export type tReorderItem = {
32
+ /** spread on the block element */
33
+ props: {
34
+ onMouseDown: React.MouseEventHandler<HTMLElement>;
35
+ onTouchStart: React.TouchEventHandler<HTMLElement>;
36
+ };
37
+ /** transform while a drag is active (undefined otherwise) */
38
+ style?: React.CSSProperties;
39
+ /** this block is the one under the pointer */
40
+ dragging: boolean;
41
+ /** some drag is active - the consumer adds its transition class/style on non-dragged blocks */
42
+ active: boolean;
43
+ };
44
+ export declare function useReorder<E extends HTMLElement = HTMLDivElement>(o: tReorderOptions): {
45
+ listRef: React.RefObject<E | null>;
46
+ item: (key: string) => tReorderItem;
47
+ dragKey: string | null;
48
+ preview: string[] | null;
49
+ };
@@ -0,0 +1,134 @@
1
+ import { useRef, useState } from 'react';
2
+ import { useDraggableApi } from './useDraggable';
3
+ export function useReorder(o) {
4
+ const listRef = useRef(null);
5
+ const [dragKey, setDragKey] = useState(null);
6
+ const slotsRef = useRef([]); // child centers at drag start (local px)
7
+ const startRef = useRef([]); // child top-lefts at drag start (local px)
8
+ // Pointer deltas are viewport px, layout is local px: under a scaled ancestor
9
+ // (client styling, zoomed containers) they diverge - normalize by the ratio.
10
+ const scaleRef = useRef(1);
11
+ const measureRef = useRef(null);
12
+ const move = o.move ?? function plainSplice(order, key, to) {
13
+ const next = order.slice();
14
+ const from = next.indexOf(key);
15
+ if (from == -1)
16
+ return next;
17
+ next.splice(from, 1);
18
+ next.splice(Math.max(0, Math.min(next.length, to)), 0, key);
19
+ return next;
20
+ };
21
+ const kids = () => Array.from(listRef.current?.children ?? []);
22
+ /** viewport px -> local px */
23
+ const local = (v) => v / scaleRef.current;
24
+ /** Nearest START-slot center to the dragged block's center. Targeting always
25
+ * runs against the slots measured at drag start - computing it against a
26
+ * live-reflowing layout oscillates at boundaries; that failure mode is
27
+ * designed out. */
28
+ function dragTarget(from, dx, dy) {
29
+ const slots = slotsRef.current;
30
+ const start = slots[from];
31
+ if (!start)
32
+ return from;
33
+ const x = start.x + dx, y = start.y + dy;
34
+ let best = from, bestD = Infinity;
35
+ slots.forEach((c, i) => {
36
+ const d = (c.x - x) ** 2 + (c.y - y) ** 2;
37
+ if (d < bestD) {
38
+ bestD = d;
39
+ best = i;
40
+ }
41
+ });
42
+ return best;
43
+ }
44
+ const drag = useDraggableApi({ holdMs: o.holdMs ?? 0, onDragEnd: function commitOrder(final) {
45
+ setDragKey(null);
46
+ measureRef.current = null;
47
+ if (dragKey == null)
48
+ return;
49
+ const from = o.order.indexOf(dragKey);
50
+ if (from == -1)
51
+ return;
52
+ const next = move(o.order, dragKey, dragTarget(from, local(final.x), local(final.y)));
53
+ if (next.some((k, i) => k != o.order[i]))
54
+ o.commit(next);
55
+ } });
56
+ function beginDrag(key, e) {
57
+ if (o.canDrag && !o.canDrag(key))
58
+ return false;
59
+ // interactive children stay clickable (checkboxes in rows etc.)
60
+ if (e.target.closest('input, button, select, textarea, a'))
61
+ return false;
62
+ const list = listRef.current;
63
+ if (!list)
64
+ return false;
65
+ scaleRef.current = list.offsetWidth ? list.getBoundingClientRect().width / list.offsetWidth : 1;
66
+ // offsetLeft/Top, NOT getBoundingClientRect: offsets are pure layout-box
67
+ // positions - transforms (incl. mid-flight transitions) never leak in
68
+ const els = kids();
69
+ slotsRef.current = els.map(el => ({ x: el.offsetLeft + el.offsetWidth / 2, y: el.offsetTop + el.offsetHeight / 2 }));
70
+ startRef.current = els.map(el => ({ x: el.offsetLeft, y: el.offsetTop }));
71
+ measureRef.current = null;
72
+ setDragKey(key);
73
+ return true;
74
+ }
75
+ /** FLIP: apply the preview order via CSS `order`, read the real layout,
76
+ * revert - one synchronous pass between frames, so the intermediate state
77
+ * never paints and CSS does the wrapping math for us. Cached per target
78
+ * (targets change rarely, not per mousemove). Positions are read via
79
+ * offsetLeft/Top, NEVER getBoundingClientRect: rects include transforms,
80
+ * and mid-drag the blocks carry mid-flight transition values (setting
81
+ * style.transform='none' does not stop a running transition within the
82
+ * same synchronous pass) - measuring through them accumulates the previous
83
+ * preview's offsets on every re-measure and the blocks fly apart. Offsets
84
+ * are pure layout-box positions, immune to all of that. */
85
+ function measuredPositions(preview, target) {
86
+ if (measureRef.current?.target == target)
87
+ return measureRef.current.pos;
88
+ const els = kids();
89
+ const saved = els.map(el => el.style.order);
90
+ els.forEach((el, i) => el.style.order = String(preview.indexOf(o.order[i])));
91
+ const pos = els.map(el => ({ x: el.offsetLeft, y: el.offsetTop }));
92
+ els.forEach((el, i) => el.style.order = saved[i]);
93
+ measureRef.current = { target, pos };
94
+ return pos;
95
+ }
96
+ const from = dragKey != null ? o.order.indexOf(dragKey) : -1;
97
+ const target = from != -1 ? dragTarget(from, local(drag.position.x), local(drag.position.y)) : -1;
98
+ const preview = from != -1 && dragKey != null ? move(o.order, dragKey, target) : null;
99
+ function item(key) {
100
+ const active = preview != null;
101
+ const dragging = key == dragKey;
102
+ let style;
103
+ if (active) {
104
+ const i = o.order.indexOf(key);
105
+ if (dragging) {
106
+ style = { transform: `translate(${local(drag.position.x)}px, ${local(drag.position.y)}px)` };
107
+ }
108
+ else if (o.preview == 'measure') {
109
+ const pos = measuredPositions(preview, target);
110
+ const a = startRef.current[i], b = pos[i];
111
+ if (a && b && (a.x != b.x || a.y != b.y))
112
+ style = { transform: `translate(${b.x - a.x}px, ${b.y - a.y}px)` };
113
+ }
114
+ else {
115
+ const pi = preview.indexOf(key);
116
+ const a = slotsRef.current[i], b = slotsRef.current[pi];
117
+ if (pi != i && a && b)
118
+ style = { transform: `translate(${b.x - a.x}px, ${b.y - a.y}px)` };
119
+ }
120
+ }
121
+ return {
122
+ props: {
123
+ onMouseDown(e) { if (e.button == 0 && beginDrag(key, e))
124
+ drag.props.onMouseDown(e); },
125
+ onTouchStart(e) { if (beginDrag(key, e))
126
+ drag.props.onTouchStart(e); },
127
+ },
128
+ style,
129
+ dragging,
130
+ active,
131
+ };
132
+ }
133
+ return { listRef, item, dragKey, preview };
134
+ }
@@ -0,0 +1,61 @@
1
+ import { tReorderItem } from './useReorder';
2
+ /** useReorderBoard - the columns extension of useReorder: keyed blocks live in
3
+ * VERTICAL columns (plain consumer divs), one block drags between/within them,
4
+ * ONE commit on drop. Column "gravity" is pure consumer CSS (justify-content:
5
+ * flex-start packs up, flex-end packs down) - the hook never knows it, it
6
+ * measures the real layout. Columns can appear/disappear between drags (the
7
+ * ref callback registry is live); the set is frozen for the duration of one
8
+ * drag. Children of a column div must be exactly its items, 1:1, in order.
9
+ * Same non-goals as useReorder: no nesting, no spans/collision packing, no
10
+ * autoscroll - that day is a ready-made dnd library. */
11
+ export type tBoardPos = {
12
+ col: string;
13
+ index: number;
14
+ };
15
+ export type tBoardColumn = {
16
+ key: string;
17
+ items: string[];
18
+ };
19
+ export type tReorderBoardOptions = {
20
+ /** columns in board order; each column's div children must render items 1:1 */
21
+ columns: tBoardColumn[];
22
+ /** ONE commit on drop; skipped when nothing moved */
23
+ commit: (next: tBoardColumn[]) => void;
24
+ /** false = this key cannot start a drag (default: all can) */
25
+ canDrag?: (key: string) => boolean;
26
+ /** hold before the drag starts; default 0 */
27
+ holdMs?: number;
28
+ /** the block was grabbed */
29
+ onDragStart?: (e: {
30
+ key: string;
31
+ from: tBoardPos;
32
+ }) => void;
33
+ /** every pointer move while dragging; position = pointer delta in local px */
34
+ onDragMove?: (e: {
35
+ key: string;
36
+ over: tBoardPos;
37
+ position: {
38
+ x: number;
39
+ y: number;
40
+ };
41
+ }) => void;
42
+ /** the target slot changed (compare prev.col != over.col for column crossings) */
43
+ onOverChange?: (e: {
44
+ key: string;
45
+ over: tBoardPos;
46
+ prev: tBoardPos | null;
47
+ }) => void;
48
+ /** drop; committed=false when nothing moved (plain click) */
49
+ onDragEnd?: (e: {
50
+ key: string;
51
+ from: tBoardPos;
52
+ over: tBoardPos;
53
+ committed: boolean;
54
+ }) => void;
55
+ };
56
+ export declare function useReorderBoard(o: tReorderBoardOptions): {
57
+ columnRef: (col: string) => (el: HTMLElement | null) => void;
58
+ item: (key: string) => tReorderItem;
59
+ dragKey: string | null;
60
+ over: tBoardPos | null;
61
+ };