Files
RenewCMS/web/src/components/LinkModal.vue
T

279 lines
6.9 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { computed, nextTick, ref, useId, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { normalizeLinkUrl } from '@/lib/articleLinkUrl'
const SHEET_QUERY = '(max-width: 39.999rem)'
const TEXT_PLACEHOLDER = 'What the reader sees'
const URL_PLACEHOLDER = 'https://example.com, /page, mailto: or #anchor'
const DIALOG_CONTENT_CLASS =
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0'
const SHEET_CONTENT_CLASS =
'divide-y-0 shadow-(--shadow-modal) ring-0 !inset-x-0 !top-auto !bottom-0 !left-0 !w-auto !max-w-none !translate-x-0 !translate-y-0 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-accented'
export interface LinkModalSubmit {
text: string
href: string
newTab: boolean
}
const props = defineProps<{
mode: 'insert' | 'edit'
text: string
href: string
newTab: boolean
}>()
const emit = defineEmits<{
submit: [value: LinkModalSubmit]
remove: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isSheet = useMediaQuery(SHEET_QUERY)
const textId = useId()
const urlId = useId()
const formId = useId()
const textValue = ref('')
const urlValue = ref('')
const newTabValue = ref(false)
const textError = ref<MessageKey | ''>('')
const urlError = ref<MessageKey | ''>('')
const focusedField = ref<'text' | 'url' | ''>('')
const editing = computed(() => props.mode === 'edit')
const title = computed(() => (editing.value ? t('Edit link') : t('Insert link')))
const confirmLabel = computed(() => (editing.value ? t('Save link') : t('Insert link')))
const presentation = computed(() => (isSheet.value ? 'sheet' : 'dialog'))
const modalUi = computed(() => ({
content: isSheet.value ? SHEET_CONTENT_CLASS : DIALOG_CONTENT_CLASS,
header: isSheet.value
? 'items-start gap-3 px-6 pt-7.5 pb-0 sm:px-6'
: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: isSheet.value ? 'pe-10 max-sm:pe-18' : 'pe-10',
close: isSheet.value ? 'end-8 top-9 size-11' : '',
title: 'text-lg/6 font-semibold',
description: 'text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}))
const modalContent = computed(() => ({
'data-presentation': presentation.value,
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
focusInitialField()
},
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}))
function focusField(id: string) {
document.getElementById(id)?.focus()
}
async function focusInitialField() {
await nextTick()
focusField(props.text === '' ? textId : urlId)
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
textValue.value = props.text
urlValue.value = props.href
newTabValue.value = props.newTab
textError.value = ''
urlError.value = ''
focusedField.value = ''
},
{ immediate: true },
)
function validateText() {
textError.value = textValue.value.trim() === '' ? 'Enter the link text.' : ''
}
function resolveHref() {
const keptAsStored =
props.mode === 'edit' &&
props.href.trim() !== '' &&
urlValue.value.trim() === props.href.trim()
return keptAsStored ? urlValue.value.trim() : normalizeLinkUrl(urlValue.value)
}
function validateUrl() {
urlError.value =
resolveHref() === null ? 'Enter a valid URL. Use http(s), mailto, a path or #anchor.' : ''
}
function onUrlBlur() {
focusedField.value = ''
if (urlValue.value.trim() !== '') {
validateUrl()
}
}
function onSubmit() {
validateText()
validateUrl()
if (textError.value) {
focusField(textId)
return
}
if (urlError.value) {
focusField(urlId)
return
}
emit('submit', {
text: textValue.value.trim(),
href: resolveHref() ?? '',
newTab: newTabValue.value,
})
}
function onRemove() {
emit('remove')
}
function onCancel() {
open.value = false
}
</script>
<template>
<UModal
v-model:open="open"
:title="title"
:description="t('Links open in the same tab unless you turn on a new tab.')"
:content="modalContent"
:ui="modalUi"
>
<template #body>
<UForm
:id="formId"
:loading-auto="false"
:state="{ text: textValue, url: urlValue }"
class="flex flex-col gap-4"
@submit="onSubmit"
>
<UFormField
:label="t('Link text')"
name="text"
:error="textError ? t(textError) : undefined"
@focusin="focusedField = 'text'"
@focusout="focusedField = ''"
>
<template v-if="textError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
:id="textId"
v-model="textValue"
:placeholder="t(TEXT_PLACEHOLDER)"
color="primary"
autocomplete="off"
:highlight="focusedField === 'text' || !!textError"
:aria-invalid="textError ? 'true' : undefined"
:ui="{ base: 'h-11 text-base md:h-10' }"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('URL')"
name="url"
:error="urlError ? t(urlError) : undefined"
@focusin="focusedField = 'url'"
@focusout="onUrlBlur"
>
<template v-if="urlError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
:id="urlId"
v-model="urlValue"
:placeholder="t(URL_PLACEHOLDER)"
:title="urlValue"
color="primary"
autocomplete="off"
spellcheck="false"
:highlight="focusedField === 'url' || !!urlError"
:aria-invalid="urlError ? 'true' : undefined"
:ui="{
root: 'min-w-0',
base: 'h-11 text-base md:h-10',
}"
class="w-full min-w-0"
/>
</UFormField>
<USwitch
v-model="newTabValue"
:label="t('Open in new tab')"
:ui="{
root: 'min-h-11 items-center gap-3 md:min-h-10',
container: 'h-auto',
base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
wrapper: 'ms-0',
label: 'text-base font-medium text-highlighted',
}"
/>
<UButton
v-if="editing"
color="error"
variant="link"
icon="i-lucide-unlink"
class="min-h-11 self-start px-0 md:min-h-10"
@click="onRemove"
>
{{ t('Remove link') }}
</UButton>
</UForm>
</template>
<template #footer>
<div
class="flex w-full gap-2"
:class="isSheet ? 'flex-col-reverse' : 'flex-row justify-end'"
>
<UButton
color="neutral"
variant="outline"
:block="isSheet"
:ui="{ base: 'h-11 justify-center md:h-8' }"
@click="onCancel"
>
{{ t('Cancel') }}
</UButton>
<UButton
type="submit"
:form="formId"
:block="isSheet"
:ui="{ base: 'h-11 justify-center md:h-8' }"
>
{{ confirmLabel }}
</UButton>
</div>
</template>
</UModal>
</template>