279 lines
6.9 KiB
Vue
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>
|