- AccountContributions.vue on My account: one card per contributed section
with status and outline buttons; confirm or danger actions go through the
shared ConfirmDialog; a returned URL opens in a new tab (noopener) and stays
as a visible link; 409 reloads with the stale toast
- ConfirmRequest.prompt and useConfirm value; ConfirmDialog renders one
labelled text input (maxlength, autocomplete off, aria-describedby comment,
autofocus), confirm disabled until a value is typed, Enter confirms
- RecordActions posts {payload} for a prompted action and {} otherwise; a 422
on payload shows the server's detail as a danger toast
- types.ts exports RecordActionPrompt, AccountContribution,
AccountContributionAction, AccountActionResult
- vitest smokes AccountContributions.test.ts and RecordActionsPrompt.test.ts
132 lines
5.0 KiB
Vue
132 lines
5.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, ref, useId, watch } from 'vue'
|
|
import {
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogOverlay,
|
|
AlertDialogPortal,
|
|
AlertDialogRoot,
|
|
AlertDialogTitle,
|
|
} from 'reka-ui'
|
|
import { LoaderCircle } from '@lucide/vue'
|
|
import { t } from '../../app/i18n'
|
|
import type { ConfirmPrompt } from './confirm'
|
|
|
|
// Confirmation (Reka AlertDialog: role alertdialog, focus trapped, Esc
|
|
// cancels, focus returns to the trigger). Text only: messages come from the
|
|
// server and are never rendered as HTML. While `busy` (an action started by
|
|
// the confirm is running) both buttons are disabled and the dialog stays.
|
|
// With a `prompt`, one labelled text input sits above the buttons: the
|
|
// confirm button stays disabled while its trimmed value is empty and Enter
|
|
// in the input confirms. The value is the host's (`value` / `update:value`).
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
open: boolean
|
|
message: string
|
|
title?: string
|
|
confirmLabel?: string
|
|
cancelLabel?: string
|
|
danger?: boolean
|
|
busy?: boolean
|
|
prompt?: ConfirmPrompt | null
|
|
value?: string
|
|
}>(),
|
|
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false, busy: false, prompt: null, value: '' },
|
|
)
|
|
const emit = defineEmits<{ 'update:open': [open: boolean]; 'update:value': [value: string]; confirm: []; cancel: [] }>()
|
|
|
|
const dialogId = `confirm-${useId()}`
|
|
const promptInput = ref<HTMLInputElement | null>(null)
|
|
const promptEmpty = computed(() => !!props.prompt && props.value.trim() === '')
|
|
|
|
// Closing any other way than the confirm button (Esc, overlay, Anuluj) cancels.
|
|
function onOpenChange(open: boolean): void {
|
|
if (!open && props.busy) {
|
|
return
|
|
}
|
|
emit('update:open', open)
|
|
if (!open) {
|
|
emit('cancel')
|
|
}
|
|
}
|
|
|
|
function onConfirm(): void {
|
|
if (props.busy || promptEmpty.value) {
|
|
return
|
|
}
|
|
emit('confirm')
|
|
emit('update:open', false)
|
|
}
|
|
|
|
function onInput(event: Event): void {
|
|
emit('update:value', (event.target as HTMLInputElement).value)
|
|
}
|
|
|
|
watch(
|
|
() => props.open && !!props.prompt,
|
|
async (prompted) => {
|
|
if (prompted) {
|
|
await nextTick()
|
|
promptInput.value?.focus()
|
|
}
|
|
},
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<AlertDialogRoot :open="open" @update:open="onOpenChange">
|
|
<AlertDialogPortal>
|
|
<AlertDialogOverlay class="pointer-events-auto fixed inset-0 z-[80] bg-overlay" />
|
|
<AlertDialogContent
|
|
data-confirm-dialog
|
|
class="pointer-events-auto fixed top-1/2 left-1/2 z-[90] flex w-[calc(100%-32px)] max-w-[440px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
|
|
>
|
|
<AlertDialogTitle class="text-[20px] font-bold tracking-[-0.01em]">
|
|
{{ title ?? t('backend::lang.form.confirm') }}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription class="text-muted [overflow-wrap:anywhere]">{{ message }}</AlertDialogDescription>
|
|
<div v-if="prompt" class="flex flex-col gap-1.5" data-confirm-prompt>
|
|
<label :for="`${dialogId}-value`" class="text-[13px] font-semibold">{{ prompt.label }}</label>
|
|
<input
|
|
:id="`${dialogId}-value`"
|
|
ref="promptInput"
|
|
type="text"
|
|
autocomplete="off"
|
|
:maxlength="prompt.maxLength"
|
|
:placeholder="prompt.placeholder"
|
|
:aria-describedby="prompt.comment ? `${dialogId}-comment` : undefined"
|
|
:disabled="busy"
|
|
:value="value"
|
|
class="h-control w-full rounded-control border border-border-strong bg-surface px-3 text-text outline-none focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
|
|
@input="onInput"
|
|
@keydown.enter.prevent="onConfirm"
|
|
/>
|
|
<p v-if="prompt.comment" :id="`${dialogId}-comment`" class="text-[13px] text-muted">{{ prompt.comment }}</p>
|
|
</div>
|
|
<div class="flex gap-2.5">
|
|
<AlertDialogCancel
|
|
data-action="cancel"
|
|
:disabled="busy"
|
|
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{{ cancelLabel ?? t('backend::lang.form.cancel') }}
|
|
</AlertDialogCancel>
|
|
<button
|
|
type="button"
|
|
data-action="confirm"
|
|
:disabled="busy || promptEmpty"
|
|
:aria-busy="busy ? 'true' : undefined"
|
|
:class="danger ? 'bg-danger text-white' : 'bg-primary text-on-primary'"
|
|
class="inline-flex h-button flex-1 items-center justify-center gap-2 rounded-control font-semibold hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
@click.prevent="onConfirm"
|
|
>
|
|
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
|
|
{{ confirmLabel ?? t('backend::lang.form.confirm') }}
|
|
</button>
|
|
</div>
|
|
</AlertDialogContent>
|
|
</AlertDialogPortal>
|
|
</AlertDialogRoot>
|
|
</template>
|