206 lines
6.4 KiB
Vue
206 lines
6.4 KiB
Vue
<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>
|