Redesign ticket details with inline editing
This commit is contained in:
@@ -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