@signal9/era-ui 4.16.6 → 4.16.8

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.
@@ -2,14 +2,11 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import {
5
- draggable,
6
- disabled as disabledPlugin,
7
- events,
8
- position as positionPlugin,
9
- stateMarker,
10
- Compartment,
11
- type Plugin,
12
- type DragEventData
5
+ Draggable,
6
+ type Axis,
7
+ type BoundsInput,
8
+ type DragEventData,
9
+ type DragPlugin
13
10
  } from '@neodrag/svelte';
14
11
  import { cn } from '../../utils/index.js';
15
12
 
@@ -21,6 +18,10 @@
21
18
  minWidth = 160,
22
19
  minHeight = 96,
23
20
  disabled = false,
21
+ axis,
22
+ bounds,
23
+ grid,
24
+ threshold = 3,
24
25
  plugins = [],
25
26
  onDragStart,
26
27
  onDrag,
@@ -43,8 +44,25 @@
43
44
  minHeight?: number;
44
45
  /** Disables dragging (reactive). */
45
46
  disabled?: boolean;
46
- /** Extra neodrag plugins appended after the pane's own. */
47
- plugins?: (Plugin | Compartment)[] | (() => (Plugin | Compartment)[]);
47
+ /** Constrain the drag to one axis. */
48
+ axis?: Axis;
49
+ /** Pen the pane inside a target ('parent', 'viewport', an element, a rect). */
50
+ bounds?: BoundsInput;
51
+ /** Snap the drag to a [x, y] pixel grid. */
52
+ grid?: readonly [number, number];
53
+ /**
54
+ * Movement in px before a drag engages. The 3px default keeps clean
55
+ * clicks as clicks — and unlike the old default-plugin distance gate it
56
+ * has no containment bail, so a fast flick that out-runs the bar before
57
+ * the threshold clears still engages (next.10's gate is pure distance).
58
+ */
59
+ threshold?: number;
60
+ /**
61
+ * Tier-2 neodrag plugins (`use`) appended to the drag — custom
62
+ * constraints via `onMove` (return an adjusted offset), scrollLock,
63
+ * analytics. Tier-1 concerns (axis/bounds/grid) are the props above.
64
+ */
65
+ plugins?: DragPlugin[] | (() => DragPlugin[]);
48
66
  onDragStart?: (data: DragEventData) => void;
49
67
  onDrag?: (data: DragEventData) => void;
50
68
  onDragEnd?: (data: DragEventData) => void;
@@ -80,14 +98,6 @@
80
98
  let dragging = false;
81
99
  const live = { x: position.x, y: position.y };
82
100
 
83
- // Compartments are v3's reactive-update mechanism: the wrapped closure
84
- // re-resolves when its reactive inputs change, without re-initializing
85
- // the draggable.
86
- const positionComp = Compartment.of(() =>
87
- positionPlugin({ current: dragging ? live : position })
88
- );
89
- const disabledComp = Compartment.of(() => (disabled ? disabledPlugin() : null));
90
-
91
101
  const track = (data: DragEventData) => {
92
102
  live.x = data.offset.x;
93
103
  live.y = data.offset.y;
@@ -96,16 +106,146 @@
96
106
  position = { x: data.offset.x, y: data.offset.y };
97
107
  };
98
108
 
