Redesign ticket details with inline editing
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
CircleAlert,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import {
|
||||
addAcceptanceCriterionAfter,
|
||||
indentAcceptanceCriterionSubtree,
|
||||
moveAcceptanceCriterionSubtree,
|
||||
nextAcceptanceCriteriaSiblingIndex,
|
||||
outdentAcceptanceCriterionSubtree,
|
||||
previousAcceptanceCriteriaSiblingIndex,
|
||||
removeAcceptanceCriterion,
|
||||
syncAcceptanceCriteriaDescendantCheckedState,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
AcceptanceCriteriaEditorEmits,
|
||||
AcceptanceCriteriaEditorProps,
|
||||
} from '@/types';
|
||||
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||
|
||||
const props = withDefaults(defineProps<AcceptanceCriteriaEditorProps>(), {
|
||||
disabled: false,
|
||||
error: '',
|
||||
saveState: 'idle',
|
||||
});
|
||||
const emit = defineEmits<AcceptanceCriteriaEditorEmits>();
|
||||
|
||||
const editorRoot = ref<HTMLElement | null>(null);
|
||||
const editingIndex = ref<number | null>(null);
|
||||
const originalText = ref<{ index: number; text: string } | null>(null);
|
||||
const skippedBlurIndex = ref<number | null>(null);
|
||||
|
||||
const emitUpdate = (items: AcceptanceCriteriaModel): void => {
|
||||
emit('update:modelValue', items);
|
||||
};
|
||||
|
||||
const emitCommit = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
source: 'check' | 'delete' | 'indent' | 'move' | 'text',
|
||||
): void => {
|
||||
emitUpdate(items);
|
||||
emit('commit', { items, source });
|
||||
};
|
||||
|
||||
const focusRow = (index: number): void => {
|
||||
editingIndex.value = index;
|
||||
|
||||
void nextTick(() => {
|
||||
editorRoot.value
|
||||
?.querySelector<HTMLInputElement>(`[data-ac-row-index="${index}"]`)
|
||||
?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const startTextEdit = (index: number): void => {
|
||||
if (!props.editable || props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
rememberText(index);
|
||||
focusRow(index);
|
||||
};
|
||||
|
||||
const addCriterion = (index: number | null): void => {
|
||||
if (!props.editable || props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyCriterionIndex = props.modelValue.findIndex(
|
||||
(criterion) => criterion.text.trim() === '',
|
||||
);
|
||||
|
||||
if (emptyCriterionIndex !== -1) {
|
||||
focusRow(emptyCriterionIndex);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = addAcceptanceCriterionAfter(props.modelValue, index);
|
||||
|
||||
emitUpdate(result.items);
|
||||
focusRow(result.index);
|
||||
};
|
||||
|
||||
const updateText = (index: number, event: Event): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = props.modelValue.map((item, itemIndex) => ({
|
||||
...item,
|
||||
text: itemIndex === index ? target.value : item.text,
|
||||
}));
|
||||
|
||||
emitUpdate(items);
|
||||
};
|
||||
|
||||
const rememberText = (index: number): void => {
|
||||
originalText.value = {
|
||||
index,
|
||||
text: props.modelValue[index]?.text ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
const commitText = (index: number): void => {
|
||||
if (skippedBlurIndex.value === index) {
|
||||
skippedBlurIndex.value = null;
|
||||
editingIndex.value = null;
|
||||
originalText.value = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const item = props.modelValue[index];
|
||||
|
||||
if (item === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedText = item.text.trim();
|
||||
|
||||
if (trimmedText === '') {
|
||||
const items = removeAcceptanceCriterion(props.modelValue, index);
|
||||
|
||||
if (item.id === undefined) {
|
||||
emitUpdate(items);
|
||||
} else {
|
||||
emitCommit(items, 'delete');
|
||||
}
|
||||
|
||||
originalText.value = null;
|
||||
editingIndex.value = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const items = props.modelValue.map((criterion, criterionIndex) => ({
|
||||
...criterion,
|
||||
text: criterionIndex === index ? trimmedText : criterion.text,
|
||||
}));
|
||||
const previousText =
|
||||
originalText.value?.index === index
|
||||
? originalText.value.text
|
||||
: item.text;
|
||||
|
||||
if (trimmedText !== previousText || props.error !== '') {
|
||||
emitCommit(items, 'text');
|
||||
} else if (trimmedText !== item.text) {
|
||||
emitUpdate(items);
|
||||
}
|
||||
|
||||
originalText.value = null;
|
||||
editingIndex.value = null;
|
||||
};
|
||||
|
||||
const cancelTextEdit = (index: number, event: KeyboardEvent): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackText =
|
||||
originalText.value?.index === index ? originalText.value.text : '';
|
||||
const items = props.modelValue.map((item, itemIndex) => ({
|
||||
...item,
|
||||
text: itemIndex === index ? fallbackText : item.text,
|
||||
}));
|
||||
|
||||
skippedBlurIndex.value = index;
|
||||
emitUpdate(items);
|
||||
target.blur();
|
||||
};
|
||||
|
||||
const insertAfterCurrent = (index: number, event: KeyboardEvent): void => {
|
||||
event.preventDefault();
|
||||
const item = props.modelValue[index];
|
||||
const trimmedText = item?.text.trim() ?? '';
|
||||
|
||||
if (item === undefined || trimmedText === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const committedItems = props.modelValue.map(
|
||||
(criterion, criterionIndex) => ({
|
||||
...criterion,
|
||||
text: criterionIndex === index ? trimmedText : criterion.text,
|
||||
}),
|
||||
);
|
||||
const result = addAcceptanceCriterionAfter(committedItems, index);
|
||||
|
||||
originalText.value = null;
|
||||
emitUpdate(result.items);
|
||||
emit('commit', { items: committedItems, source: 'text' });
|
||||
focusRow(result.index);
|
||||
};
|
||||
|
||||
const toggleCriterion = (index: number, event: Event): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitCommit(
|
||||
syncAcceptanceCriteriaDescendantCheckedState(
|
||||
props.modelValue,
|
||||
index,
|
||||
target.checked,
|
||||
),
|
||||
'check',
|
||||
);
|
||||
};
|
||||
|
||||
const indentCriterion = (index: number): void => {
|
||||
emitCommit(
|
||||
indentAcceptanceCriterionSubtree(props.modelValue, index),
|
||||
'indent',
|
||||
);
|
||||
};
|
||||
|
||||
const outdentCriterion = (index: number): void => {
|
||||
emitCommit(
|
||||
outdentAcceptanceCriterionSubtree(props.modelValue, index),
|
||||
'indent',
|
||||
);
|
||||
};
|
||||
|
||||
const moveCriterion = (index: number, direction: 'down' | 'up'): void => {
|
||||
emitCommit(
|
||||
moveAcceptanceCriterionSubtree(props.modelValue, index, direction),
|
||||
'move',
|
||||
);
|
||||
};
|
||||
|
||||
const deleteCriterion = (index: number): void => {
|
||||
emitCommit(removeAcceptanceCriterion(props.modelValue, index), 'delete');
|
||||
};
|
||||
|
||||
const handleIndentKeyboard = (index: number, event: KeyboardEvent): void => {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
outdentCriterion(index);
|
||||
} else {
|
||||
indentCriterion(index);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorRoot" class="space-y-3">
|
||||
<div
|
||||
v-if="modelValue.length === 0"
|
||||
class="border-outline-variant bg-surface-container-low flex min-h-28 flex-col items-center justify-center gap-3 rounded border border-dashed px-4 py-6 text-center"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
No acceptance criteria have been added yet.
|
||||
</p>
|
||||
<Button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
@click="addCriterion(null)"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
Add criterion
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ol v-else class="space-y-1.5">
|
||||
<li
|
||||
v-for="(item, index) in modelValue"
|
||||
:key="item.id ?? `draft-${index}`"
|
||||
class="group/ac-row"
|
||||
:style="{ paddingLeft: `${item.indent * 1.1}rem` }"
|
||||
>
|
||||
<div
|
||||
class="border-outline-variant/80 bg-background hover:border-primary/35 focus-within:border-primary/50 focus-within:bg-primary/[0.025] flex min-h-10 flex-wrap items-start gap-2 rounded border px-2.5 py-2 transition sm:flex-nowrap"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.checked"
|
||||
:disabled="!editable || disabled"
|
||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 shrink-0 rounded"
|
||||
:aria-label="`Mark ${item.text || 'criterion'} as complete`"
|
||||
@change="toggleCriterion(index, $event)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="editable && editingIndex === index"
|
||||
:data-ac-row-index="index"
|
||||
:value="item.text"
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
class="placeholder:text-muted-foreground/70 min-w-0 flex-1 border-0 bg-transparent p-0 text-sm leading-5 outline-none disabled:cursor-not-allowed"
|
||||
placeholder="Describe a verifiable outcome"
|
||||
@input="updateText(index, $event)"
|
||||
@blur="commitText(index)"
|
||||
@keydown.enter="insertAfterCurrent(index, $event)"
|
||||
@keydown.esc="cancelTextEdit(index, $event)"
|
||||
@keydown.tab="handleIndentKeyboard(index, $event)"
|
||||
/>
|
||||
<button
|
||||
v-else-if="editable"
|
||||
type="button"
|
||||
class="placeholder:text-muted-foreground/70 hover:text-primary focus-visible:ring-ring min-w-0 flex-1 whitespace-normal rounded text-left text-sm leading-5 outline-none focus-visible:ring-2"
|
||||
:disabled="disabled"
|
||||
@click="startTextEdit(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
item.checked
|
||||
? 'text-muted-foreground line-through'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ item.text || 'Describe a verifiable outcome' }}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 flex-1 text-sm leading-5"
|
||||
:class="
|
||||
item.checked
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="editable"
|
||||
class="flex w-full shrink-0 items-center justify-end gap-0.5 pl-6 opacity-100 transition sm:w-auto sm:pl-0 sm:opacity-0 sm:group-focus-within/ac-row:opacity-100 sm:group-hover/ac-row:opacity-100"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="disabled"
|
||||
aria-label="Add criterion below"
|
||||
title="Add below"
|
||||
@mousedown.prevent
|
||||
@click="addCriterion(index)"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="disabled || item.indent === 0"
|
||||
aria-label="Outdent criterion"
|
||||
title="Outdent (Shift+Tab)"
|
||||
@mousedown.prevent
|
||||
@click="outdentCriterion(index)"
|
||||
>
|
||||
<IndentDecrease class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="
|
||||
disabled ||
|
||||
previousAcceptanceCriteriaSiblingIndex(
|
||||
modelValue,
|
||||
index,
|
||||
) === null
|
||||
"
|
||||
aria-label="Indent criterion"
|
||||
title="Indent (Tab)"
|
||||
@mousedown.prevent
|
||||
@click="indentCriterion(index)"
|
||||
>
|
||||
<IndentIncrease class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="
|
||||
disabled ||
|
||||
previousAcceptanceCriteriaSiblingIndex(
|
||||
modelValue,
|
||||
index,
|
||||
) === null
|
||||
"
|
||||
aria-label="Move criterion up"
|
||||
title="Move up"
|
||||
@mousedown.prevent
|
||||
@click="moveCriterion(index, 'up')"
|
||||
>
|
||||
<ArrowUp class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="
|
||||
disabled ||
|
||||
nextAcceptanceCriteriaSiblingIndex(
|
||||
modelValue,
|
||||
index,
|
||||
) === null
|
||||
"
|
||||
aria-label="Move criterion down"
|
||||
title="Move down"
|
||||
@mousedown.prevent
|
||||
@click="moveCriterion(index, 'down')"
|
||||
>
|
||||
<ArrowDown class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive hover:text-destructive size-6"
|
||||
:disabled="disabled"
|
||||
aria-label="Delete criterion"
|
||||
title="Delete"
|
||||
@mousedown.prevent
|
||||
@click="deleteCriterion(index)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="flex min-h-5 items-center justify-between gap-3">
|
||||
<Button
|
||||
v-if="editable && modelValue.length > 0"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="disabled"
|
||||
@click="addCriterion(null)"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
Add criterion
|
||||
</Button>
|
||||
<span v-else />
|
||||
|
||||
<span
|
||||
v-if="saveState === 'saving'"
|
||||
class="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<LoaderCircle class="size-3.5 animate-spin" />
|
||||
Saving
|
||||
</span>
|
||||
<span
|
||||
v-else-if="saveState === 'saved'"
|
||||
class="text-primary flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Check class="size-3.5" />
|
||||
Saved
|
||||
</span>
|
||||
<span
|
||||
v-else-if="saveState === 'error' || error"
|
||||
class="text-destructive flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<CircleAlert class="size-3.5" />
|
||||
{{ error || 'Could not save changes.' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, CircleAlert, LoaderCircle } from 'lucide-vue-next';
|
||||
import type { InlineSaveStatusProps } from '@/types';
|
||||
|
||||
withDefaults(defineProps<InlineSaveStatusProps>(), {
|
||||
error: '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="state !== 'idle' || error"
|
||||
class="flex items-center gap-1.5 pt-1 text-[11px]"
|
||||
>
|
||||
<template v-if="state === 'saving'">
|
||||
<LoaderCircle class="text-muted-foreground size-3 animate-spin" />
|
||||
<span class="text-muted-foreground">Saving</span>
|
||||
</template>
|
||||
<template v-else-if="state === 'saved'">
|
||||
<Check class="text-primary size-3" />
|
||||
<span class="text-primary">Saved</span>
|
||||
</template>
|
||||
<template v-else-if="state === 'error' || error">
|
||||
<CircleAlert class="text-destructive size-3" />
|
||||
<span class="text-destructive">{{
|
||||
error || 'Could not save.'
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, CircleAlert, LoaderCircle, Pencil } from 'lucide-vue-next';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import type {
|
||||
InlineTextEditorEmits,
|
||||
InlineTextEditorProps,
|
||||
InlineTextEditorValue,
|
||||
} from '@/types';
|
||||
|
||||
const props = withDefaults(defineProps<InlineTextEditorProps>(), {
|
||||
disabled: false,
|
||||
error: '',
|
||||
inputType: 'text',
|
||||
multiline: false,
|
||||
saveState: 'idle',
|
||||
variant: 'body',
|
||||
});
|
||||
const emit = defineEmits<InlineTextEditorEmits>();
|
||||
|
||||
const isEditing = ref(false);
|
||||
const originalValue = ref<InlineTextEditorValue>(null);
|
||||
const skipNextBlur = ref(false);
|
||||
const editor = ref<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||
|
||||
const startEditing = (): void => {
|
||||
if (!props.editable || props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
originalValue.value = props.value;
|
||||
isEditing.value = true;
|
||||
|
||||
void nextTick(() => {
|
||||
editor.value?.focus();
|
||||
|
||||
if (editor.value instanceof HTMLInputElement) {
|
||||
editor.value.select();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateValue = (event: Event): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (
|
||||
!(target instanceof HTMLInputElement) &&
|
||||
!(target instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:value', target.value);
|
||||
};
|
||||
|
||||
const finishEditing = (): void => {
|
||||
if (skipNextBlur.value) {
|
||||
skipNextBlur.value = false;
|
||||
isEditing.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = false;
|
||||
|
||||
if (props.value !== originalValue.value || props.error !== '') {
|
||||
emit('commit');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEditing = (): void => {
|
||||
skipNextBlur.value = true;
|
||||
emit('update:value', originalValue.value);
|
||||
editor.value?.blur();
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEditing();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!props.multiline && event.key === 'Enter') ||
|
||||
(props.multiline &&
|
||||
event.key === 'Enter' &&
|
||||
(event.metaKey || event.ctrlKey))
|
||||
) {
|
||||
event.preventDefault();
|
||||
editor.value?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = (): string => {
|
||||
if (props.value === null || props.value === '') {
|
||||
return props.placeholder;
|
||||
}
|
||||
|
||||
return String(props.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div v-if="isEditing" class="space-y-1.5">
|
||||
<textarea
|
||||
v-if="multiline"
|
||||
ref="editor"
|
||||
:value="value ?? ''"
|
||||
rows="5"
|
||||
:disabled="disabled"
|
||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-32 w-full resize-y rounded border px-3 py-2 text-sm leading-6 outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@input="updateValue"
|
||||
@blur="finishEditing"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
ref="editor"
|
||||
:value="value ?? ''"
|
||||
:type="inputType"
|
||||
:disabled="disabled"
|
||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-3 py-2 text-sm outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@input="updateValue"
|
||||
@blur="finishEditing"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<p class="text-muted-foreground text-[11px]">
|
||||
{{
|
||||
multiline
|
||||
? 'Cmd/Ctrl+Enter to save · Escape to cancel'
|
||||
: 'Enter to save · Escape to cancel'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="editable"
|
||||
type="button"
|
||||
class="group/inline hover:bg-primary/5 focus-visible:ring-ring relative -m-1 flex w-[calc(100%+0.5rem)] min-w-0 items-start gap-2 rounded p-1 text-left outline-none transition focus-visible:ring-2"
|
||||
:disabled="disabled"
|
||||
@click="startEditing"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 whitespace-pre-wrap"
|
||||
:class="[
|
||||
variant === 'title'
|
||||
? 'text-2xl font-semibold leading-tight tracking-tight sm:text-[1.75rem]'
|
||||
: variant === 'property'
|
||||
? 'text-sm font-medium'
|
||||
: 'text-sm leading-6',
|
||||
value === null || value === ''
|
||||
? 'text-muted-foreground italic'
|
||||
: 'text-foreground',
|
||||
]"
|
||||
>
|
||||
{{ displayValue() }}
|
||||
</span>
|
||||
<Pencil
|
||||
class="text-muted-foreground mt-0.5 size-3.5 shrink-0 opacity-0 transition group-hover/inline:opacity-100 group-focus-visible/inline:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-else>
|
||||
<p
|
||||
class="whitespace-pre-wrap"
|
||||
:class="[
|
||||
variant === 'title'
|
||||
? 'text-2xl font-semibold leading-tight tracking-tight sm:text-[1.75rem]'
|
||||
: variant === 'property'
|
||||
? 'text-sm font-medium'
|
||||
: 'text-sm leading-6',
|
||||
value === null || value === ''
|
||||
? 'text-muted-foreground italic'
|
||||
: 'text-foreground',
|
||||
]"
|
||||
>
|
||||
{{ displayValue() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="saveState !== 'idle' || error"
|
||||
class="mt-1 flex items-center gap-1.5 text-[11px]"
|
||||
>
|
||||
<template v-if="saveState === 'saving'">
|
||||
<LoaderCircle
|
||||
class="text-muted-foreground size-3 animate-spin"
|
||||
/>
|
||||
<span class="text-muted-foreground">Saving</span>
|
||||
</template>
|
||||
<template v-else-if="saveState === 'saved'">
|
||||
<Check class="text-primary size-3" />
|
||||
<span class="text-primary">Saved</span>
|
||||
</template>
|
||||
<template v-else-if="saveState === 'error' || error">
|
||||
<CircleAlert class="text-destructive size-3" />
|
||||
<span class="text-destructive">{{
|
||||
error || 'Could not save.'
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user