@kywi-software/core 0.9.0 → 0.11.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.
Files changed (87) hide show
  1. package/AGENT-PATTERNS.md +15 -13
  2. package/README.md +67 -0
  3. package/dist/admin/layout-editor/column-overlay.d.ts.map +1 -1
  4. package/dist/admin/layout-editor/column-overlay.js +7 -1
  5. package/dist/admin/layout-editor/column-overlay.js.map +1 -1
  6. package/dist/admin/layout-editor/drop-indicator.d.ts +18 -1
  7. package/dist/admin/layout-editor/drop-indicator.d.ts.map +1 -1
  8. package/dist/admin/layout-editor/drop-indicator.js +23 -3
  9. package/dist/admin/layout-editor/drop-indicator.js.map +1 -1
  10. package/dist/admin/layout-editor/editor-canvas.d.ts +31 -8
  11. package/dist/admin/layout-editor/editor-canvas.d.ts.map +1 -1
  12. package/dist/admin/layout-editor/editor-canvas.js +131 -48
  13. package/dist/admin/layout-editor/editor-canvas.js.map +1 -1
  14. package/dist/admin/layout-editor/editor-state.d.ts.map +1 -1
  15. package/dist/admin/layout-editor/editor-state.js +19 -3
  16. package/dist/admin/layout-editor/editor-state.js.map +1 -1
  17. package/dist/admin/layout-editor/editor.css +106 -8
  18. package/dist/admin/layout-editor/inline-text.d.ts +56 -4
  19. package/dist/admin/layout-editor/inline-text.d.ts.map +1 -1
  20. package/dist/admin/layout-editor/inline-text.js +134 -11
  21. package/dist/admin/layout-editor/inline-text.js.map +1 -1
  22. package/dist/admin/layout-editor/layout-editor.d.ts.map +1 -1
  23. package/dist/admin/layout-editor/layout-editor.js +1 -1
  24. package/dist/admin/layout-editor/layout-editor.js.map +1 -1
  25. package/dist/admin/layout-editor/module-overlay.d.ts +26 -13
  26. package/dist/admin/layout-editor/module-overlay.d.ts.map +1 -1
  27. package/dist/admin/layout-editor/module-overlay.js +206 -22
  28. package/dist/admin/layout-editor/module-overlay.js.map +1 -1
  29. package/dist/admin/layout-editor/overlay-shell.d.ts.map +1 -1
  30. package/dist/admin/layout-editor/overlay-shell.js +39 -12
  31. package/dist/admin/layout-editor/overlay-shell.js.map +1 -1
  32. package/dist/admin/layout-editor/props-panel.js +1 -1
  33. package/dist/admin/layout-editor/props-panel.js.map +1 -1
  34. package/dist/admin/layout-editor/section-overlay.d.ts +8 -1
  35. package/dist/admin/layout-editor/section-overlay.d.ts.map +1 -1
  36. package/dist/admin/layout-editor/section-overlay.js +8 -8
  37. package/dist/admin/layout-editor/section-overlay.js.map +1 -1
  38. package/dist/admin/layout-editor/section-variant-container-config.js +5 -5
  39. package/dist/admin/layout-editor/section-variant-container-config.js.map +1 -1
  40. package/dist/admin/layout-editor/use-chrome-flip.d.ts +111 -11
  41. package/dist/admin/layout-editor/use-chrome-flip.d.ts.map +1 -1
  42. package/dist/admin/layout-editor/use-chrome-flip.js +233 -33
  43. package/dist/admin/layout-editor/use-chrome-flip.js.map +1 -1
  44. package/dist/admin/layout-editor/use-editor-dnd.d.ts +44 -1
  45. package/dist/admin/layout-editor/use-editor-dnd.d.ts.map +1 -1
  46. package/dist/admin/layout-editor/use-editor-dnd.js +191 -3
  47. package/dist/admin/layout-editor/use-editor-dnd.js.map +1 -1
  48. package/dist/admin/styles/admin.css +22 -1
  49. package/dist/admin/surfaces/layouts/layout-editor-client.d.ts.map +1 -1
  50. package/dist/admin/surfaces/layouts/layout-editor-client.js +12 -1
  51. package/dist/admin/surfaces/layouts/layout-editor-client.js.map +1 -1
  52. package/dist/api/content-routes.d.ts.map +1 -1
  53. package/dist/api/content-routes.js +34 -7
  54. package/dist/api/content-routes.js.map +1 -1
  55. package/dist/api/index.d.ts.map +1 -1
  56. package/dist/api/index.js +7 -73
  57. package/dist/api/index.js.map +1 -1
  58. package/dist/cache/content-invalidation.d.ts +28 -0
  59. package/dist/cache/content-invalidation.d.ts.map +1 -0
  60. package/dist/cache/content-invalidation.js +72 -0
  61. package/dist/cache/content-invalidation.js.map +1 -0
  62. package/dist/db/content-restore.d.ts +5 -1
  63. package/dist/db/content-restore.d.ts.map +1 -1
  64. package/dist/db/content-restore.js +9 -2
  65. package/dist/db/content-restore.js.map +1 -1
  66. package/dist/layout/comments-module.d.ts.map +1 -1
  67. package/dist/layout/comments-module.js +6 -2
  68. package/dist/layout/comments-module.js.map +1 -1
  69. package/dist/layout/module-render-context.d.ts +32 -0
  70. package/dist/layout/module-render-context.d.ts.map +1 -1
  71. package/dist/layout/module-render-context.js +32 -1
  72. package/dist/layout/module-render-context.js.map +1 -1
  73. package/dist/layout/modules-extended.d.ts.map +1 -1
  74. package/dist/layout/modules-extended.js +21 -11
  75. package/dist/layout/modules-extended.js.map +1 -1
  76. package/dist/layout/modules.d.ts.map +1 -1
  77. package/dist/layout/modules.js +15 -10
  78. package/dist/layout/modules.js.map +1 -1
  79. package/dist/scope/inline-editing.d.ts.map +1 -1
  80. package/dist/scope/inline-editing.js +10 -0
  81. package/dist/scope/inline-editing.js.map +1 -1
  82. package/dist/scope/sticky-offset.d.ts +185 -0
  83. package/dist/scope/sticky-offset.d.ts.map +1 -0
  84. package/dist/scope/sticky-offset.js +432 -0
  85. package/dist/scope/sticky-offset.js.map +1 -0
  86. package/dist/site/styles.css +41 -3
  87. package/package.json +1 -1