109
+ // next.10's reactive seam: option GETTERS on one Draggable, tracked by the
110
+ // wrapper's own $effect, which pushes a fine-grained update() when a getter's
111
+ // dependency changes — the successor to the Compartment mechanism this file
112
+ // used to wire by hand.
113
+ //
114
+ // The position getter is the same trick the position Compartment played:
115
+ // while a gesture is live it returns the non-reactive `live` (so a mid-drag
116
+ // re-resolve can never hand neodrag a stale $state and snap the pane back),
117
+ // and the `dragging ? …` short-circuit also means the getter doesn't READ
118
+ // `position` during a gesture — no tracking, no invalidation, the hot path
119
+ // stays exactly as non-reactive as before. `position` has deliberately no
120
+ // SETTER here: the wrapper only writes `options.position` per-move when the
121
+ // option is two-way (setter present), and per-move $state writes are the
122
+ // thing this whole design exists to avoid — `position` is written once, by
123
+ // `sync`, on drag end.
124
+ //
125
+ // NEVER read `drag.isDragging` or `drag.offset` in this file: they are
126
+ // per-move $state on the wrapper, and subscribing to them re-creates the
127
+ // per-move reactive round trip the `live` object removes.
128
+ const drag = new Draggable({
129
+ get position() {
130
+ return dragging ? live : position;
131
+ },
132
+ get disabled() {
133
+ return disabled;
134
+ },
135
+ get axis() {
136
+ return axis;
137
+ },
138
+ get bounds() {
139
+ return bounds;
140
+ },
141
+ get grid() {
142
+ return grid;
143
+ },
144
+ get threshold() {
145
+ return threshold;
146
+ },
147
+ get use() {
148
+ return typeof plugins === 'function' ? plugins() : plugins;
149
+ },
150
+ onDragStart: (data) => {
151
+ dragging = true;
152
+ track(data);
153
+ onDragStart?.(data);
154
+ },
155
+ onDrag: (data) => {
156
+ track(data);
157
+ onDrag?.(data);
158
+ },
159
+ onDragEnd: (data) => {
160
+ dragging = false;
161
+ track(data);
162
+ sync(data);
163
+ onDragEnd?.(data);
164
+ }
165
+ });
166
+
167
+ // The zone bridge: era's public drag contract is DATA ATTRIBUTES
168
+ // (data-pane-handle marks the grab bar, data-pane-control marks anything
169
+ // that must never start a drag), because markers are written by arbitrary
170
+ // consumers who never see this component's internals. next.10 replaced the
171
+ // per-event shouldStart hook with a registered handle/cancel zone walk, so
172
+ // the attributes are bridged into that registry: the wrapper's handle()/
173
+ // cancel() return spreadable attachments, and an attachment IS a plain
174
+ // `(node) => cleanup` under a symbol key — extracted once and applied to
175
+ // whichever nodes carry the attributes. Cancel beats handle from inside
176
+ // (the engine's path walk is innermost-first at equal priority), which is
177
+ // exactly the old gate's `control` precedence.
178
+ const attachmentOf = (props: Record<symbol, unknown>) =>
179
+ props[Object.getOwnPropertySymbols(props)[0] as unknown as keyof typeof props] as (
180
+ node: Element
181
+ ) => () => void;
182
+ const markHandle = attachmentOf(drag.handle());
183
+ const markCancel = attachmentOf(drag.cancel());
184
+
185
+ // Attribute nodes come and go (a pane bar's centre content swaps, tabs
186
+ // mount) — a MutationObserver keeps the registry synced to the DOM, so the
187
+ // contract stays "carry the attribute", never "also register somewhere".
188
+ $effect(() => {
189
+ const node = ref;
190
+ if (!node) return;
191
+ // Plain Map/Set on purpose: registry bookkeeping, never rendered — a
192
+ // reactive wrapper would only add proxy cost to a structure that the
193
+ // MutationObserver alone touches.
194
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
195
+ const marks = new Map<Element, { kind: 'handle' | 'cancel'; off: () => void }>();
196
+ const syncZones = () => {
197
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
198
+ const seen = new Set<Element>();
199
+ for (const el of node.querySelectorAll('[data-pane-handle], [data-pane-control]')) {
200
+ const kind = el.hasAttribute('data-pane-control') ? 'cancel' : 'handle';
201
+ seen.add(el);
202
+ const prev = marks.get(el);
203
+ if (prev && prev.kind === kind) continue;
204
+ prev?.off();
205
+ marks.set(el, { kind, off: kind === 'handle' ? markHandle(el) : markCancel(el) });
206
+ }
207
+ for (const [el, mark] of marks) {
208
+ if (seen.has(el)) continue;
209
+ mark.off();
210
+ marks.delete(el);
211
+ }
212
+ };
213
+ syncZones();
214
+ const observer = new MutationObserver(syncZones);
215
+ observer.observe(node, {
216
+ subtree: true,
217
+ childList: true,
218
+ attributeFilter: ['data-pane-handle', 'data-pane-control']
219
+ });
220
+ return () => {
221
+ observer.disconnect();
222
+ for (const mark of marks.values()) mark.off();
223
+ };
224
+ });
225
+
226
+ // Ctrl's whole-pane grab, zone edition: while Ctrl is held the ROOT is a
227
+ // registered handle, and the [&_*]:pointer-events-none class below makes
228
+ // every descendant hit-transparent — so the pointerdown's path is just the
229
+ // root, no cancel marker can intervene, and the pane is one solid slab.
230
+ // Rides the reactive ctrlHeld (the old gate read the event directly; zones
231
+ // are registrations, so a keydown-to-effect microtask is the cost — the
232
+ // visual affordances already ran on the same signal).
233
+ $effect(() => {
234
+ if (!ctrlHeld || resizing) return;
235
+ const node = ref;
236
+ if (!node) return;
237
+ return markHandle(node);
238
+ });
239
+
99
240
  let resizing = $state(false);
