@redseed/redseed-ui-vue3 8.52.1 → 8.54.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'
|
|
@@ -35,6 +36,7 @@ export * from './src/components/MessageBox'
|
|
|
35
36
|
export * from './src/components/MetaInfo'
|
|
36
37
|
export * from './src/components/Modal'
|
|
37
38
|
export * from './src/components/Pagination'
|
|
39
|
+
export * from './src/components/Pill'
|
|
38
40
|
export * from './src/components/Progress'
|
|
39
41
|
export * from './src/components/Section'
|
|
40
42
|
export * from './src/components/Skeleton'
|
package/package.json
CHANGED
|
@@ -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,103 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, onMounted, watchEffect } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
selected: {
|
|
6
|
+
type: Boolean,
|
|
7
|
+
default: false,
|
|
8
|
+
},
|
|
9
|
+
disabled: {
|
|
10
|
+
type: Boolean,
|
|
11
|
+
default: false,
|
|
12
|
+
},
|
|
13
|
+
// The name of a RUNTIME CSS custom property to use as the accent — applied as
|
|
14
|
+
// `var(--<color>, <neutral>)`, driving the icon-badge fill and the selected
|
|
15
|
+
// ring/tint. Pass a real runtime theme variable, e.g. the consuming app's
|
|
16
|
+
// palette (`color7` → `var(--color7)`). NOTE: RSUI's own Tailwind `@theme`
|
|
17
|
+
// color tokens (e.g. `--color-brand-500`) are inlined into utilities, not
|
|
18
|
+
// exposed as runtime vars, so they will NOT resolve here. An UNRESOLVABLE
|
|
19
|
+
// (undefined) token degrades to a neutral grey rather than an invisible pill;
|
|
20
|
+
// a token that *resolves* to `transparent`/empty is not caught — passing a
|
|
21
|
+
// real colour is the consumer's responsibility. Optional — falls back to a
|
|
22
|
+
// neutral accent when omitted, so RSUI stays theme-agnostic.
|
|
23
|
+
color: {
|
|
24
|
+
type: String,
|
|
25
|
+
default: '',
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits(['click'])
|
|
30
|
+
|
|
31
|
+
// The single accent token colours both the icon badge and the selected
|
|
32
|
+
// treatment. When no color is supplied we leave the property unset so the CSS
|
|
33
|
+
// neutral fallback applies. When a color IS supplied it carries an inline
|
|
34
|
+
// fallback to the neutral grey, so an UNRESOLVABLE (undefined) token degrades to
|
|
35
|
+
// grey rather than an invisible badge/selection. Note: var()'s fallback does not
|
|
36
|
+
// fire for a token that *resolves* to transparent/empty — passing a real colour
|
|
37
|
+
// is the consumer's responsibility.
|
|
38
|
+
const accentStyle = computed(() =>
|
|
39
|
+
props.color
|
|
40
|
+
? { '--rsui-pill-accent': `var(--${props.color}, var(--Colors-Grey-500))` }
|
|
41
|
+
: {},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const pillClass = computed(() => [
|
|
45
|
+
'rsui-pill',
|
|
46
|
+
{
|
|
47
|
+
'rsui-pill--selected': props.selected && !props.disabled,
|
|
48
|
+
'rsui-pill--disabled': props.disabled,
|
|
49
|
+
},
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
function handleClick(event) {
|
|
53
|
+
// Guarded — a disabled pill emits nothing even if a click reaches it.
|
|
54
|
+
if (props.disabled) return
|
|
55
|
+
|
|
56
|
+
emit('click', event)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// button element ref, used for the icon-only accessibility check
|
|
60
|
+
const pillElementRef = ref(null)
|
|
61
|
+
|
|
62
|
+
// an icon-only pill has no visible label text
|
|
63
|
+
const iconOnly = computed(
|
|
64
|
+
() => pillElementRef.value && !pillElementRef.value.textContent.trim(),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// warn in development when an icon-only pill lacks an accessible name
|
|
68
|
+
onMounted(() => {
|
|
69
|
+
watchEffect(() => {
|
|
70
|
+
if (
|
|
71
|
+
process.env.NODE_ENV !== 'production' &&
|
|
72
|
+
iconOnly.value &&
|
|
73
|
+
!pillElementRef.value.getAttribute('aria-label') &&
|
|
74
|
+
!pillElementRef.value.getAttribute('aria-labelledby')
|
|
75
|
+
) {
|
|
76
|
+
console.warn(
|
|
77
|
+
'[RSUI] Icon-only Pill detected without aria-label. Add aria-label for accessibility.',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
defineExpose({
|
|
84
|
+
pillElementRef,
|
|
85
|
+
})
|
|
86
|
+
</script>
|
|
87
|
+
<template>
|
|
88
|
+
<button
|
|
89
|
+
ref="pillElementRef"
|
|
90
|
+
:class="pillClass"
|
|
91
|
+
:style="accentStyle"
|
|
92
|
+
:disabled="props.disabled"
|
|
93
|
+
:aria-pressed="props.selected && !props.disabled"
|
|
94
|
+
type="button"
|
|
95
|
+
@click="handleClick"
|
|
96
|
+
>
|
|
97
|
+
<span v-if="$slots.icon" class="rsui-pill__icon" aria-hidden="true">
|
|
98
|
+
<slot name="icon"></slot>
|
|
99
|
+
</span>
|
|
100
|
+
|
|
101
|
+
<slot></slot>
|
|
102
|
+
</button>
|
|
103
|
+
</template>
|