321 lines
12 KiB
Vue
321 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ClipboardCheck,
|
|
IndentDecrease,
|
|
IndentIncrease,
|
|
Plus,
|
|
Trash2,
|
|
} from 'lucide-vue-next';
|
|
import { computed, nextTick, ref, useId, watch } from 'vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import InputError from '@/components/InputError.vue';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import {
|
|
canIndentAcceptanceCriterion,
|
|
indentAcceptanceCriterion,
|
|
removeAcceptanceCriterion,
|
|
syncAcceptanceCriteriaDescendantCheckedState,
|
|
} from '@/lib/acceptance-criteria';
|
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: AcceptanceCriteriaModel;
|
|
readonly?: boolean;
|
|
disabled?: boolean;
|
|
persistToggles?: boolean;
|
|
allowEditing?: boolean;
|
|
saving?: boolean;
|
|
dirty?: boolean;
|
|
error?: string;
|
|
}>(),
|
|
{
|
|
readonly: false,
|
|
disabled: false,
|
|
persistToggles: false,
|
|
allowEditing: true,
|
|
saving: undefined,
|
|
dirty: false,
|
|
error: '',
|
|
},
|
|
);
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [items: AcceptanceCriteriaModel];
|
|
toggle: [index: number, checked: boolean];
|
|
}>();
|
|
const { t } = useLocalization();
|
|
const id = useId();
|
|
const root = ref<HTMLElement>();
|
|
const editing = ref(!props.persistToggles);
|
|
const completed = computed(
|
|
() => props.modelValue.filter((item) => item.checked).length,
|
|
);
|
|
const locked = computed(() => props.readonly || props.disabled);
|
|
const update = (items: AcceptanceCriteriaModel): void => {
|
|
if (!locked.value) {
|
|
emit('update:modelValue', items);
|
|
}
|
|
};
|
|
const focusRow = async (index: number): Promise<void> => {
|
|
await nextTick();
|
|
const inputs =
|
|
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
|
|
if (inputs?.[index]) {
|
|
inputs[index].focus();
|
|
} else {
|
|
root.value
|
|
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
|
|
?.focus();
|
|
}
|
|
};
|
|
const add = (): void => {
|
|
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
|
|
void focusRow(props.modelValue.length);
|
|
};
|
|
const remove = (index: number): void => {
|
|
update(removeAcceptanceCriterion(props.modelValue, index));
|
|
void focusRow(Math.max(0, index - 1));
|
|
};
|
|
watch(
|
|
() => props.modelValue,
|
|
async () => {
|
|
await nextTick();
|
|
root.value
|
|
?.querySelectorAll<HTMLTextAreaElement>('textarea')
|
|
.forEach((input) => {
|
|
input.setCustomValidity(
|
|
input.value.trim() === '' ? t('criteria.required') : '',
|
|
);
|
|
});
|
|
},
|
|
);
|
|
const changeText = (index: number, event: Event): void => {
|
|
const text = (event.target as HTMLTextAreaElement).value;
|
|
update(
|
|
props.modelValue.map((item, position) =>
|
|
position === index ? { ...item, text } : item,
|
|
),
|
|
);
|
|
};
|
|
const toggle = (index: number, event: Event): void => {
|
|
const target = event.target as HTMLInputElement;
|
|
const checked = target.checked;
|
|
target.checked = props.modelValue[index].checked;
|
|
if (locked.value) {
|
|
return;
|
|
}
|
|
if (props.persistToggles && !editing.value && !props.dirty) {
|
|
emit('toggle', index, checked);
|
|
} else {
|
|
update(
|
|
syncAcceptanceCriteriaDescendantCheckedState(
|
|
props.modelValue,
|
|
index,
|
|
checked,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
const onKeydown = (index: number, event: KeyboardEvent): void => {
|
|
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
update(
|
|
indentAcceptanceCriterion(
|
|
props.modelValue,
|
|
index,
|
|
event.key === 'ArrowRight' ? 1 : -1,
|
|
),
|
|
);
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<section
|
|
ref="root"
|
|
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
|
|
:aria-labelledby="`${id}-title`"
|
|
:aria-busy="disabled"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<h3
|
|
:id="`${id}-title`"
|
|
class="flex items-center gap-2 font-semibold"
|
|
>
|
|
<ClipboardCheck
|
|
class="text-primary size-5"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ t('criteria.title') }}
|
|
</h3>
|
|
<span class="text-muted-foreground text-sm" aria-live="polite">{{
|
|
t('criteria.progress', { completed, total: modelValue.length })
|
|
}}</span>
|
|
<Button
|
|
v-if="persistToggles && allowEditing && !readonly"
|
|
type="button"
|
|
variant="outline"
|
|
:disabled="
|
|
disabled ||
|
|
(editing &&
|
|
modelValue.some((item) => item.text.trim() === ''))
|
|
"
|
|
:aria-pressed="editing"
|
|
@click="editing = !editing"
|
|
>
|
|
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
|
|
</Button>
|
|
</div>
|
|
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
|
|
{{ t('criteria.empty') }}
|
|
</p>
|
|
<ol v-else class="space-y-3">
|
|
<li
|
|
v-for="(item, index) in modelValue"
|
|
:key="item.id ?? `new-${index}`"
|
|
:data-indent="item.indent"
|
|
class="border-border min-w-0"
|
|
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
|
|
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
|
|
>
|
|
<div class="flex min-w-0 items-start gap-2">
|
|
<label
|
|
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
|
|
:class="{ 'cursor-not-allowed opacity-60': locked }"
|
|
>
|
|
<input
|
|
:checked="item.checked"
|
|
type="checkbox"
|
|
:disabled="locked"
|
|
:aria-label="
|
|
t('criteria.complete', {
|
|
number: index + 1,
|
|
text: item.text,
|
|
})
|
|
"
|
|
class="border-input accent-primary size-5 rounded"
|
|
@change="toggle(index, $event)"
|
|
/>
|
|
</label>
|
|
<div class="min-w-0 flex-1">
|
|
<textarea
|
|
v-if="editing && !readonly"
|
|
:value="item.text"
|
|
:aria-label="
|
|
t('criteria.item', { number: index + 1 })
|
|
"
|
|
:aria-describedby="`${id}-hint ${id}-error`"
|
|
:disabled="disabled"
|
|
:aria-invalid="Boolean(error)"
|
|
required
|
|
rows="2"
|
|
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
|
|
@input="changeText(index, $event)"
|
|
@keydown="onKeydown(index, $event)"
|
|
/>
|
|
<p
|
|
v-else
|
|
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
|
|
:class="{
|
|
'text-muted-foreground line-through':
|
|
item.checked,
|
|
}"
|
|
>
|
|
{{ item.text }}
|
|
</p>
|
|
<div
|
|
v-if="editing && !readonly"
|
|
class="flex flex-wrap gap-1"
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
:disabled="disabled || item.indent === 0"
|
|
:aria-label="
|
|
t('criteria.outdent', { number: index + 1 })
|
|
"
|
|
@click="
|
|
update(
|
|
indentAcceptanceCriterion(
|
|
modelValue,
|
|
index,
|
|
-1,
|
|
),
|
|
)
|
|
"
|
|
><IndentDecrease aria-hidden="true"
|
|
/></Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
:disabled="
|
|
disabled ||
|
|
!canIndentAcceptanceCriterion(
|
|
modelValue,
|
|
index,
|
|
)
|
|
"
|
|
:aria-label="
|
|
t('criteria.indent', { number: index + 1 })
|
|
"
|
|
@click="
|
|
update(
|
|
indentAcceptanceCriterion(
|
|
modelValue,
|
|
index,
|
|
1,
|
|
),
|
|
)
|
|
"
|
|
><IndentIncrease aria-hidden="true"
|
|
/></Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
:disabled="disabled"
|
|
:aria-label="
|
|
t('criteria.remove', { number: index + 1 })
|
|
"
|
|
@click="remove(index)"
|
|
><Trash2 aria-hidden="true"
|
|
/></Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
<template v-if="editing && !readonly">
|
|
<Button
|
|
data-add-criterion
|
|
type="button"
|
|
variant="outline"
|
|
:disabled="disabled"
|
|
@click="add"
|
|
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
|
|
>
|
|
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
|
{{ t('criteria.hint') }}
|
|
</p>
|
|
</template>
|
|
<p
|
|
v-if="persistToggles && dirty"
|
|
class="text-muted-foreground text-sm"
|
|
role="status"
|
|
>
|
|
{{ t('criteria.unsaved') }}
|
|
</p>
|
|
<p
|
|
v-if="saving ?? disabled"
|
|
class="text-muted-foreground text-sm"
|
|
role="status"
|
|
>
|
|
{{ t('criteria.saving') }}
|
|
</p>
|
|
<InputError :id="`${id}-error`" :message="error" role="alert" />
|
|
</section>
|
|
</template>
|