100
241
 
101
- // What may start a drag, decided PER POINTERDOWN from the real event — no
102
- // cached zones, no reactive state, nothing to fall out of sync. neodrag's
103
- // `shouldStart` hook runs on the initiating pointer event; returning false
104
- // vetoes the drag. Because this is a pure function of that event, it's correct
105
- // after resizes, re-renders, or anything else the ctrl-drag can't break.
106
- // Ctrl held → the WHOLE pane is a handle (read live off the event).
107
- // otherwise → only the handle bar, and never a resize handle / control
108
- // (those carry data-pane-control).
242
+ // The layer-promotion predicate: the same decision the zone registry makes
243
+ // (Ctrl whole pane; else the handle bar and never a control), as a pure
244
+ // function of the pointer event, because armLayer runs on the raw
245
+ // pointerdown before the engine's own claim and must not wait on any
246
+ // registration. If the two ever disagree the cost is one speculative
247
+ // will-change frame, not a wrong drag: the engine's zones are the authority
248
+ // on whether the gesture actually starts.
109
249
  function canStartDrag(e: PointerEvent): boolean {
110
250
  const target = e.target as Element | null;
111
251
  if (!target) return false;
@@ -114,18 +254,11 @@
114
254
  return target.closest('[data-pane-handle]') != null;
115
255
  }
116
256
 
