@redseed/redseed-ui-vue3 8.52.0 → 8.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -25,6 +25,7 @@ export * from './src/components/GridContainer'
25
25
  export * from './src/components/HTML'
26
26
  export * from './src/components/Icon'
27
27
  export * from './src/components/Image'
28
+ export * from './src/components/InlineEditableText'
28
29
  export * from './src/components/Layout'
29
30
  export * from './src/components/Link'
30
31
  export * from './src/components/LinkedList'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.52.0",
3
+ "version": "8.53.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -80,7 +80,11 @@ const emit = defineEmits([
80
80
  'reorder'
81
81
  ])
82
82
 
83
- // Drag and drop
83
+ // Drag and drop.
84
+ // Two engines cooperate by input type and share the state + classes below:
85
+ // mouse -> native HTML5 DnD (draggable attr + drag* handlers on the container)
86
+ // touch/pen -> Pointer Events engine (see onHandlePointerDown and friends)
87
+ // A single gesture is only ever handled by one engine, so they never collide.
84
88
  const dragFromIndex = ref(null)
85
89
  let dragOverChild = null
86
90
  let childObserver = null
@@ -125,7 +129,18 @@ function getChildIndex(el) {
125
129
  return Array.from(cardsContainerElement.value.children).indexOf(child)
126
130
  }
127
131
 
