@rozie-ui/data-table-solid 0.1.1 → 0.2.1

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/src/Popover.tsx DELETED
@@ -1,416 +0,0 @@
1
- import type { JSX } from 'solid-js';
2
- import { Show, children, createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
3
- import { __rozieInjectStyle, createControllableSignal, createOutsideClick, rozieAttr } from '@rozie/runtime-solid';
4
- // The `offset` AND `arrow` middleware factories are ALIASED on import: both are
5
- // ALSO author PROP names (`offset`, `arrow`). A bare `offset`/`arrow` shorthand in
6
- // the buildMiddleware factories object resolves to the PROP — on Vue/Svelte the
7
- // destructured prop local shadows the import, and on Angular the emitter rewrites
8
- // the bare shorthand to the prop signal (`offset: this.offset()`, a number) instead
9
- // of the middleware function (TS2322). Aliasing both severs the import↔prop clash.
10
- // (The Cropper import-name==component-name class, applied to imports vs PROP names —
11
- // two collisions, not one.) computePosition/autoUpdate/flip/shift carry no clash.
12
- import { computePosition, autoUpdate, offset as offsetMiddleware, flip, shift, arrow as arrowMiddleware } from '@floating-ui/dom';
13
- import { buildMiddleware } from './internal/middleware';
14
-
15
- // null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
16
- // anchorNode/floatingNode/arrowNode hold the resolved ref ELEMENTS (read ONLY in
17
- // $onMount/handlers, ROZ123). They are deliberately named DIFFERENTLY from the
18
- // `ref="anchorEl"` / `ref="floatingEl"` / `ref="arrowEl"` template ref names: the
19
- // React/Svelte emitters declare a `const anchorEl = useRef(...)` for the ref, and a
20
- // top-level `let anchorEl` hoisted to its own `useRef` would REDECLARE it (TS2451 —
21
- // the local-name==ref-name self-shadow class, here in its `let X = null; X = $refs.X`
22
- // variant, which deconflictRefShadows does NOT auto-rewrite since it only fires on the
23
- // `const X = $refs.X` init shape).
24
- // stopAutoUpdate is the autoUpdate teardown handle — a TOP-LEVEL `let` so the Solid
25
- // onMount→onCleanup split (teardown is a separate closure) can still see it.
26
- // lastFocusedEl (phase 72-06b) holds whatever had DOM focus at the moment a
27
- // `trigger="click"` popover opened (natively the clicked trigger element itself,
28
- // since a mousedown focuses a native `<button>` before its `click` fires) —
29
- // restored on dismissal so Escape/click-outside don't drop focus to `<body>`.
30
- // Same null-let convention as the others: read/written only in handlers, `any`
31
- // via typeNeutralize.
32
-
33
- __rozieInjectStyle('Popover-c6cf02ea', `.rozie-popover[data-rozie-s-c6cf02ea] {
34
- display: contents;
35
- }
36
- .rozie-popover-anchor[data-rozie-s-c6cf02ea] {
37
- display: inline-block;
38
- }
39
- .rozie-popover-floating[data-rozie-s-c6cf02ea] {
40
- position: absolute;
41
- left: 0;
42
- top: 0;
43
- z-index: var(--rozie-popover-z, 1000);
44
- width: max-content;
45
- max-width: var(--rozie-popover-max-width, calc(100vw - 16px));
46
- background: var(--rozie-popover-bg, #fff);
47
- color: var(--rozie-popover-color, inherit);
48
- border: var(--rozie-popover-border, 1px solid rgba(0, 0, 0, 0.12));
49
- border-radius: var(--rozie-popover-radius, 8px);
50
- box-shadow: var(--rozie-popover-shadow, 0 8px 24px rgba(0, 0, 0, 0.12));
51
- padding: var(--rozie-popover-padding, 8px 12px);
52
- }
53
- .rozie-popover-arrow[data-rozie-s-c6cf02ea] {
54
- position: absolute;
55
- width: var(--rozie-popover-arrow-size, 8px);
56
- height: var(--rozie-popover-arrow-size, 8px);
57
- background: var(--rozie-popover-bg, #fff);
58
- border: var(--rozie-popover-border, 1px solid rgba(0, 0, 0, 0.12));
59
- transform: rotate(45deg);
60
- }`);
61
-
62
- interface AnchorSlotCtx { open: any; toggle: any; show: any; hide: any; }
63
-
64
- interface PopoverProps {
65
- /**
66
- * Whether the floating content is open. The sole `model: true` prop — two-way bind it (`r-model:open` / `v-model:open` / `bind:open` / `[(open)]`) and Popover writes the new state back whenever the trigger or a dismissal toggles it. Left unbound it falls back to an uncontrolled default.
67
- */
68
- open?: boolean;
69
- defaultOpen?: boolean;
70
- onOpenChange?: (open: boolean) => void;
71
- /**
72
- * Floating UI placement of the content relative to the anchor — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end` (e.g. `bottom-start`). With `disableFlip` off, the content may flip to the opposite side when it would overflow the viewport. Reconciled at runtime.
73
- */
74
- placement?: string;
75
- /**
76
- * How the anchor opens the content: `'click'` toggles on click, `'hover'` opens on pointer-enter and closes on pointer-leave (tooltip-style), `'focus'` opens on focus and closes on blur. Drives both the gesture handlers and the ARIA role (`'hover'`/`'focus'` → tooltip, `'click'` → popover dialog).
77
- */
78
- trigger?: string;
79
- /**
80
- * Distance in pixels between the anchor and the floating content (the Floating UI `offset` middleware). Reconciled at runtime.
81
- */
82
- offset?: number;
83
- /**
84
- * Disable the Floating UI `flip` middleware. By default the content flips to the opposite side of the anchor when it would overflow the viewport; set this to keep it pinned to `placement` regardless.
85
- */
86
- disableFlip?: boolean;
87
- /**
88
- * Disable the Floating UI `shift` middleware. By default the content shifts along its axis to stay within the viewport; set this to keep it strictly aligned to the anchor.
89
- */
90
- disableShift?: boolean;
91
- /**
92
- * Opt in to a positioned arrow element. When set, Popover renders an arrow `<div>` and runs the Floating UI `arrow` middleware against it so it points at the anchor. Style it via the `--rozie-popover-*` arrow CSS custom properties.
93
- */
94
- arrow?: boolean;
95
- /**
96
- * Disable the control entirely: the trigger no longer opens the content and any open content is suppressed.
97
- */
98
- disabled?: boolean;
99
- /**
100
- * Opt in to modal dialog semantics for a `click` popover. **Off by default:** a click popover is a non-modal, click-outside-dismissable layer, so its panel is rendered role-neutral (the slot content owns its own ARIA role — e.g. a `role="menu"`) and carries NO `aria-modal`. Set `modal` for a genuinely modal dialog popover: the panel then gets `role="dialog"` + `aria-modal="true"`. **Note:** Popover ships no focus trap (it stays a minimal headless primitive); if you set `modal`, provide your own focus containment so the `aria-modal` claim holds. Ignored for `hover`/`focus` triggers (always tooltip-flavored).
101
- */
102
- modal?: boolean;
103
- /**
104
- * Floating UI positioning strategy — 'absolute' (default) or 'fixed'. Use 'fixed' to escape a scrollable/overflow-clipping ancestor (e.g. a sticky table header). Reconciled at runtime.
105
- */
106
- strategy?: string;
107
- onChange?: (...args: unknown[]) => void;
108
- anchorSlot?: (ctx: AnchorSlotCtx) => JSX.Element;
109
- // D-131: default slot resolved via children() at body top
110
- children?: JSX.Element;
111
- slots?: Record<string, (ctx: any) => JSX.Element>;
112
- ref?: (h: PopoverHandle) => void;
113
- }
114
-
115
- export interface PopoverHandle {
116
- show: (...args: any[]) => any;
117
- hide: (...args: any[]) => any;
118
- toggle: (...args: any[]) => any;
119
- reposition: (...args: any[]) => any;
120
- }
121
-
122
- export default function Popover(_props: PopoverProps): JSX.Element {
123
- const _merged = mergeProps({ placement: 'bottom', trigger: 'click', offset: 8, disableFlip: false, disableShift: false, arrow: false, disabled: false, modal: false, strategy: 'absolute' }, _props);
124
- const [local, attrs] = splitProps(_merged, ['open', 'placement', 'trigger', 'offset', 'disableFlip', 'disableShift', 'arrow', 'disabled', 'modal', 'strategy', 'children', 'ref']);
125
- const resolved = children(() => local.children);
126
- onMount(() => { local.ref?.({ show, hide, toggle, reposition }); });
127
-
128
- const [open, setOpen] = createControllableSignal<boolean>(_props as unknown as Record<string, unknown>, 'open', false);
129
- onMount(() => {
130
- const _cleanup = (() => {
131
- // $refs read ONLY here (ROZ123). The floating + arrow elements live behind r-if
132
- // and may be null until open; startTracking re-reads via the watch path.
133
- anchorNode = anchorElRef;
134
- if (open() && !local.disabled) {
135
- // floatingNode is populated by its r-if having rendered; read it lazily inside
136
- // the watch/handlers too. Position on next tick when it exists.
137
- floatingNode = floatingElRef;
138
- arrowNode = arrowElRef;
139
- startTracking();
140
- }
141
- })() as unknown;
142
- if (_cleanup) onCleanup(_cleanup as () => void);
143
- onCleanup(() => {
144
- stopTracking();
145
- });
146
- });
147
- createEffect(on(() => (() => open())(), (v) => untrack(() => ((isOpen: any) => {
148
- if (isOpen && !local.disabled) {
149
- queueMicrotask(() => {
150
- if (!open() || local.disabled) return;
151
- floatingNode = floatingElRef;
152
- arrowNode = arrowElRef;
153
- startTracking();
154
- });
155
- } else {
156
- stopTracking();
157
- }
158
- })(v)), { defer: true }));
159
- createEffect(on(() => (() => local.placement)(), (v) => untrack(() => (() => {
160
- if (open()) position();
161
- })()), { defer: true }));
162
- createEffect(on(() => (() => local.offset)(), (v) => untrack(() => (() => {
163
- if (open()) position();
164
- })()), { defer: true }));
165
- createEffect(on(() => (() => local.disableFlip)(), (v) => untrack(() => (() => {
166
- if (open()) position();
167
- })()), { defer: true }));
168
- createEffect(on(() => (() => local.disableShift)(), (v) => untrack(() => (() => {
169
- if (open()) position();
170
- })()), { defer: true }));
171
- createEffect(on(() => (() => local.strategy)(), (v) => untrack(() => (() => {
172
- if (open()) position();
173
- })()), { defer: true }));
174
- let anchorElRef: HTMLElement | null = null;
175
- let floatingElRef: HTMLElement | null = null;
176
- let arrowElRef: HTMLElement | null = null;
177
-
178
- // null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
179
- // anchorNode/floatingNode/arrowNode hold the resolved ref ELEMENTS (read ONLY in
180
- // $onMount/handlers, ROZ123). They are deliberately named DIFFERENTLY from the
181
- // `ref="anchorEl"` / `ref="floatingEl"` / `ref="arrowEl"` template ref names: the
182
- // React/Svelte emitters declare a `const anchorEl = useRef(...)` for the ref, and a
183
- // top-level `let anchorEl` hoisted to its own `useRef` would REDECLARE it (TS2451 —
184
- // the local-name==ref-name self-shadow class, here in its `let X = null; X = $refs.X`
185
- // variant, which deconflictRefShadows does NOT auto-rewrite since it only fires on the
186
- // `const X = $refs.X` init shape).
187
- // stopAutoUpdate is the autoUpdate teardown handle — a TOP-LEVEL `let` so the Solid
188
- // onMount→onCleanup split (teardown is a separate closure) can still see it.
189
- // lastFocusedEl (phase 72-06b) holds whatever had DOM focus at the moment a
190
- // `trigger="click"` popover opened (natively the clicked trigger element itself,
191
- // since a mousedown focuses a native `<button>` before its `click` fires) —
192
- // restored on dismissal so Escape/click-outside don't drop focus to `<body>`.
193
- // Same null-let convention as the others: read/written only in handlers, `any`
194
- // via typeNeutralize.
195
- let anchorNode: any = null;
196
- let floatingNode: any = null;
197
- let arrowNode: any = null;
198
- let stopAutoUpdate: any = null;
199
- let lastFocusedEl: any = null;
200
-
201
- // `document.activeElement` stops at the OUTERMOST shadow-DOM host when focus
202
- // lives inside a NESTED shadow tree — e.g. a Lit consumer that composes
203
- // `<rozie-popover>` inside its own shadow root (data-table's vendored copy):
204
- // clicking the trigger focuses a real element several shadow boundaries deep,
205
- // but `document.activeElement` only resolves as far as the outermost custom
206
- // element (`<rozie-data-table>`), not the actual focused node. Walking
207
- // `.shadowRoot.activeElement` recursively drills to the true focused element.
208
- // On the other 5 targets (no shadow DOM) `el.shadowRoot` is always
209
- // null/undefined, so the loop is a no-op and this degrades to a plain
210
- // `document.activeElement` read — one implementation, safe on every target.
211
- function deepActiveElement() {
212
- let el = document.activeElement;
213
- while (el && el.shadowRoot && el.shadowRoot.activeElement) {
214
- el = el.shadowRoot.activeElement;
215
- }
216
- return el;
217
- }
218
-
219
- // Drive the two-way model + emit in one place. Named `requestOpen` (NOT `setOpen`)
220
- // to dodge the React generated `setOpen` setter for the `open` model (ROZ524).
221
- //
222
- // Focus-return (phase 72-06b, D-08 a11y finding): scoped to `trigger === 'click'`
223
- // only — click-triggered popovers are genuinely interactive (a real dialog the
224
- // user tabs/clicks into), so restoring focus to the trigger on dismissal matches
225
- // standard disclosure-widget a11y practice. Deliberately NOT applied to
226
- // `hover`/`focus` triggers (tooltip-flavored, see `isTooltip()`): those close on
227
- // pointerleave/blur constantly during normal mouse/keyboard traversal, and
228
- // forcing a focus() call on every such close would fight the user's own focus
229
- // movement rather than restore anything lost.
230
- function requestOpen(next: any) {
231
- if (open() === next) return;
232
- if (next && local.trigger === 'click') {
233
- lastFocusedEl = deepActiveElement();
234
- }
235
- setOpen(next);
236
- _props.onChange?.(next);
237
- if (!next && local.trigger === 'click' && lastFocusedEl && lastFocusedEl.isConnected && typeof lastFocusedEl.focus === 'function') {
238
- lastFocusedEl.focus();
239
- }
240
- if (!next) {
241
- lastFocusedEl = null;
242
- }
243
- }
244
-
245
- // Apply the resolved x/y (and arrow offset, when present) onto the floating element.
246
- function applyPosition(x: any, y: any, middlewareData: any) {
247
- if (!floatingNode) return;
248
- floatingNode.style.left = x + 'px';
249
- floatingNode.style.top = y + 'px';
250
- if (arrowNode && middlewareData && middlewareData.arrow) {
251
- const ax = middlewareData.arrow.x;
252
- const ay = middlewareData.arrow.y;
253
- arrowNode.style.left = ax == null ? '' : ax + 'px';
254
- arrowNode.style.top = ay == null ? '' : ay + 'px';
255
- }
256
- }
257
-
258
- // Recompute the position once. Pure engine call; safe to invoke whenever both
259
- // elements exist and the content is open. `opts` is a null-let (→ `any`) so the
260
- // loosely-typed `<props>` placement (string) + the `unknown[]` middleware array don't
261
- // fail the strict leaf tsc against Floating UI's `Placement` / `Middleware[]` types
262
- // (the cropper `let cfg = null` constructor-args idiom).
263
- function position() {
264
- if (!anchorNode || !floatingNode) return;
265
- const middleware = buildMiddleware({
266
- offset: offsetMiddleware,
267
- flip,
268
- shift,
269
- arrow: arrowMiddleware
270
- }, {
271
- offset: local.offset,
272
- disableFlip: local.disableFlip,
273
- disableShift: local.disableShift,
274
- arrow: local.arrow,
275
- arrowEl: arrowNode
276
- });
277
- // 'fixed' inline position MUST be written before computePosition measures the
278
- // floating element's offset parent (fixed vs absolute changes the containing
279
- // block). Default 'absolute' explicitly CLEARS any inline position instead of
280
- // writing `position: absolute` — so a never-fixed popover still writes no
281
- // visible inline position (byte-identical-off preserved: `style.position = ''`
282
- // is a no-op when the property was never set), while a live `strategy`
283
- // reconcile (fixed → absolute, see the $watch below) correctly resets the
284
- // stale inline `fixed` so the stylesheet's `position: absolute` rule re-takes
285
- // over instead of positioning `fixed` with absolute-computed coordinates
286
- // (72-REVIEW.md WR-01).
287
- if (local.strategy === 'fixed') {
288
- floatingNode.style.position = 'fixed';
289
- } else {
290
- floatingNode.style.position = '';
291
- }
292
- let opts: any = null;
293
- opts = {
294
- placement: local.placement,
295
- strategy: local.strategy,
296
- middleware
297
- };
298
- computePosition(anchorNode, floatingNode, opts).then((result: any) => {
299
- applyPosition(result.x, result.y, result.middlewareData);
300
- });
301
- }
302
-
303
- // Start autoUpdate (idempotent — stop any prior subscription first) and do an
304
- // initial position. Floating UI's autoUpdate keeps the position fresh on scroll/
305
- // resize/ancestor-layout changes and returns its own teardown.
306
- function startTracking() {
307
- if (!anchorNode || !floatingNode) return;
308
- if (stopAutoUpdate) {
309
- stopAutoUpdate();
310
- stopAutoUpdate = null;
311
- }
312
- stopAutoUpdate = autoUpdate(anchorNode, floatingNode, position);
313
- }
314
- function stopTracking() {
315
- if (stopAutoUpdate) {
316
- stopAutoUpdate();
317
- stopAutoUpdate = null;
318
- }
319
- }
320
- // ─── trigger gesture handlers (wired conditionally on the anchor by `trigger`) ──
321
- function onAnchorClick() {
322
- if (local.disabled) return;
323
- requestOpen(!open());
324
- }
325
- function onAnchorPointerEnter() {
326
- if (local.disabled) return;
327
- requestOpen(true);
328
- }
329
- function onAnchorPointerLeave() {
330
- if (local.disabled) return;
331
- requestOpen(false);
332
- }
333
- function onAnchorFocus() {
334
- if (local.disabled) return;
335
- requestOpen(true);
336
- }
337
- function onAnchorBlur() {
338
- if (local.disabled) return;
339
- requestOpen(false);
340
- }
341
-
342
- // Dismissal handler — method reference for the <listeners> block (an inline
343
- // handler referencing $event leaks into React's useEffect deps → TS2552; every
344
- // corpus <listener> uses a method-ref + modifiers).
345
- function dismiss() {
346
- requestOpen(false);
347
- }
348
-
349
- // ─── role helpers (plain functions; tooltip vs popover-dialog by trigger) ───────
350
- // hover/focus triggers are tooltip-flavored; click is an interactive popover.
351
- function isTooltip() {
352
- return local.trigger === 'hover' || local.trigger === 'focus';
353
- }
354
- // Role: hover/focus → 'tooltip'; a click popover is 'dialog' ONLY when the consumer
355
- // opts into `modal` (which is what also emits aria-modal). A default (non-modal)
356
- // click popover returns `undefined` — a role-NEUTRAL positioned container, so the slot
357
- // content owns its own semantics (e.g. the data-table ⋯ menu declares role="menu").
358
- // Emitting role="dialog" + aria-modal="true" on a click-outside-dismissable panel
359
- // with no focus trap wrongly tells assistive tech that sibling content is inert (IN-03).
360
- // `undefined` (not `null`) for the neutral case: the Vue `:role` binding target is
361
- // `string | undefined`, and under strict vue-tsc `null` is not assignable to it —
362
- // `undefined` drops the attribute identically (Vue/Solid nullish-attr drop treats both
363
- // alike) while keeping the emitted leaf's inferred type a clean `'tooltip' | 'dialog' | undefined`.
364
- function floatingRole() {
365
- return isTooltip() ? 'tooltip' : local.modal ? 'dialog' : undefined;
366
- }
367
-
368
- // ─── imperative handle ($expose) ────────────────────────────────────────────────
369
- // Verbs: show/hide/toggle/reposition. NOT `update` (reserved Lit lifecycle) → the
370
- // reposition verb is `reposition`. None collide with the `change` emit, the `open`
371
- // model, or its React `setOpen` setter, nor with inherited HTMLElement members.
372
- function show() {
373
- if (!local.disabled) requestOpen(true);
374
- }
375
- function hide() {
376
- requestOpen(false);
377
- }
378
- function toggle() {
379
- if (!local.disabled) requestOpen(!open());
380
- }
381
- function reposition() {
382
- position();
383
- }
384
-
385
- createEffect(() => {
386
- if (!(open())) return;
387
- const _rozieHandler = ($event: KeyboardEvent) => {
388
- if ($event.key !== 'Escape') return;
389
- dismiss();
390
- };
391
- document.addEventListener('keydown', _rozieHandler);
392
- onCleanup(() => document.removeEventListener('keydown', _rozieHandler));
393
- });
394
-
395
- createOutsideClick(
396
- [() => anchorElRef, () => floatingElRef],
397
- dismiss,
398
- () => open(),
399
- );
400
-
401
- return (
402
- <>
403
- <div {...attrs} class={"rozie-popover" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-c6cf02ea="">
404
-
405
-
406
- <div aria-haspopup="dialog" aria-expanded={!!open()} aria-describedby={rozieAttr(isTooltip() && open() ? 'rozie-popover-floating' : null)} class={"rozie-popover-anchor"} ref={(el) => { anchorElRef = el as HTMLElement; }} onClick={($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { local.trigger === 'click' && onAnchorClick(); }} onPointerEnter={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { local.trigger === 'hover' && onAnchorPointerEnter(); }} onPointerLeave={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { local.trigger === 'hover' && onAnchorPointerLeave(); }} onFocusIn={($event: FocusEvent & { currentTarget: HTMLDivElement; target: Element }) => { local.trigger === 'focus' && onAnchorFocus(); }} onFocusOut={($event: FocusEvent & { currentTarget: HTMLDivElement; target: Element }) => { local.trigger === 'focus' && onAnchorBlur(); }} data-rozie-s-c6cf02ea="">
407
- {(_props.anchorSlot ?? _props.slots?.['anchor'])?.({ open: open(), toggle, show, hide })}
408
- </div>
409
-
410
-
411
- {<Show when={open() && !local.disabled}><div class={"rozie-popover-floating"} ref={(el) => { floatingElRef = el as HTMLElement; }} id="rozie-popover-floating" role={rozieAttr(floatingRole())} aria-modal={!!(floatingRole() === 'dialog')} data-rozie-s-c6cf02ea="">
412
- {<Show when={local.arrow}><div class={"rozie-popover-arrow"} ref={(el) => { arrowElRef = el as HTMLElement; }} data-rozie-s-c6cf02ea="" /></Show>}{resolved()}
413
- </div></Show>}</div>
414
- </>
415
- );
416
- }
@@ -1,54 +0,0 @@
1
- /**
2
- * middleware.ts — pure construction of the Floating UI middleware stack from the
3
- * Popover's first-class props.
4
- *
5
- * This is the one piece of BRANCHY engine glue in Popover.rozie (offset always on,
6
- * flip/shift opt-out via `disableFlip`/`disableShift`, arrow opt-in via `arrow` +
7
- * an element). Extracted to `src/internal/` so it can be unit-tested in isolation
8
- * (codegen vendors `src/internal/` into every leaf via copyInternal, excluding
9
- * `*.test.ts`).
10
- *
11
- * It takes the Floating UI middleware FACTORIES as arguments (rather than importing
12
- * `@floating-ui/dom` itself) so the unit test can pass lightweight stand-ins and
13
- * assert ORDER + presence without pulling the real engine — and so the vendored
14
- * copy in each leaf has zero engine import of its own (the leaf's Popover.* owns
15
- * the single engine import).
16
- *
17
- * Floating UI ordering contract: offset → flip → shift → arrow. `offset` first so
18
- * the gap is measured before collision detection; `arrow` last so it reads the
19
- * final resolved placement.
20
- */
21
-
22
- export interface MiddlewareFactories {
23
- offset: (value: number) => unknown;
24
- flip: () => unknown;
25
- shift: () => unknown;
26
- arrow: (opts: { element: Element }) => unknown;
27
- }
28
-
29
- export interface MiddlewareConfig {
30
- offset: number;
31
- disableFlip: boolean;
32
- disableShift: boolean;
33
- arrow: boolean;
34
- arrowEl: Element | null;
35
- }
36
-
37
- /**
38
- * Build the ordered middleware array for `computePosition`. Pure — no engine
39
- * import, no DOM read beyond the passed-in arrow element.
40
- */
41
- export function buildMiddleware(
42
- factories: MiddlewareFactories,
43
- config: MiddlewareConfig,
44
- ): unknown[] {
45
- const mw: unknown[] = [factories.offset(config.offset)];
46
- if (!config.disableFlip) mw.push(factories.flip());
47
- if (!config.disableShift) mw.push(factories.shift());
48
- // The arrow middleware needs a real element to position; opt-in only when both
49
- // the `arrow` prop is set AND the arrow element has mounted.
50
- if (config.arrow && config.arrowEl) {
51
- mw.push(factories.arrow({ element: config.arrowEl }));
52
- }
53
- return mw;
54
- }