117
- const dragGate: Plugin = {
118
- name: 'era:pane-drag-gate',
119
- shouldStart(_ctx, _state, event) {
120
- return canStartDrag(event as PointerEvent);
121
- }
122
- };
123
-
124
257
  /*
125
258
  * Promote to a compositor layer on POINTERDOWN, not on drag start.
126
259
  *
127
- * neodrag flips data-neodrag-state="dragging" from inside a rAF callback, so a
128
- * class keyed on that state arms `will-change` a frame AFTER the pane has
260
+ * neodrag marks the node (data-neodrag-dragging) only once the drag engages,
261
+ * so a class keyed on that state arms `will-change` a frame AFTER the pane has
129
262
  * already started moving — layer creation then lands in the first frames of the
130
263
  * gesture, which are the frames a drag is judged by. The pointerdown frame is
131
264
  * free: the pointer is down and nothing has moved yet, so the layer is ready
@@ -149,33 +282,6 @@
149
282
  window.addEventListener('pointercancel', disarm, { capture: true, once: true });
150
283
  }
151
284
 
152
- // Replaces neodrag's DEFAULT threshold plugin (same name — the engine keys
153
- // plugins by name, last one wins, and user plugins come after defaults). The
154
- // default keeps its 3px click-vs-drag distance gate but ALSO cancels the drag
155
- // when the first pointermove's target has escaped the dragged node — which is
156
- // exactly what a fast flick does (the cursor out-runs the bar before the drag
157
- // starts and captures the pointer), so quick grabs randomly never engaged.
158
- // Same distance gate here, no containment bail: the pointerdown already
159
- // decided eligibility (dragGate), where the cursor is one move later must not.
160
- const flickSafeThreshold: Plugin = {
161
- name: 'neodrag:threshold',
162
- setup() {
163
- return { x: 0, y: 0 };
164
- },
165
- shouldStart(_ctx, state: { x: number; y: number }, event) {
166
- const e = event as PointerEvent;
167
- state.x = e.clientX;
168
- state.y = e.clientY;
169
- return true;
170
- },
171
- drag(ctx, state: { x: number; y: number }, event) {
172
- if (ctx.isDragging) return;
173
- const e = event as PointerEvent;
174
- // Clean clicks stay clicks: no drag until the pointer has moved > 3px.
175
- if ((e.clientX - state.x) ** 2 + (e.clientY - state.y) ** 2 <= 9) ctx.preventStart();
176
- }
177
- };
178
-
179
285
  // Ctrl held is ALSO tracked reactively — but only for the visual affordances
180
286
  // (grab cursor, no text selection, inert tabs), never for the drag itself. If
181
287
  // this lags the keypress, the drag still works (the gate reads the event); the
@@ -411,36 +517,7 @@
411
517
  {...restProps}
412
518
  style:width={size ? `${size.width}px` : undefined}
413
519
  style:height={size ? `${size.height}px` : undefined}
414
- {@attach draggable(() => [
415
- // Gates which pointerdown starts a drag: handle bar only, or the whole pane
416
- // while Ctrl is held. Stateless — decided live off the event. See above.
417
- dragGate,
418
- // Overrides the default threshold by name — fast flicks must engage. See above.
419
- flickSafeThreshold,
420
- // Exposes data-neodrag-state="idle | dragging" on the root — the drag-only
421
- // layer-promotion hook hangs off it.
422
- stateMarker(),
423
- events({
424
- onDragStart: (data) => {
425
- dragging = true;
426
- track(data);
427
- onDragStart?.(data);
428
- },
429
- onDrag: (data) => {
430
- track(data);
431
- onDrag?.(data);
432
- },
433
- onDragEnd: (data) => {
434
- dragging = false;
435
- track(data);
436
- sync(data);
437
- onDragEnd?.(data);
438
- }
439
- }),
440
- positionComp,
441
- disabledComp,
442
- ...(typeof plugins === 'function' ? plugins() : plugins)
443
- ])}
520
+ {...drag.attach}
444
521
  >
445
522
  {@render children?.()}
446
523
  {#if resizable}
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- import { Compartment, type Plugin, type DragEventData } from '@neodrag/svelte';
3
+ import { type Axis, type BoundsInput, type DragEventData, type DragPlugin } from '@neodrag/svelte';
4
4
  type $$ComponentProps = HTMLAttributes<HTMLDivElement> & {
5
5
  ref?: HTMLDivElement | null;
6
6
  /** Offset from the pane's layout position. Bindable and writable —
@@ -22,8 +22,25 @@ type $$ComponentProps = HTMLAttributes<HTMLDivElement> & {
22
22
  minHeight?: number;
23
23
  /** Disables dragging (reactive). */
24
24
  disabled?: boolean;
25
- /** Extra neodrag plugins appended after the pane's own. */
26
- plugins?: (Plugin | Compartment)[] | (() => (Plugin | Compartment)[]);
25
+ /** Constrain the drag to one axis. */
26
+ axis?: Axis;
27
+ /** Pen the pane inside a target ('parent', 'viewport', an element, a rect). */
28
+ bounds?: BoundsInput;
29
+ /** Snap the drag to a [x, y] pixel grid. */
30
+ grid?: readonly [number, number];
31
+ /**
32
+ * Movement in px before a drag engages. The 3px default keeps clean
33
+ * clicks as clicks — and unlike the old default-plugin distance gate it
34
+ * has no containment bail, so a fast flick that out-runs the bar before
35
+ * the threshold clears still engages (next.10's gate is pure distance).
36
+ */
37
+ threshold?: number;
38
+ /**
39
+ * Tier-2 neodrag plugins (`use`) appended to the drag — custom
40
+ * constraints via `onMove` (return an adjusted offset), scrollLock,
41
+ * analytics. Tier-1 concerns (axis/bounds/grid) are the props above.
42
+ */
43
+ plugins?: DragPlugin[] | (() => DragPlugin[]);
27
44
  onDragStart?: (data: DragEventData) => void;