132
+ // True while a touch/pen pointer drag owns the shared drag state (dragFromIndex /
133
+ // dragOverChild). The native mouse engine must bail out of EVERY handler when this
134
+ // holds: returning from a dragstart listener does not cancel the browser's native
135
+ // drag (only preventDefault would), so dragover/dragleave/drop/dragend still fire on
136
+ // a hybrid device (finger on one handle, mouse on another) and would otherwise emit a
137
+ // reorder mixing the touch source with the mouse target.
138
+ function pointerDragOwnsState() {
139
+ return activePointerId !== null
140
+ }
141
+
128
142
  function onHandleMouseDown(e) {
143
+ if (pointerDragOwnsState()) return
129
144
  const child = getCardChild(e.target)
130
145
  if (child) {
131
146
  child.setAttribute('draggable', 'true')
@@ -142,7 +157,7 @@ function onHandleMouseUp(e) {
142
157
  }
143
158
 
144
159
  function onDragStart(e) {
145
- if (!props.reorderable) return
160
+ if (!props.reorderable || pointerDragOwnsState()) return
146
161
  const index = getChildIndex(e.target)
147
162
  if (index === -1) return
148
163
  dragFromIndex.value = index
@@ -152,7 +167,7 @@ function onDragStart(e) {
152
167
  }
153
168
 
154
169
  function onDragOver(e) {
155
- if (!props.reorderable || dragFromIndex.value === null) return
170
+ if (!props.reorderable || pointerDragOwnsState() || dragFromIndex.value === null) return
156
171
  e.preventDefault()
157
172
  e.dataTransfer.dropEffect = 'move'
158
173
  const child = getCardChild(e.target)
@@ -167,7 +182,7 @@ function onDragOver(e) {
167
182
  }
168
183
 
169
184
  function onDragLeave(e) {
170
- if (!props.reorderable) return
185
+ if (!props.reorderable || pointerDragOwnsState()) return
171
186
  const child = getCardChild(e.target)
172
187
  if (child && !child.contains(e.relatedTarget)) {
173
188
  child.classList.remove('rsui-card-group__card--drag-over')
@@ -175,7 +190,7 @@ function onDragLeave(e) {
175
190
  }
176
191
 
177
192
  function onDrop(e) {
178
- if (!props.reorderable) return
193
+ if (!props.reorderable || pointerDragOwnsState()) return
179
194
  e.preventDefault()
180
195
  const toIndex = getChildIndex(e.target)
181
196
  if (toIndex !== -1 && dragFromIndex.value !== null && dragFromIndex.value !== toIndex) {
@@ -185,12 +200,237 @@ function onDrop(e) {
185
200
  }
186
201
 
187
202
  function onDragEnd() {
203
+ if (pointerDragOwnsState()) return
188
204
  cleanupDragState()
189
205
  }
190
206
 
207
+ // Pointer-driven drag engine — TOUCH and PEN only. Mouse is deliberately left to
208
+ // the native HTML5 DnD path above: browsers give mouse users a real drag image and
209
+ // drop semantics for free, but they never START native DnD from a touch gesture, so
210
+ // the handle was inert on touch (#238). onHandlePointerDown early-returns for mouse
211
+ // before any preventDefault/setPointerCapture — either would cancel the browser's
212
+ // native dragstart — so a single gesture is only ever handled by one engine. On a
213
+ // touch/pen press we setPointerCapture, which routes every later move/up for that
214
+ // pointer back to the handle; because capture makes e.target always the handle,
215
+ // elementFromPoint (not e.target) resolves the card under the pointer.
216
+ const DRAG_THRESHOLD_PX = 4
217
+
218
+ // Native DnD auto-scrolls a long list for mouse users near the viewport edge for
219
+ // free; the pointer engine has to do it by hand, or a touch user can only reorder
220
+ // within the cards already on screen (e.g. can't move item 1 to item 12 in a long
221
+ // meeting agenda). While a touch/pen drag is near an edge we scroll the nearest
222
+ // scrollable ancestor (or the window) each frame and re-run the hit-test, since the
223
+ // finger is stationary while content moves under it.
224
+ const AUTO_SCROLL_ZONE_PX = 64
225
+ const AUTO_SCROLL_MAX_SPEED_PX = 14
226
+
227
+ let activePointerId = null
228
+ let capturedHandle = null
229
+ let dragStartX = 0
230
+ let dragStartY = 0
231
+ let hasDragMoved = false
232
+ let lastPointerX = 0
233
+ let lastPointerY = 0
234
+ let scrollContainer = null
235
+ let autoScrollVelocity = 0
236
+ let autoScrollFrame = null
237
+
238
+ function getChildFromPoint(x, y) {
239
+ return getCardChild(document.elementFromPoint(x, y))
240
+ }
241
+
242
+ // Resolve and highlight the drop target under a viewport point. Shared by
243
+ // pointermove and the auto-scroll frame. Moving off any droppable card (or back
244
+ // onto the source) clears the highlight, mirroring native dragleave.
245
+ function updateDropTarget(x, y) {
246
+ const child = getChildFromPoint(x, y)
247
+ const index = child ? Array.from(cardsContainerElement.value.children).indexOf(child) : -1
248
+ if (index === -1 || index === dragFromIndex.value) {
249
+ if (dragOverChild) {
250
+ dragOverChild.classList.remove('rsui-card-group__card--drag-over')
251
+ dragOverChild = null
252
+ }
253
+ return
254
+ }
255
+ if (dragOverChild && dragOverChild !== child) {
256
+ dragOverChild.classList.remove('rsui-card-group__card--drag-over')
257
+ }
258
+ child.classList.add('rsui-card-group__card--drag-over')
259
+ dragOverChild = child
260
+ }
261
+
262
+ // Nearest scrollable ancestor of the cards, or null to scroll the window.
263
+ function findScrollContainer(el) {
264
+ let node = el?.parentElement
265
+ while (node && node !== document.body && node !== document.documentElement) {
266
+ const { overflowY } = window.getComputedStyle(node)
267
+ // Only a genuinely scrollable box qualifies as the scroll target.
268
+ if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
269
+ return node
270
+ }
271
+ node = node.parentElement
272
+ }
273
+ return null
274
+ }
275
+
276
+ function updateAutoScroll(clientY) {
277
+ // Edges come from the scroll container's box, or the viewport when scrolling
278
+ // the window.
279
+ const top = scrollContainer ? scrollContainer.getBoundingClientRect().top : 0
280
+ const bottom = scrollContainer ? scrollContainer.getBoundingClientRect().bottom : window.innerHeight
281
+
282
+ let velocity = 0
283
+ if (clientY < top + AUTO_SCROLL_ZONE_PX) {
284
+ const depth = Math.min(1, (top + AUTO_SCROLL_ZONE_PX - clientY) / AUTO_SCROLL_ZONE_PX)
285
+ velocity = -Math.ceil(depth * AUTO_SCROLL_MAX_SPEED_PX)
286
+ } else if (clientY > bottom - AUTO_SCROLL_ZONE_PX) {
287
+ const depth = Math.min(1, (clientY - (bottom - AUTO_SCROLL_ZONE_PX)) / AUTO_SCROLL_ZONE_PX)
288
+ velocity = Math.ceil(depth * AUTO_SCROLL_MAX_SPEED_PX)
289
+ }
290
+
291
+ autoScrollVelocity = velocity
292
+ if (velocity !== 0) {
293
+ startAutoScroll()
294
+ } else {
295
+ stopAutoScroll()
296
+ }
297
+ }
298
+
299
+ function startAutoScroll() {
300
+ if (autoScrollFrame !== null) return
301
+ const step = () => {
302
+ if (autoScrollVelocity === 0 || dragFromIndex.value === null) {
303
+ autoScrollFrame = null
304
+ return
305
+ }
306
+ if (scrollContainer) {
307
+ scrollContainer.scrollTop += autoScrollVelocity
308
+ } else {
309
+ window.scrollBy(0, autoScrollVelocity)
310
+ }
311
+ // Content moved under the stationary finger — re-resolve the drop target.
312
+ updateDropTarget(lastPointerX, lastPointerY)
313
+ autoScrollFrame = requestAnimationFrame(step)
314
+ }
315
+ autoScrollFrame = requestAnimationFrame(step)
316
+ }
317
+
318
+ function stopAutoScroll() {
319
+ if (autoScrollFrame !== null) {
320
+ cancelAnimationFrame(autoScrollFrame)
321
+ autoScrollFrame = null
322
+ }
323
+ autoScrollVelocity = 0
324
+ }
325
+
326
+ function onHandlePointerDown(e) {
327
+ if (!props.reorderable) return
328
+ // Mouse is served by the native DnD engine. Return BEFORE preventDefault or
329
+ // setPointerCapture — either would suppress the browser's native dragstart and
330
+ // break desktop reordering.
331
+ if (e.pointerType === 'mouse') return
332
+ // A drag is already in flight — a second finger on another handle
333
+ // (activePointerId set), or a native mouse drag that already owns dragFromIndex
334
+ // (hybrid device). Ignore the new pointer so the first gesture keeps its state
335
+ // and nothing is left stuck in a grabbed/dragging class.
336
+ if (activePointerId !== null || dragFromIndex.value !== null) return
337
+ const index = getChildIndex(e.target)
338
+ if (index === -1) return
339
+ const child = getCardChild(e.target)
340
+
341
+ activePointerId = e.pointerId
342
+ capturedHandle = e.currentTarget
343
+ dragStartX = e.clientX
344
+ dragStartY = e.clientY
345
+ hasDragMoved = false
346
+ lastPointerX = e.clientX
347
+ lastPointerY = e.clientY
348
+ scrollContainer = findScrollContainer(cardsContainerElement.value)
349
+ dragFromIndex.value = index
350
+ child.classList.add('rsui-card-group__card--grabbed')
351
+
352
+ // Stop the press starting a text selection / page scroll, then keep the handle
353
+ // focused so the keyboard arrow-key path still works after a pointer grab.
354
+ e.preventDefault()
355
+ capturedHandle.focus()
356
+
357
+ // Route all further events for this pointer to the handle so touch and pen
358
+ // behave identically. Guarded for environments without the API.
359
+ if (typeof capturedHandle.setPointerCapture === 'function') {
360
+ capturedHandle.setPointerCapture(e.pointerId)
361
+ }
362
+ }
363
+
364
+ function onHandlePointerMove(e) {
365
+ if (dragFromIndex.value === null || e.pointerId !== activePointerId) return
366
+
367
+ // Wait until the pointer clears a small threshold before treating it as a
368
+ // drag — mirrors native DnD, which only starts after a few px of movement.
369
+ if (!hasDragMoved) {
370
+ const movedX = Math.abs(e.clientX - dragStartX)
371
+ const movedY = Math.abs(e.clientY - dragStartY)
372
+ if (movedX < DRAG_THRESHOLD_PX && movedY < DRAG_THRESHOLD_PX) return
373
+ hasDragMoved = true
374
+ const draggedChild = cardsContainerElement.value?.children[dragFromIndex.value]
375
+ if (draggedChild) draggedChild.classList.add('rsui-card-group__card--dragging')
376
+ }
377
+
378
+ e.preventDefault()
379
+
380
+ lastPointerX = e.clientX
381
+ lastPointerY = e.clientY
382
+ updateDropTarget(e.clientX, e.clientY)
383
+ updateAutoScroll(e.clientY)
384
+ }
385
+
386
+ function onHandlePointerUp(e) {
387
+ if (dragFromIndex.value === null || e.pointerId !== activePointerId) return
388
+ // Drop onto the currently highlighted target — what the user sees. Releasing
389
+ // off any card leaves dragOverChild null, so no reorder fires, matching a
390
+ // native drop outside the list.
391
+ const toIndex = dragOverChild
392
+ ? Array.from(cardsContainerElement.value.children).indexOf(dragOverChild)
393
+ : -1
394
+ if (toIndex !== -1 && toIndex !== dragFromIndex.value) {
395
+ emit('reorder', { fromIndex: dragFromIndex.value, toIndex })
396
+ }
397
+ releasePointerCapture()
398
+ cleanupDragState()
399
+ }
400
+
401
+ function onHandlePointerCancel(e) {
402
+ if (e.pointerId !== activePointerId) return
403
+ releasePointerCapture()
404
+ cleanupDragState()
405
+ }
406
+
407
+ function onHandleLostPointerCapture(e) {
408
+ // Capture can be taken away without a pointerup/pointercancel — an OS-level
409
+ // gesture, or the captured handle being detached mid-drag. Reset so the drag
410
+ // does not strand activePointerId (which would dead-lock every future touch
411
+ // drag on the guard at the top of onHandlePointerDown). No-op once a normal
412
+ // pointerup/cancel has already cleaned up, since activePointerId is then null.
413
+ if (e.pointerId !== activePointerId) return
414
+ cleanupDragState()
415
+ }
416
+
417
+ function releasePointerCapture() {
418
+ if (
419
+ capturedHandle &&
420
+ activePointerId !== null &&
421
+ typeof capturedHandle.hasPointerCapture === 'function' &&
422
+ capturedHandle.hasPointerCapture(activePointerId)
423
+ ) {
424
+ capturedHandle.releasePointerCapture(activePointerId)
425
+ }
426
+ }
427
+
191
428
  function cleanupDragState() {
192
429
  const container = cardsContainerElement.value
193
430
  if (container) {
431
+ // Clear every drag-state class and the native draggable flag in one pass.
432
+ // Only the native mouse path sets draggable, so removeAttribute is a
433
+ // harmless no-op after a touch/pen drag.
194
434
  Array.from(container.children).forEach(child => {
195
435
  child.classList.remove('rsui-card-group__card--drag-over', 'rsui-card-group__card--dragging', 'rsui-card-group__card--grabbed')
196
436
  child.removeAttribute('draggable')
@@ -198,6 +438,13 @@ function cleanupDragState() {
198
438
  }
199
439
  dragFromIndex.value = null
200
440
  dragOverChild = null
441
+ // Pointer-engine state. The native mouse path never sets these, so resetting
442
+ // them here is a no-op for a mouse drag.
443
+ stopAutoScroll()
444
+ scrollContainer = null
445
+ activePointerId = null
446
+ capturedHandle = null
447
+ hasDragMoved = false
201
448
  }
202
449
 
203
450
  function onHandleKeyDown(e) {
@@ -237,6 +484,27 @@ function onHandleKeyDown(e) {
237
484
  }
238
485
  }
239
486
 
487
+ // Every listener a drag handle carries, so add and remove stay in lockstep — the
488
+ // native mouse engine, the touch/pen pointer engine, and the shared keyboard path.
489
+ const handleListeners = [
490
+ ['mousedown', onHandleMouseDown],
491
+ ['mouseup', onHandleMouseUp],
492
+ ['pointerdown', onHandlePointerDown],
493
+ ['pointermove', onHandlePointerMove],
494
+ ['pointerup', onHandlePointerUp],
495
+ ['pointercancel', onHandlePointerCancel],
496
+ ['lostpointercapture', onHandleLostPointerCapture],
497
+ ['keydown', onHandleKeyDown],
498
+ ]
499
+
500
+ function addHandleListeners(handle) {
501
+ handleListeners.forEach(([type, listener]) => handle.addEventListener(type, listener))
502
+ }
503
+
504
+ function removeHandleListeners(handle) {
505
+ handleListeners.forEach(([type, listener]) => handle.removeEventListener(type, listener))
506
+ }
507
+
240
508
  function syncHandles() {
241
509
  const container = cardsContainerElement.value
242
510
  if (!container) return
@@ -249,30 +517,32 @@ function syncHandles() {
249
517
  handle.setAttribute('role', 'button')
250
518
  handle.setAttribute('tabindex', '0')
251
519
  handle.setAttribute('aria-label', 'Reorder')
252
- handle.addEventListener('mousedown', onHandleMouseDown)
253
- handle.addEventListener('mouseup', onHandleMouseUp)
254
- handle.addEventListener('keydown', onHandleKeyDown)
520
+ addHandleListeners(handle)
255
521
  handle.appendChild(createHandleSvg())
256
522
  child.style.position = 'relative'
257
523
  child.prepend(handle)
258
524
  } else if (!props.reorderable && hasHandle) {
259
- hasHandle.removeEventListener('mousedown', onHandleMouseDown)
260
- hasHandle.removeEventListener('mouseup', onHandleMouseUp)
261
- hasHandle.removeEventListener('keydown', onHandleKeyDown)
525
+ removeHandleListeners(hasHandle)
262
526
  hasHandle.remove()
263
527
  }
264
528
  })
265
529
  }
266
530
 
267
531
  function cleanupHandles() {
532
+ // A touch/pen drag may still be in flight when handles are torn down — e.g.
533
+ // reorderable toggled off mid-gesture, or an unmount. Once the handle is
534
+ // removed it can no longer receive pointerup/pointercancel, so release capture
535
+ // and reset drag state here. Otherwise activePointerId stays non-null and the
536
+ // guard at the top of onHandlePointerDown makes every future touch/pen drag
537
+ // early-return, silently killing touch reordering after a re-enable.
538
+ releasePointerCapture()
539
+ cleanupDragState()
268
540
  const container = cardsContainerElement.value
269
541
  if (!container) return
270
542
  Array.from(container.children).forEach(child => {
271
543
  const handle = child.querySelector('.rsui-card-group__drag-handle')
272
544
  if (handle) {
273
- handle.removeEventListener('mousedown', onHandleMouseDown)
274
- handle.removeEventListener('mouseup', onHandleMouseUp)
275
- handle.removeEventListener('keydown', onHandleKeyDown)
545
+ removeHandleListeners(handle)
276
546
  handle.remove()
277
547
  }
278
548
  child.removeAttribute('draggable')
@@ -0,0 +1,295 @@
1
+ <script setup>
2
+ /**
3
+ * InlineEditableText — click-to-edit text that edits in place.
4
+ *
5
+ * - Persistence is delegated to the parent via `@save` — the component owns
6
+ * the interaction + save-state, the parent owns the request.
7
+ * - ALL typography is inherited from context (`font`/`color`/`line-height`),
8
+ * so it renders as a page heading in one place and a card title in another
9
+ * with zero per-consumer config.
10
+ * - Icons come in through slots, so the component has no icon dependency.
11
+ *
12
+ * SAVE CONTRACT — `@save` is called as
13
+ * `save(value, { onSuccess, onError, onFinish })` and the parent MUST call
14
+ * `onFinish` when the request settles: it is the ONLY thing that clears the
15
+ * saving state and makes the field editable again, so a parent that forgets it
16
+ * wedges the field (permanent spinner, no re-edit). In development a
17
+ * `console.warn` fires if `onFinish` hasn't run a few seconds after a save
18
+ * begins. `onSuccess` confirms the write — the component only then emits
19
+ * `update:modelValue`, so the bound value never holds an unsaved edit. `onError`
20
+ * reopens the editor with the typed value preserved for retry, and the parent
21
+ * can surface a failure message through the optional `#error` slot (rendered in
22
+ * an assertive live region).
23
+ *
24
+ * Structural styles live in `@redseed/redseed-ui-tailwindcss`
25
+ * (`components/inline_editable_text.css`, `rsui-inline-editable-text` BEM). The
26
+ * component deliberately sets no typography/colour — those are inherited.
27
+ *
28
+ * @example
29
+ * <InlineEditableText
30
+ * v-model="title"
31
+ * :editable="canEdit"
32
+ * placeholder="Untitled"
33
+ * ariaLabel="Edit title"
34
+ * @save="(value, callbacks) =>
35
+ * router.put(url, { title: value }, { preserveScroll: true, ...callbacks })"
36
+ * >
37
+ * <template #edit-affordance><PencilSquareIcon /></template>
38
+ * <template #saving><SpinnerIcon /></template>
39
+ * <template #saved><CheckIcon /></template>
40
+ * <template #error>{{ form.errors.title }}</template>
41
+ * </InlineEditableText>
42
+ */
43
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
44
+
45
+ const props = defineProps({
46
+ modelValue: {
47
+ type: String,
48
+ default: '',
49
+ },
50
+ // When false the text is read-only: no click-to-edit, no affordance.
51
+ editable: {
52
+ type: Boolean,
53
+ default: true,
54
+ },
55
+ placeholder: {
56
+ type: String,
57
+ default: '',
58
+ },
59
+ ariaLabel: {
60
+ type: String,
61
+ default: 'Edit',
62
+ },
63
+ // Display-only decoration (e.g. a completed item) — never affects editing.
64
+ strikethrough: {
65
+ type: Boolean,
66
+ default: false,
67
+ },
68
+ })
69
+
70
+ const emit = defineEmits(['update:modelValue', 'save'])
71
+
72
+ const isEditing = ref(false)
73
+
74
+ const isSaving = ref(false)
75
+
76
+ const isSaved = ref(false)
77
+
78
+ // Local buffer while editing, so Escape can revert and an onError reopen keeps
79
+ // the typed value on screen. The bound value only changes on a confirmed save
80
+ // (see commit's onSuccess), so it stays untouched while editing, saving, or
81
+ // after a failure — which is what lets a same-value retry re-fire the save.
82
+ const draft = ref(props.modelValue)
83
+
84
+ const inputRef = ref(null)
85
+
86
+ // Id of the 1500ms "saved tick" timer and the dev-only save watchdog, tracked
87
+ // so they can be cleared before a re-arm and on unmount (RSUI cleanup idiom —
88
+ // see Tooltip / CardGroup).
89
+ let savedTimeoutId = null
90
+
91
+ let saveWatchdogId = null
92
+
93
+ // Idle-and-editable — the only state a click opens the editor from. While a save
94
+ // is settling (spinner/tick) the text isn't re-openable, so rapid clicks can't
95
+ // stack saves.
96
+ const canEnterEdit = computed(() => props.editable && !isSaving.value && !isSaved.value)
97
+
98
+ // Show the optimistic draft while saving so the typed value stays on screen
99
+ // through the round-trip; otherwise the committed value.
100
+ const displayValue = computed(() => (isSaving.value ? draft.value : props.modelValue))
101
+
102
+ // Collapse whitespace runs + trim — a sensible default for a title-like field so
103
+ // stored values stay clean. Parents can normalise further in their save handler.
104
+ function normalise(value) {
105
+ return value.replace(/\s+/g, ' ').trim()
106
+ }
107
+
108
+ function startEditing() {
109
+ if (!canEnterEdit.value) {
110
+ return
111
+ }
112
+
113
+ draft.value = props.modelValue
114
+ isEditing.value = true
115
+ }
116
+
117
+ // Focus + select-all whenever the editor opens (first open and onError reopen)
118
+ // so a single click lets the user type over the value immediately.
119
+ watch(isEditing, (editing) => {
120
+ if (editing) {
121
+ nextTick(() => inputRef.value?.select())
122
+ }
123
+ })
124
+
125
+ // A permission revocation mid-edit (editable flips to false) must not be
126
+ // ignored: close the open editor and revert to the bound value.
127
+ watch(() => props.editable, (editable) => {
128
+ if (!editable && isEditing.value) {
129
+ cancel()
130
+ }
131
+ })
132
+
133
+ // Dev-only backstop for the mandatory onFinish contract. The saving state is
134
+ // cleared solely by the parent's onFinish, so a forgetful parent wedges the
135
+ // field. Rather than a production watchdog that would fight legitimately slow
136
+ // saves, mirror ButtonSlot's dev-mode warning idiom and surface the contract
137
+ // violation in development only.
138
+ function startSaveWatchdog() {
139
+ if (process.env.NODE_ENV === 'production') {
140
+ return
141
+ }
142
+
143
+ clearSaveWatchdog()
144
+ saveWatchdogId = window.setTimeout(() => {
145
+ console.warn('[RSUI] InlineEditableText: a save has not called onFinish. The @save(value, { onSuccess, onError, onFinish }) contract requires onFinish to clear the saving state.')
146
+ }, 5000)
147
+ }
148
+
149
+ function clearSaveWatchdog() {
150
+ clearTimeout(saveWatchdogId)
151
+ saveWatchdogId = null
152
+ }
153
+
154
+ function commit() {
155
+ // Re-entrancy guard: pressing Enter commits and closes the editor, and the
156
+ // browser then fires `blur` as the focused textarea unmounts — which would
157
+ // re-invoke commit() and emit a second `save` (a duplicate PUT). Once the
158
+ // editor is closed, a commit is a no-op.
159
+ if (!isEditing.value) {
160
+ return
161
+ }
162
+
163
+ const value = normalise(draft.value)
164
+
165
+ // No-op when nothing changed — opening then leaving must not fire a save.
166
+ if (value === props.modelValue) {
167
+ isEditing.value = false
168
+ return
169
+ }
170
+
171
+ // Close immediately so the display (showing the draft) carries the spinner
172
+ // then tick; the parent drives these via the callbacks below.
173
+ isEditing.value = false
174
+ isSaving.value = true
175
+ startSaveWatchdog()
176
+ emit('save', value, {
177
+ onSuccess: () => {
178
+ // Emit the new bound value ONLY on a confirmed write. Emitting
179
+ // optimistically at commit would leave the parent bound to an
180
+ // unsaved value after a failure, so a same-value retry would hit the
181
+ // no-op guard above and silently drop the edit. `value` is captured
182
+ // in this closure, so no separate optimistic emit is needed.
183
+ emit('update:modelValue', value)
184
+ isSaved.value = true
185
+ clearTimeout(savedTimeoutId)
186
+ savedTimeoutId = window.setTimeout(() => { isSaved.value = false }, 1500)
187
+ },
188
+ onError: () => {
189
+ // Reopen so the typed value survives (rather than silently
190
+ // reverting) and the user can retry. The bound value was never
191
+ // updated, so re-committing the same text fires a fresh save. The
192
+ // parent can surface the reason via the #error slot.
193
+ draft.value = value
194
+ isEditing.value = true
195
+ },
196
+ onFinish: () => {
197
+ clearSaveWatchdog()
198
+ isSaving.value = false
199
+ },
200
+ })
201
+ }
202
+
203
+ function cancel() {
204
+ draft.value = props.modelValue
205
+ isEditing.value = false
206
+ }
207
+
208
+ onBeforeUnmount(() => {
209
+ clearTimeout(savedTimeoutId)
210
+ clearSaveWatchdog()
211
+ })
212
+ </script>
213
+
214
+ <template>
215
+ <!-- Collapsed control. When editable it is exposed as a real interactive
216
+ control for keyboard + screen-reader users: role="button" + tabindex +
217
+ Enter/Space + aria-label. A span (not a native <button>) is used
218
+ deliberately — the component MUST keep flowing inline and inheriting
219
+ typography, which a <button> would reset/box; this is the documented
220
+ RSUI fallback for that constraint. Attributes are gated on canEnterEdit
221
+ so a read-only or mid-save field is inert (no focus, no role). -->
222
+ <span
223
+ v-if="!isEditing"
224
+ class="rsui-inline-editable-text"
225
+ :class="{ 'rsui-inline-editable-text--editable': canEnterEdit }"
226
+ :role="canEnterEdit ? 'button' : undefined"
227
+ :tabindex="canEnterEdit ? 0 : undefined"
228
+ :aria-label="canEnterEdit ? ariaLabel : undefined"
229
+ @click="startEditing"
230
+ @keydown.enter.prevent="startEditing"
231
+ @keydown.space.prevent="startEditing"
232
+ >
233
+ <span
234
+ class="rsui-inline-editable-text__value"
235
+ :class="{ 'rsui-inline-editable-text__value--placeholder': !displayValue }"
236
+ ><s v-if="strikethrough">{{ displayValue || placeholder }}</s><template v-if="!strikethrough">{{ displayValue || placeholder }}</template></span>
237
+
238
+ <!-- Reserved-width adornment (min-width floor) so the affordance/spinner/
239
+ tick swapping never shifts the text. The three states are mutually
240
+ exclusive; each branch carries its own explicit condition (no
241
+ v-else) per house style. -->
242
+ <span
243
+ class="rsui-inline-editable-text__adornment"
244
+ aria-hidden="true"
245
+ >
246
+ <span v-if="isSaving">
247
+ <slot name="saving" />
248
+ </span>
249
+
250
+ <span v-if="!isSaving && isSaved">
251
+ <slot name="saved" />
252
+ </span>
253
+
254
+ <span
255
+ v-if="!isSaving && !isSaved && canEnterEdit"
256
+ class="rsui-inline-editable-text__affordance"
257
+ >
258
+ <slot name="edit-affordance" />
259
+ </span>
260
+ </span>
261
+ </span>
262
+
263
+ <!-- Auto-grow: an inline-grid sizer whose hidden ::after mirror carries the
264
+ value drives the width; the textarea fills the same cell. -->
265
+ <span
266
+ v-if="isEditing"
267
+ class="rsui-inline-editable-text__sizer"
268
+ :data-value="draft || placeholder"
269
+ >
270
+ <textarea
271
+ ref="inputRef"
272
+ v-model="draft"
273
+ rows="1"
274
+ :placeholder="placeholder"
275
+ :aria-label="ariaLabel"
276
+ class="rsui-inline-editable-text__input"
277
+ @blur="commit"
278
+ @keydown.enter.prevent="commit"
279
+ @keydown.esc.prevent="cancel"
280
+ ></textarea>
281
+ </span>
282
+
283
+ <!-- Optional parent-surfaced failure message (e.g. set in @save's onError,
284
+ cleared on success). Rendered only when the parent populates the slot,
285
+ in an assertive live region so screen-reader users get the failure that
286
+ the aria-hidden adornment can't convey. Mirrors the RSUI form-field
287
+ error pattern (FormFieldSlot). -->
288
+ <span
289
+ v-if="$slots.error"
290
+ class="rsui-inline-editable-text__error"
291
+ role="alert"
292
+ >
293
+ <slot name="error" />
294
+ </span>
295
+ </template>
@@ -0,0 +1,5 @@
1
+ import InlineEditableText from './InlineEditableText.vue'
2
+
3
+ export {
4
+ InlineEditableText,
5
+ }