Files
KanbanGit/resources/js/components/projects/InlineTextEditor.vue
T
Twyco 8c70d094b8
linter / quality (pull_request) Successful in 1m23s
tests / ci (8.4) (pull_request) Successful in 1m49s
Redesign ticket details with inline editing
2026-07-18 19:50:50 +02:00

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>