@@ -1,11 +1,13 @@
1
1
  'use client';
2
2
  import React from 'react';
3
+ import { readStickyOffsetPx } from '../../scope/sticky-offset.js';
3
4
  /**
4
5
  * Height of the editor's fixed top bar, mirroring `--kywi-overlay-bar-height`
5
6
  * in editor.css (the page itself is offset by that token via
6
- * `.kywi-frontend-edit--editing { padding-top: … }`). It has to be a number
7
- * here this is arithmetic against a `getBoundingClientRect()`, not a layout
8
- * value CSS could resolve so the two must be changed together.
7
+ * `.kywi-frontend-edit--editing { padding-top: … }`, and the site's own
8
+ * top-anchored elements by `sticky-offset.ts`). It has to be a number here
9
+ * this is arithmetic against a `getBoundingClientRect()`, not a layout value
10
+ * CSS could resolve — so all of those must be changed together.
9
11
  */
10
12
  const BAR_HEIGHT_PX = 46;
11
13
  /** One chrome row: the height a floating section/container bar occupies. */
@@ -13,8 +15,68 @@ const CHROME_ROW_PX = 40;
13
15
  /**
14
16
  * An owner whose top edge sits above this line has no room for a chrome row
15
17
  * between the fixed bar and itself, so its chrome has to move.
18
+ *
19
+ * The floor, not the whole story: a page whose own header is `position: sticky;
20
+ * top: 0` has a SECOND band under the editor's bar, because the sticky-offset
21
+ * detector re-pins that header just below it (kywi-cms#146). Clearing only the
22
+ * editor's bar then lands the chrome inside the site's nav — measured on the
23
+ * reference app at a clamped chrome of y 60–92 against a nav band of 46–111.
24
+ * So the live strip is this plus whatever the detector published; see
25
+ * {@link topStripPx}.
16
26
  */
17
27
  export const BAR_STRIP_PX = BAR_HEIGHT_PX + CHROME_ROW_PX;
