@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
|
@@ -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
|
|
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
|
|
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
|
|
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>
|