28
45
  onDrag?: (data: DragEventData) => void;
29
46
  onDragEnd?: (data: DragEventData) => void;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet, Component } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
- import type { Plugin, Compartment, DragEventData } from '@neodrag/svelte';
4
+ import type { DragEventData, DragPlugin } from '@neodrag/svelte';
5
5
  import type { IconProps } from '@lucide/svelte';
6
6
  import Root from './pane-root.svelte';
7
7
  import Handle from './pane-handle.svelte';
@@ -42,9 +42,10 @@
42
42
  icon?: Component<IconProps> | null;
43
43
  /** Disables dragging (reactive). */
44
44
  disabled?: boolean;
45
- /** Extra neodrag plugins (axis, bounds, grid, threshold, …) appended
46
- * after the pane's own. Pass Compartments for reactive plugin sets. */
47
- plugins?: (Plugin | Compartment)[] | (() => (Plugin | Compartment)[]);
45
+ /** Tier-2 neodrag plugins (`use`) appended to the drag. Tier-1 concerns
46
+ * (axis/bounds/grid/threshold) are Pane.Root props spread through
47
+ * restProps or use Root directly for those. */
48
+ plugins?: DragPlugin[] | (() => DragPlugin[]);
48
49
  onDragStart?: (data: DragEventData) => void;
49
50
  onDrag?: (data: DragEventData) => void;
50
51
  onDragEnd?: (data: DragEventData) => void;
@@ -1,6 +1,6 @@
1
1
  import type { Snippet, Component } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- import type { Plugin, Compartment, DragEventData } from '@neodrag/svelte';
3
+ import type { DragEventData, DragPlugin } from '@neodrag/svelte';
4
4
  import type { IconProps } from '@lucide/svelte';
5
5
  type $$ComponentProps = HTMLAttributes<HTMLDivElement> & {
6
6
  ref?: HTMLDivElement | null;
@@ -23,9 +23,10 @@ type $$ComponentProps = HTMLAttributes<HTMLDivElement> & {
23
23
  icon?: Component<IconProps> | null;
24
24
  /** Disables dragging (reactive). */
25
25
  disabled?: boolean;
26
- /** Extra neodrag plugins (axis, bounds, grid, threshold, …) appended
27
- * after the pane's own. Pass Compartments for reactive plugin sets. */
28
- plugins?: (Plugin | Compartment)[] | (() => (Plugin | Compartment)[]);
26
+ /** Tier-2 neodrag plugins (`use`) appended to the drag. Tier-1 concerns
27
+ * (axis/bounds/grid/threshold) are Pane.Root props spread through
28
+ * restProps or use Root directly for those. */
29
+ plugins?: DragPlugin[] | (() => DragPlugin[]);
29
30
  onDragStart?: (data: DragEventData) => void;
30
31
  onDrag?: (data: DragEventData) => void;
31
32
  onDragEnd?: (data: DragEventData) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "4.16.6",
3
+ "version": "4.16.8",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",
@@ -155,7 +155,7 @@
155
155
  "dependencies": {
156
156
  "@internationalized/date": "^3.12.3",
157
157
  "@lucide/svelte": "^0.577.0",
158
- "@neodrag/svelte": "^3.0.0-next.8",
158
+ "@neodrag/svelte": "3.0.0-next.10",
159
159
  "bits-ui": "^2.18.1",
160
160
  "clsx": "^2.1.1",
161
161
  "dompurify": "^3.4.12",