28
+ /**
29
+ * Width of the open module-palette rail, used when `--kywi-overlay-rail-left`
30
+ * cannot be read (server render, a host that lazy-loads editor.css after the
31
+ * first measurement, jsdom). Mirrors `--kywi-editor-panel-width-left` in
32
+ * editor.css, which the rail token resolves to.
33
+ *
34
+ * The rail is `box-sizing: border-box` (it carries `.kywi-overlay-chrome`, see
35
+ * the box-sizing rule at the top of editor.css), so this number is the rail's
36
+ * FULL on-screen footprint — its 1px right border included. That is what makes
37
+ * "read the token" and "measure the rail" the same answer, and it is why the
38
+ * token is the source here: it is stable while the rail's own width transition
39
+ * is still running, which a rect read during a palette toggle is not.
40
+ */
41
+ export const RAIL_WIDTH_FALLBACK_PX = 232;
42
+ /**
43
+ * The narrowest strip of owner worth pushing the bar into. A chrome bar cannot
44
+ * be pushed so far right that its LEFT edge leaves its owner: it is absolutely
45
+ * positioned INSIDE the owner, and a bar that starts past a narrow band's right
46
+ * edge points at nothing. So on an owner too narrow to both clear the rail and
47
+ * still leave this much room, the offset stops here and the bar stays (partly)
48
+ * behind the rail — which the owner fixes with one click on the palette toggle.
49
+ *
50
+ * What this actually guarantees, precisely: the bar's left edge stays inside the
51
+ * owner with at least 200px of the owner to its right — NOT that the whole bar
52
+ * fits. The in-place bar renders its own controls plus Personalize and A/B, so a
53
+ * fully-actioned one is nearer 300px wide and can still overhang the owner's
54
+ * right edge; the chrome carries no max-width to stop it. That overhang is
55
+ * cosmetic (the buttons remain over the page, clickable, and visibly attached to
56
+ * their band) and deliberate: the alternative is refusing to move the bar out
57
+ * from under the rail at all, which is the bug this cap exists inside of.
58
+ */
59
+ export const CHROME_MIN_WIDTH_PX = 200;
60
+ /**
61
+ * Current width of the left rail, from the token the rail itself is sized by,
62
+ * so a host that re-themes `--kywi-editor-panel-width-left` moves the chrome
63
+ * with the rail instead of leaving it behind a wider one.
64
+ *
65
+ * `getPropertyValue` returns the custom property's COMPUTED value, i.e. with
66
+ * its own `var()` reference already substituted — so this reads "232px", not
67
+ * "var(--kywi-editor-panel-width-left)". Either way a value this cannot parse
68
+ * falls back rather than producing NaN arithmetic.
69
+ */
70
+ function readRailWidthPx() {
71
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
72
+ return RAIL_WIDTH_FALLBACK_PX;
73
+ }
74
+ const raw = window
75
+ .getComputedStyle(document.documentElement)
76
+ .getPropertyValue('--kywi-overlay-rail-left');
77
+ const parsed = Number.parseFloat(raw);
78
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : RAIL_WIDTH_FALLBACK_PX;
79
+ }
18
80
  /**
19
81
  * In-place chrome floats ABOVE its owner (`transform: translateY(-100%)`, see
20
82
  * `.kywi-ip-section-chrome` / `.kywi-ip-vc-chrome` in editor.css). For an owner
@@ -24,10 +86,39 @@ export const BAR_STRIP_PX = BAR_HEIGHT_PX + CHROME_ROW_PX;
24
86
  * an edge-adjacent element's chrome can never cover the rails (kywi-cms#118).
25
87
  * The fix is therefore positional (kywi-cms#122).
26
88
  *
27
- * Returns the distance, in px, to pin the chrome DOWN from its owner's top edge
28
- * so it lands just under the fixed bar or null when the owner has room above
29
- * it and the ordinary above-position applies. The caller turns that into
30
- * `--kywi-ip-chrome-clamp` + `.kywi-ip-chrome--clamped`.
89
+ * The same argument runs sideways for the open module palette (kywi-cms#132).
90
+ * That rail is fixed to the left viewport edge, 232px wide, and out-ranks
91
+ * chrome by the same deliberate tier so a FULL-WIDTH section, whose chrome
92
+ * renders at the section's own `left: 0`, has its grip, +Col, Personalize, A/B
93
+ * and × buttons sitting under the palette, unclickable. z-index cannot rescue
94
+ * that either (the rail must stay on top), and unlike an arbitrary site header
95
+ * the rail's footprint is a value the editor owns, so the fix is again
96
+ * positional: push the chrome right until it clears the rail.
97
+ *
98
+ * The vertical strip is not always just the bar, though. On a site whose header
99
+ * is `position: sticky; top: 0` — the common theme pattern — the editor re-pins
100
+ * that header directly under its own bar for the session (sticky-offset.ts,
101
+ * kywi-cms#146), so there is a SECOND band to clear. Clearing only the bar puts
102
+ * the chrome inside the site's nav: measured on the reference app at a clamped
103
+ * chrome of y 60–92 against a nav band of 46–111. The detector publishes that
104
+ * band's height and the clamp adds it, which is a no-op (0) on a page with no
105
+ * top-anchored chrome — i.e. every page the #122 geometry was written against.
106
+ *
107
+ * Returns both offsets from ONE rect read, in px:
108
+ * - `clampPx` — distance to pin the chrome DOWN from its owner's top edge so
109
+ * it lands just under the fixed bar (and the site's offset nav, if any), or
110
+ * null when the owner has room above it and the ordinary above-position
111
+ * applies. The caller turns that into `--kywi-ip-chrome-clamp` +
112
+ * `.kywi-ip-chrome--clamped`.
113
+ * - `railAvoidPx` — distance to push the chrome RIGHT so it clears the open
114
+ * palette rail, or null when the palette is closed or the owner already
115
+ * starts to the right of it. The caller turns that into
116
+ * `--kywi-ip-chrome-left` + `.kywi-ip-chrome--rail-avoid`.
117
+ *
118
+ * One hook rather than two: both offsets are read off the same
119
+ * `getBoundingClientRect()`, which is also the rule the drag work landed on
120
+ * (kywi-cms#136) — chrome geometry comes from rects taken in one frame, never
121
+ * from separately-sampled measurements that can disagree by a scroll.
31
122
  *
32
123
  * Pinning inside the owner rather than hanging the bar off the owner's BOTTOM
33
124
  * edge (the first shape this fix took): a hero taller than the viewport put its
@@ -36,49 +127,106 @@ export const BAR_STRIP_PX = BAR_HEIGHT_PX + CHROME_ROW_PX;
36
127
  * app. Clamping keeps the bar one row under the editor's own bar for as long as
37
128
  * any of its owner is on screen, which is the whole point of the fix.
38
129
  *
39
- * Re-measured on mount and on every scroll/resize: the owner's top edge moves
40
- * relative to the bar as the reader scrolls, and while clamped the offset has
41
- * to track it pixel for pixel. That is a style write per scroll for the one or
42
- * two chromes near the top strip; every other chrome returns a stable value
43
- * (null, or the bottom clamp below) and React bails out without touching the
44
- * DOM at all.
130
+ * Re-measured on mount, on every scroll/resize, and whenever the owner's own box
131
+ * changes a `ResizeObserver`, for the layout moves no window event reports
132
+ * (see the effect). The owner's top edge moves relative to the bar as the reader
133
+ * scrolls, and while clamped the offset has to track it pixel for pixel. That is
134
+ * a style write per scroll for the one or two chromes near the top strip; every
135
+ * other chrome returns a stable value (null, or the bottom clamp below) and
136
+ * React bails out without touching the DOM at all.
45
137
  *
46
138
  * @param ownerRef the element the chrome is positioned against (the section or
47
139
  * variant-container root). A null ref never clamps.
48
140
  * @param enabled pass false for surfaces with no floating chrome — the admin
49
141
  * artboard has its own header and in-flow toolbars, so it must neither clamp
50
142
  * nor pay for the listeners.
143
+ * @param paletteOpen whether the left module-palette rail is currently expanded.
144
+ * In-place only; the admin Layout tab's palette is an in-flow column that
145
+ * displaces the canvas rather than floating over it, so nothing there has a
146
+ * rail to avoid. Part of the effect's deps, so toggling the palette
147
+ * re-measures immediately rather than waiting for the next scroll.
51
148
  */
52
- export function useChromeFlip(ownerRef, enabled = true) {
149
+ export function useChromeFlip(ownerRef, enabled = true, paletteOpen = false) {
53
150
  const [clampOffsetPx, setClampOffsetPx] = React.useState(null);
151
+ const [railAvoidOffsetPx, setRailAvoidOffsetPx] = React.useState(null);
54
152
  React.useEffect(() => {
55
153
  if (!enabled || typeof window === 'undefined') {
56
154
  setClampOffsetPx(null);
155
+ setRailAvoidOffsetPx(null);
57
156
  return;
58
157
  }
158
+ // Read once per effect run, not per scroll: this is a theme token, so it
159
+ // can only change with a stylesheet, and `getComputedStyle` in a scroll
160
+ // handler is exactly the kind of per-frame style read the clamp's own
161
+ // comment is careful to avoid. A palette toggle re-runs the effect (it is
162
+ // in the deps), which is also when a re-read could matter.
163
+ const railWidthPx = readRailWidthPx();
59
164
  const measure = () => {
60
165
  const owner = ownerRef.current;
61
166
  if (!owner)
62
167
  return;
63
- const { top, height } = owner.getBoundingClientRect();
64
- if (top >= BAR_STRIP_PX) {
168
+ // ONE rect for both axes — see the kywi-cms#136 note above.
169
+ const { top, height, left, width } = owner.getBoundingClientRect();
170
+ // ── Vertical: clear the editor's fixed top bar (kywi-cms#122) and the
171
+ // site's offset sticky band on top of it (kywi-cms#146) ─────────────
172
+ //
173
+ // Read per measurement, unlike the rail width above: that is a theme
174
+ // token which can only change with a stylesheet, whereas this tracks a
175
+ // live element whose height changes when its nav wraps. Cheap enough for
176
+ // a scroll handler because `readStickyOffsetPx` reads back the INLINE
177
+ // custom property the detector wrote — a CSSOM read, not a
178
+ // `getComputedStyle` that could force a style recalculation. 0 (the whole
179
+ // pre-#146 behaviour) whenever the page has no offset top band.
180
+ const stickyPx = readStickyOffsetPx();
181
+ // Where a clamped bar has to land: under the editor's bar AND under the
182
+ // site's nav, which the detector re-pinned directly beneath it.
183
+ const stripBottomPx = BAR_HEIGHT_PX + stickyPx;
184
+ if (top >= BAR_STRIP_PX + stickyPx) {
65
185
  setClampOffsetPx(null);
66
- return;
67
186
  }
68
- // How far down from the owner's top edge the bar has to sit to clear the
69
- // fixed bar. Never negative: an owner already below the bar keeps its
70
- // chrome flush with its own top edge rather than being pulled up into it.
71
- const wanted = Math.max(0, BAR_HEIGHT_PX - top);
72
- // …but never past the owner's own bottom edge either. Once the owner has
73
- // scrolled far enough that the bar would otherwise hang below it, the bar
74
- // rides out with its owner instead of sticking to the top of the screen
75
- // over whatever the reader is actually looking at. This also caps the
76
- // per-scroll churn: for anything well above the fold the offset stops
77
- // changing, and the style writes stop with it.
78
- const maxInsideOwner = Math.max(0, height - CHROME_ROW_PX);
79
- // Rounded so sub-pixel rects (browser zoom, fractional layouts) don't
80
- // re-render on every frame of a scroll that hasn't moved a whole pixel.
81
- setClampOffsetPx(Math.round(Math.min(wanted, maxInsideOwner)));
187
+ else {
188
+ // How far down from the owner's top edge the bar has to sit to clear the
189
+ // fixed bar. Never negative: an owner already below the bar keeps its
190
+ // chrome flush with its own top edge rather than being pulled up into it.
191
+ const wanted = Math.max(0, stripBottomPx - top);
192
+ // …but never past the owner's own bottom edge either. Once the owner has
193
+ // scrolled far enough that the bar would otherwise hang below it, the bar
194
+ // rides out with its owner instead of sticking to the top of the screen
195
+ // over whatever the reader is actually looking at. This also caps the
196
+ // per-scroll churn: for anything well above the fold the offset stops
197
+ // changing, and the style writes stop with it.
198
+ const maxInsideOwner = Math.max(0, height - CHROME_ROW_PX);
199
+ // Rounded so sub-pixel rects (browser zoom, fractional layouts) don't
200
+ // re-render on every frame of a scroll that hasn't moved a whole pixel.
201
+ setClampOffsetPx(Math.round(Math.min(wanted, maxInsideOwner)));
202
+ }
203
+ // ── Horizontal: clear the open palette rail (kywi-cms#132) ────────────
204
+ if (!paletteOpen || left >= railWidthPx) {
205
+ // A closed palette, or an owner (a centred container, an inset band)
206
+ // that already starts to the right of the rail — the ordinary
207
+ // left-edge position is reachable, so leave it alone.
208
+ setRailAvoidOffsetPx(null);
209
+ }
210
+ else {
211
+ // Full-width owners are the case in the issue: left 0, so the chrome
212
+ // needs the rail's whole width. A partly-covered owner needs only the
213
+ // overlap.
214
+ const wanted = railWidthPx - left;
215
+ // Capped so the bar's left edge stays inside the owner it belongs to.
216
+ // A band narrower than the rail plus a usable strip keeps whatever room
217
+ // it has; pushing further would start the bar past the band's own right
218
+ // edge, over the section beside it.
219
+ const maxInsideOwner = Math.max(0, width - CHROME_MIN_WIDTH_PX);
220
+ // Rounded AWAY from each obstacle, not to the nearest pixel. A
221
+ // fractional owner left (zoom, a centred layout on an odd viewport)
222
+ // rounded to nearest can land the bar half a pixel back INSIDE the rail
223
+ // — owner left 124.6 + round(107.4) = 231.6, against a rail that ends at
224
+ // 232 — which is both visibly clipped and a real hit-test failure at the
225
+ // bar's left edge. `ceil` on the push clears the rail; `floor` on the
226
+ // cap keeps the guarantee above (≥200px of owner to the bar's right)
227
+ // rather than overshooting it by the same sub-pixel.
228
+ setRailAvoidOffsetPx(Math.min(Math.ceil(wanted), Math.floor(maxInsideOwner)));
229
+ }
82
230
  };
83
231
  measure();
84
232
  // `capture: true` so a scroll inside any nested scroll container on the
@@ -87,11 +235,63 @@ export function useChromeFlip(ownerRef, enabled = true) {
87
235
  const listenerOptions = { passive: true, capture: true };
88
236
  window.addEventListener('scroll', measure, listenerOptions);
89
237
  window.addEventListener('resize', measure, listenerOptions);
238
+ // Scroll and resize are window events, and the owner can move under BOTH
239
+ // measurements without either firing. The live case is the responsive
240
+ // preview: switching Desktop → Mobile re-caps the page's max-width
241
+ // (`.kywi-ip-page--constrained`, overlay-shell), so every band's left edge
242
+ // and width jump inwards while the window is exactly the size it was and
243
+ // nothing scrolled. Nor is there a prop change to fall back on: the offsets
244
+ // are state inside this hook, so the canvas's React.memo has nothing to
245
+ // re-render on. Measured on the reference app at 1280 with the palette out:
246
+ // a band at x=124 takes a 108px push, and switching to Mobile moves it to
247
+ // x=452.5 — the bar stayed at 560.5, a third of the band's width adrift,
248
+ // until something scrolled. Observing the owner heals both axes; the same
249
+ // layout change is what moves its top edge relative to the fixed bar.
250
+ //
251
+ // Feature-detected rather than assumed: happy-dom ships an inert stub and
252
+ // older hosts have none, and a chrome that only re-measures on scroll is
253
+ // exactly the behaviour this shipped with, so a missing observer degrades
254
+ // instead of throwing.
255
+ const ResizeObserverCtor = window
256
+ .ResizeObserver;
257
+ let observer = null;
258
+ if (typeof ResizeObserverCtor === 'function') {
259
+ observer = new ResizeObserverCtor(measure);
260
+ const owner = ownerRef.current;
261
+ if (owner)
262
+ observer.observe(owner);
263
+ }
90
264
  return () => {
91
265
  window.removeEventListener('scroll', measure, listenerOptions);
92
266
  window.removeEventListener('resize', measure, listenerOptions);
267
+ observer?.disconnect();
93
268
  };
94
- }, [ownerRef, enabled]);
95
- return clampOffsetPx;
269
+ }, [ownerRef, enabled, paletteOpen]);
270
+ return React.useMemo(() => ({ clampPx: clampOffsetPx, railAvoidPx: railAvoidOffsetPx }), [clampOffsetPx, railAvoidOffsetPx]);
271
+ }
272
+ /**
273
+ * The modifier classes the measured offsets ask for, in the order the CSS
274
+ * expects them. Shared by both consumers (`SectionOverlay` and
275
+ * `VariantContainerBlock`) so a third one cannot half-implement the contract.
276
+ */
277
+ export function chromeOffsetClasses(offsets) {
278
+ const classes = [];
279
+ if (offsets.clampPx !== null)
280
+ classes.push('kywi-ip-chrome--clamped');
281
+ if (offsets.railAvoidPx !== null)
282
+ classes.push('kywi-ip-chrome--rail-avoid');
283
+ return classes;
284
+ }
285
+ /**
286
+ * The custom properties those classes read, or undefined when neither offset
287
+ * applies (so the element keeps no `style` attribute at all in the common case).
288
+ */
289
+ export function chromeOffsetStyle(offsets) {
290
+ if (offsets.clampPx === null && offsets.railAvoidPx === null)
291
+ return undefined;
292
+ return {
293
+ ...(offsets.clampPx !== null ? { '--kywi-ip-chrome-clamp': `${offsets.clampPx}px` } : {}),
294
+ ...(offsets.railAvoidPx !== null ? { '--kywi-ip-chrome-left': `${offsets.railAvoidPx}px` } : {}),
295
+ };
96
296
  }
97
297
  //# sourceMappingURL=use-chrome-flip.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-chrome-flip.js","sourceRoot":"","sources":["../../../src/admin/layout-editor/use-chrome-flip.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AACZ,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB;;;;;;GAMG;AACH,MAAM,aAAa,GAAG,EAAE,CAAA;AAExB,4EAA4E;AAC5E,MAAM,aAAa,GAAG,EAAE,CAAA;AAExB;;;GAGG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,aAAa,GAAG,aAAa,CAAA;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,UAAU,aAAa,CAC3B,QAA6C,EAC7C,OAAO,GAAG,IAAI;IAEd,MAAM,CAAC,aAAa,EAAE,gBAAgB,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAgB,IAAI,CAAC,CAAA;IAE7E,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE;QACnB,IAAI,CAAC,OAAO,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;YAC9C,gBAAgB,CAAC,IAAI,CAAC,CAAA;YACtB,OAAM;QACR,CAAC;QAED,MAAM,OAAO,GAAG,GAAG,EAAE;YACnB,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAA;YAC9B,IAAI,CAAC,KAAK;gBAAE,OAAM;YAClB,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,qBAAqB,EAAE,CAAA;YACrD,IAAI,GAAG,IAAI,YAAY,EAAE,CAAC;gBACxB,gBAAgB,CAAC,IAAI,CAAC,CAAA;gBACtB,OAAM;YACR,CAAC;YACD,yEAAyE;YACzE,sEAAsE;YACtE,0EAA0E;YAC1E,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,aAAa,GAAG,GAAG,CAAC,CAAA;YAC/C,yEAAyE;YACzE,0EAA0E;YAC1E,wEAAwE;YACxE,sEAAsE;YACtE,sEAAsE;YACtE,+CAA+C;YAC/C,MAAM,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC,CAAA;YAC1D,sEAAsE;YACtE,wEAAwE;YACxE,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAA;QAChE,CAAC,CAAA;QAED,OAAO,EAAE,CAAA;QACT,wEAAwE;QACxE,yEAAyE;QACzE,0DAA0D;QAC1D,MAAM,eAAe,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAW,CAAA;QACjE,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,eAAe,CAAC,CAAA;QAC3D,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,eAAe,CAAC,CAAA;QAC3D,OAAO,GAAG,EAAE;YACV,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,eAAe,CAAC,CAAA;YAC9D,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,eAAe,CAAC,CAAA;QAChE,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAA;IAEvB,OAAO,aAAa,CAAA;AACtB,CAAC"}
1
+ {"version":3,"file":"use-chrome-flip.js","sourceRoot":"","sources":["../../../src/admin/layout-editor/use-chrome-flip.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { useSensors } from '@dnd-kit/core';
3
- import type { CollisionDetection, DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
3
+ import type { Announcements, CollisionDetection, DragEndEvent, DragMoveEvent, DragStartEvent, KeyboardCoordinateGetter } from '@dnd-kit/core';
4
4
  import type { ModuleRegistry } from '../../layout/registry.js';
5
5
  import type { EditorAction } from './editor-state.js';
6
6
  import type { ActiveDrag, DropTarget } from './drag-feedback.js';
@@ -31,6 +31,49 @@ export interface EditorDnd {
31
31
  handleDragMove: (event: DragMoveEvent) => void;
32
32
  handleDragEnd: (event: DragEndEvent) => void;
33
33
  handleDragCancel: () => void;
34
+ /**
35
+ * Spread onto `<DndContext accessibility={...}>` in both shells so a
36
+ * keyboard drag (kywi-cms#139) narrates itself the same way a pointer drag
37
+ * shows itself: what's picked up, where it is, and what happened at drop or
38
+ * cancel. dnd-kit renders these into its own visually-hidden live region —
39
+ * no `container` override, unlike audience-list.tsx's — because neither
40
+ * shell nests DndContext inside an element (like `<table>`) that rejects a
41
+ * bare `<div>` child.
42
+ */
43
+ accessibility: {
44
+ announcements: Announcements;
45
+ };
34
46
  }
47
+ /**
48
+ * kywi-cms#139: `sortableKeyboardCoordinates` — the repo's standing
49
+ * coordinateGetter for every other keyboard drag (audience-list.tsx,
50
+ * site-tree.tsx, site-tree-table.tsx, menu-editor.tsx) — turned out to be
51
+ * silently inert for a palette item. Verified by hand: Tab to a palette item,
52
+ * Space to pick it up, arrow keys did *nothing* — no drop indicator, no
53
+ * screen-reader "over" announcement, and Space dropped it "not over a drop
54
+ * target" every time.
55
+ *
56
+ * The cause is in dnd-kit's own implementation, not this repo: it resolves
57
+ * the dragged item's OWN slot via `droppableContainers.get(active.id)`, to
58
+ * offset the move against where the item already sits in a shared sortable
59
+ * list. Every other caller drags a `useSortable` row — which, being sortable,
60
+ * is also its own droppable, so that lookup succeeds. A palette item
61
+ * (module-palette.tsx) is a plain `useDraggable` with no droppable
62
+ * counterpart of its own; the lookup misses, an internal guard silently
63
+ * fails, and the getter returns nothing — dnd-kit never even calls
64
+ * `onMove`, so no re-render, no collision recompute, no announcement.
65
+ *
66
+ * Delegate to the real thing whenever the active item IS also a droppable —
67
+ * a canvas reorder (module-overlay.tsx's `useSortable`) behaves exactly as
68
+ * it did before this existed. Only when that lookup misses (a palette item)
69
+ * does this take over, with the same direction-filter-then-closestCorners
70
+ * dnd-kit's own version uses, landing the drag on the target droppable's
71
+ * top-left corner rather than computing a same-list offset that doesn't
72
+ * apply here. That is enough: `resolveDragEndAction`/`resolvePaletteDropSlot`
73
+ * read the drop from `over.data`, not from the translate's exact pixels, so
74
+ * a keyboard move only has to make `over` become the right droppable — which
75
+ * this does — not reproduce dnd-kit's reorder-preview polish.
76
+ */
77
+ export declare const paletteAwareKeyboardCoordinates: KeyboardCoordinateGetter;
35
78
  export declare function useEditorDnd({ registry, dispatch }: EditorDndOptions): EditorDnd;
36
79
  //# sourceMappingURL=use-editor-dnd.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-editor-dnd.d.ts","sourceRoot":"","sources":["../../../src/admin/layout-editor/use-editor-dnd.ts"],"names":[],"mappings":"AACA,OAAO,KAAmD,MAAM,OAAO,CAAA;AACvE,OAAO,EAGL,UAAU,EAEX,MAAM,eAAe,CAAA;AACtB,OAAO,KAAK,EACV,kBAAkB,EAClB,YAAY,EACZ,aAAa,EACb,cAAc,EACf,MAAM,eAAe,CAAA;AACtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AASrD,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAEhE;;;;;;GAMG;AAEH,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,KAAK,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAA;CACvC;AAED,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAA;IACtC,kBAAkB,EAAE,kBAAkB,CAAA;IACtC,kEAAkE;IAClE,UAAU,EAAE,UAAU,GAAG,IAAI,CAAA;IAC7B,6EAA6E;IAC7E,UAAU,EAAE,UAAU,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAA;IAChD,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAA;IAC9C,aAAa,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;IAC5C,gBAAgB,EAAE,MAAM,IAAI,CAAA;CAC7B;AAmBD,wBAAgB,YAAY,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,gBAAgB,GAAG,SAAS,CA0LhF"}
1
+ {"version":3,"file":"use-editor-dnd.d.ts","sourceRoot":"","sources":["../../../src/admin/layout-editor/use-editor-dnd.ts"],"names":[],"mappings":"AACA,OAAO,KAA4D,MAAM,OAAO,CAAA;AAChF,OAAO,EAIL,UAAU,EAIX,MAAM,eAAe,CAAA;AACtB,OAAO,KAAK,EAEV,aAAa,EACb,kBAAkB,EAClB,YAAY,EACZ,aAAa,EACb,cAAc,EAEd,wBAAwB,EAEzB,MAAM,eAAe,CAAA;AAEtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AASrD,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAEhE;;;;;;GAMG;AAEH,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,KAAK,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAA;CACvC;AAED,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAA;IACtC,kBAAkB,EAAE,kBAAkB,CAAA;IACtC,kEAAkE;IAClE,UAAU,EAAE,UAAU,GAAG,IAAI,CAAA;IAC7B,6EAA6E;IAC7E,UAAU,EAAE,UAAU,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAA;IAChD,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAA;IAC9C,aAAa,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;IAC5C,gBAAgB,EAAE,MAAM,IAAI,CAAA;IAC5B;;;;;;;;OAQG;IACH,aAAa,EAAE;QAAE,aAAa,EAAE,aAAa,CAAA;KAAE,CAAA;CAChD;AA0ED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,+BAA+B,EAAE,wBAwD7C,CAAA;AAED,wBAAgB,YAAY,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,gBAAgB,GAAG,SAAS,CAuOhF"}
@@ -1,6 +1,7 @@
1
1
  'use client';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
3
- import { PointerSensor, KeyboardSensor, useSensors, useSensor, } from '@dnd-kit/core';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { PointerSensor, KeyboardSensor, KeyboardCode, useSensors, useSensor, closestCorners, getFirstCollision, } from '@dnd-kit/core';
4
+ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
4
5
  import { createDwellTimer, editorCollisionDetection, resolveDragEndAction, resolvePaletteDropSlot, } from './drag-utils.js';
5
6
  /**
6
7
  * How long a drag has to hold one slot before the thin line grows into a
@@ -17,6 +18,149 @@ const DWELL_MS = 600;
17
18
  function zoneKey(columnId, index) {
18
19
  return `${columnId}:${index}`;
19
20
  }
21
+ /**
22
+ * The screen-reader subject of a drag: the registry label, for both a
23
+ * palette item and a canvas reorder — `ModuleOverlay`'s `useSortable` data
24
+ * carries `moduleType` too (kywi-cms#139 review), so both sources resolve the
25
+ * same way now. The generic fallback only fires if drag data is ever missing
26
+ * `moduleType` altogether (there is no such caller today; kept for safety).
27
+ */
28
+ function describeSubject(data, labelFor) {
29
+ if (data?.moduleType)
30
+ return labelFor(data.moduleType);
31
+ return 'the module';
32
+ }
33
+ /** Upper-cases the first character, for `describeSubject`'s output at the
34
+ * start of a sentence (`onDragOver`/`onDragEnd`) — a registry label is
35
+ * already capitalized ("Heading"), so this only changes anything for the
36
+ * generic "the module" fallback ("The module …"). */
37
+ function capitalize(text) {
38
+ return text.length ? text[0].toUpperCase() + text.slice(1) : text;
39
+ }
40
+ /**
41
+ * Where a drag is over, or would drop, in words — position only, never the
42
+ * column's own id. Production column ids are `crypto.randomUUID()`, so
43
+ * speaking one (`column 3f2a9c1e-4b7d-4a1e-9c2f-8ab0d5e61a77`) would read 36
44
+ * hex-and-dash characters aloud on every arrow press (kywi-cms#139 review;
45
+ * caught late because the original tests' `col-1`/`col-2` fixtures read fine
46
+ * spoken and masked it). The announcement callbacks only receive `{ active,
47
+ * over }` (dnd-kit's `Accessibility.Arguments`) — no droppable list, no
48
+ * ordinal — so "column 2 of 3" isn't available without threading new layout
49
+ * state into the hook just to answer a screen reader once per keypress; the
50
+ * per-column position already on `over.data` (see `EditorDragDatum`) is.
51
+ * That index is deliberately the *declared* slot, not the pointer-corrected
52
+ * one `resolveDragEndAction` computes for a mouse drop: a keyboard drag has
53
+ * no pointer at all, so the declared slot is the only honest answer to
54
+ * "where does the screen reader say this is" that a keyboard user's own
55
+ * arrow presses actually produced.
56
+ */
57
+ function describeLocation(over) {
58
+ const data = over?.data.current;
59
+ if (!data?.columnId)
60
+ return null;
61
+ const index = data.index ?? 0;
62
+ return `this column, position ${index + 1}`;
63
+ }
64
+ /** Arrow-key codes `sortableKeyboardCoordinates` (and this function) act on. */
65
+ const KEYBOARD_DIRECTIONS = [
66
+ KeyboardCode.Down,
67
+ KeyboardCode.Up,
68
+ KeyboardCode.Left,
69
+ KeyboardCode.Right,
70
+ ];
71
+ /**
72
+ * kywi-cms#139: `sortableKeyboardCoordinates` — the repo's standing
73
+ * coordinateGetter for every other keyboard drag (audience-list.tsx,
74
+ * site-tree.tsx, site-tree-table.tsx, menu-editor.tsx) — turned out to be
75
+ * silently inert for a palette item. Verified by hand: Tab to a palette item,
76
+ * Space to pick it up, arrow keys did *nothing* — no drop indicator, no
77
+ * screen-reader "over" announcement, and Space dropped it "not over a drop
78
+ * target" every time.
79
+ *
80
+ * The cause is in dnd-kit's own implementation, not this repo: it resolves
81
+ * the dragged item's OWN slot via `droppableContainers.get(active.id)`, to
82
+ * offset the move against where the item already sits in a shared sortable
83
+ * list. Every other caller drags a `useSortable` row — which, being sortable,
84
+ * is also its own droppable, so that lookup succeeds. A palette item
85
+ * (module-palette.tsx) is a plain `useDraggable` with no droppable
86
+ * counterpart of its own; the lookup misses, an internal guard silently
87
+ * fails, and the getter returns nothing — dnd-kit never even calls
88
+ * `onMove`, so no re-render, no collision recompute, no announcement.
89
+ *
90
+ * Delegate to the real thing whenever the active item IS also a droppable —
91
+ * a canvas reorder (module-overlay.tsx's `useSortable`) behaves exactly as
92
+ * it did before this existed. Only when that lookup misses (a palette item)
93
+ * does this take over, with the same direction-filter-then-closestCorners
94
+ * dnd-kit's own version uses, landing the drag on the target droppable's
95
+ * top-left corner rather than computing a same-list offset that doesn't
96
+ * apply here. That is enough: `resolveDragEndAction`/`resolvePaletteDropSlot`
97
+ * read the drop from `over.data`, not from the translate's exact pixels, so
98
+ * a keyboard move only has to make `over` become the right droppable — which
99
+ * this does — not reproduce dnd-kit's reorder-preview polish.
100
+ */
101
+ export const paletteAwareKeyboardCoordinates = (event, args) => {
102
+ const { context } = args;
103
+ if (context.droppableContainers.get(args.active)) {
104
+ return sortableKeyboardCoordinates(event, args);
105
+ }
106
+ if (!KEYBOARD_DIRECTIONS.includes(event.code))
107
+ return undefined;
108
+ // Hoisted here (dnd-kit's own getter does the same): an arrow press that
109
+ // resolves no candidate below still must not fall through to the browser's
110
+ // native page-scroll mid-drag — that would shift `scrollAdjustedTranslate`
111
+ // and could move `over` without the owner having pressed anything meant to
112
+ // move it (kywi-cms#139 review).
113
+ event.preventDefault();
114
+ const { active, collisionRect, droppableRects, droppableContainers, over } = context;
115
+ if (!active || !collisionRect)
116
+ return undefined;
117
+ const candidates = [];
118
+ droppableContainers.getEnabled().forEach((entry) => {
119
+ if (!entry || entry.disabled)
120
+ return;
121
+ const rect = droppableRects.get(entry.id);
122
+ if (!rect)
123
+ return;
124
+ switch (event.code) {
125
+ case KeyboardCode.Down:
126
+ if (collisionRect.top < rect.top)
127
+ candidates.push(entry);
128
+ break;
129
+ case KeyboardCode.Up:
130
+ if (collisionRect.top > rect.top)
131
+ candidates.push(entry);
132
+ break;
133
+ case KeyboardCode.Left:
134
+ if (collisionRect.left > rect.left)
135
+ candidates.push(entry);
136
+ break;
137
+ case KeyboardCode.Right:
138
+ if (collisionRect.left < rect.left)
139
+ candidates.push(entry);
140
+ break;
141
+ }
142
+ });
143
+ const collisions = closestCorners({
144
+ active,
145
+ collisionRect,
146
+ droppableRects,
147
+ droppableContainers: candidates,
148
+ pointerCoordinates: null,
149
+ });
150
+ let closestId = getFirstCollision(collisions, 'id');
151
+ // Same "don't re-announce where we already are" rule as dnd-kit's own
152
+ // version: if the nearest candidate is the droppable we're already over,
153
+ // and there is a second one, prefer that — otherwise repeated presses in
154
+ // the same direction would never move past the first neighbour.
155
+ if (closestId === over?.id && collisions.length > 1)
156
+ closestId = collisions[1]?.id ?? null;
157
+ if (closestId == null)
158
+ return undefined;
159
+ const targetRect = droppableRects.get(closestId);
160
+ if (!targetRect)
161
+ return undefined;
162
+ return { x: targetRect.left, y: targetRect.top };
163
+ };
20
164
  export function useEditorDnd({ registry, dispatch }) {
21
165
  const sensors = useSensors(
22
166
  // kywi-cms#134: without a distance constraint a bare CLICK on a palette
@@ -24,7 +168,14 @@ export function useEditorDnd({ registry, dispatch }) {
24
168
  // resolves a drop target under the cursor and the editor silently inserts
25
169
  // a module the user never dragged anywhere. Requiring 5px of travel makes
26
170
  // a click a no-op and leaves a real drag untouched.
27
- useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor));
171
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
172
+ // kywi-cms#139: without a coordinateGetter, dnd-kit's KeyboardSensor has no
173
+ // way to translate an arrow-key press into a position at all — Tab could
174
+ // focus a palette item and Space could "pick it up", but arrows did
175
+ // nothing and the drag could never move. See `paletteAwareKeyboardCoordinates`
176
+ // above for why the repo's usual `sortableKeyboardCoordinates` doesn't
177
+ // reach a palette item on its own.
178
+ useSensor(KeyboardSensor, { coordinateGetter: paletteAwareKeyboardCoordinates }));
28
179
  /**
29
180
  * The pointer's live viewport position, in the same coordinate space
30
181
  * `getBoundingClientRect()` reports. dnd-kit computes exactly this value on
@@ -85,6 +236,42 @@ export function useEditorDnd({ registry, dispatch }) {
85
236
  * arrived in the same tick would see a stale (null) label.
86
237
  */
87
238
  const labelFor = useCallback((moduleType) => registry?.get(moduleType)?.label ?? moduleType, [registry]);
239
+ // ── Screen-reader announcements (kywi-cms#139) ────────────────────────────
240
+ // A keyboard drag is otherwise silent: sighted mouse users see the ghost and
241
+ // drop indicator this hook already publishes above, but a screen-reader
242
+ // user picking a palette item up with Space and moving it with arrow keys
243
+ // gets no feedback that anything happened at all. dnd-kit renders whatever
244
+ // these return into its own visually-hidden live region.
245
+ const announcements = useMemo(() => ({
246
+ onDragStart({ active }) {
247
+ const data = active.data.current;
248
+ return `Picked up ${describeSubject(data, labelFor)}.`;
249
+ },
250
+ onDragOver({ active, over }) {
251
+ const data = active.data.current;
252
+ const subject = capitalize(describeSubject(data, labelFor));
253
+ const location = describeLocation(over);
254
+ return location
255
+ ? `${subject} is over ${location}.`
256
+ : `${subject} is no longer over a drop target.`;
257
+ },
258
+ onDragEnd({ active, over }) {
259
+ const data = active.data.current;
260
+ const subject = capitalize(describeSubject(data, labelFor));
261
+ const location = describeLocation(over);
262
+ return location
263
+ ? `${subject} was dropped over ${location}.`
264
+ : `${subject} was dropped, but not over a drop target.`;
265
+ },
266
+ onDragCancel({ active }) {
267
+ const data = active.data.current;
268
+ return `${capitalize(describeSubject(data, labelFor))} drag was cancelled.`;
269
+ },
270
+ }), [labelFor]);
271
+ // Stable identity like `announcements` itself: DndContext takes this whole
272
+ // object as a prop, and dnd-kit's own Accessibility component re-subscribes
273
+ // its live region whenever it changes.
274
+ const accessibility = useMemo(() => ({ announcements }), [announcements]);
88
275
  /** Stand the whole feedback layer down — no ghost, no indicator, no timer. */
89
276
  const clearFeedback = useCallback(() => {
90
277
  dwell.reset();
@@ -180,6 +367,7 @@ export function useEditorDnd({ registry, dispatch }) {
180
367
  handleDragMove,
181
368
  handleDragEnd,
182
369
  handleDragCancel,
370
+ accessibility,
183
371
  };
184
372
  }
185
373
  //# sourceMappingURL=use-editor-dnd.js.map