154 lines
3.7 KiB
Vue
154 lines
3.7 KiB
Vue
<script setup lang="ts">
|
|
import { t, locale, formatNumber } from '@/i18n'
|
|
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
value: number
|
|
max: number
|
|
min?: number
|
|
warnAt?: number
|
|
variant?: 'inline' | 'footer' | 'field'
|
|
plainText?: boolean
|
|
}>(),
|
|
{
|
|
min: 0,
|
|
warnAt: 0.9,
|
|
variant: 'inline',
|
|
plainText: false,
|
|
},
|
|
)
|
|
|
|
function formatThousands(count: number) {
|
|
if (locale.value === 'fr') return formatNumber(count)
|
|
return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
|
}
|
|
|
|
const overBy = computed(() => props.value - props.max)
|
|
const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
|
|
if (props.min > 0 && props.value < props.min) {
|
|
return 'under'
|
|
}
|
|
if (overBy.value > 0) {
|
|
return 'over'
|
|
}
|
|
if (props.value >= props.max * props.warnAt) {
|
|
return 'warn'
|
|
}
|
|
return 'normal'
|
|
})
|
|
|
|
const overText = computed(() => {
|
|
const suffix = props.plainText
|
|
? t('Shorten the text.')
|
|
: t('Shorten the text or remove an image. Formatting counts too.')
|
|
return overBy.value === 1
|
|
? t('1 character over. {0}', [suffix])
|
|
: t('{0} characters over. {1}', [formatThousands(overBy.value), suffix])
|
|
})
|
|
|
|
const warnText = 'Close to the limit. Images and formatting count too.'
|
|
|
|
const message = computed(() => {
|
|
if (status.value === 'over') {
|
|
return overText.value
|
|
}
|
|
if (status.value === 'warn') {
|
|
return t(warnText)
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const announcement = ref('')
|
|
|
|
watch([status, locale], ([next], [previous]) => {
|
|
if (next === previous && !announcement.value) {
|
|
return
|
|
}
|
|
if (next === 'over') {
|
|
announcement.value = overText.value
|
|
} else if (next === 'warn') {
|
|
announcement.value = t('Almost at the length limit.')
|
|
} else {
|
|
announcement.value = ''
|
|
}
|
|
})
|
|
|
|
const isErrorStatus = computed(() => status.value === 'over' || status.value === 'under')
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
v-if="variant === 'footer'"
|
|
class="flex items-center gap-3 border-t border-default bg-default px-6 py-2"
|
|
>
|
|
<span
|
|
class="flex-1 text-xs"
|
|
:class="{
|
|
'text-warning': status === 'warn',
|
|
'text-error': isErrorStatus,
|
|
'text-muted': status === 'normal',
|
|
}"
|
|
>{{ message }}</span
|
|
>
|
|
<span
|
|
class="flex items-center gap-1.5 font-mono text-xs"
|
|
:class="{
|
|
'text-warning': status === 'warn',
|
|
'text-error': isErrorStatus,
|
|
'text-muted': status === 'normal',
|
|
}"
|
|
>
|
|
<UIcon
|
|
v-if="status === 'warn'"
|
|
name="i-lucide-triangle-alert"
|
|
class="size-3.5"
|
|
data-testid="character-counter-warning-icon"
|
|
/>
|
|
<UIcon
|
|
v-else-if="status === 'over'"
|
|
name="i-lucide-circle-alert"
|
|
class="size-3.5"
|
|
data-testid="character-counter-over-icon"
|
|
/>
|
|
{{ formatThousands(value) }} / {{ formatThousands(max) }}
|
|
</span>
|
|
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
|
|
</div>
|
|
<span
|
|
v-else-if="variant === 'field'"
|
|
class="font-mono text-xs leading-tight"
|
|
:class="overBy > 0 ? 'text-error' : 'text-muted'"
|
|
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
|
|
>
|
|
<div v-else class="flex flex-col gap-1">
|
|
<div
|
|
class="flex items-center gap-1.5 font-mono text-xs"
|
|
:class="{
|
|
'text-warning': status === 'warn',
|
|
'text-error': isErrorStatus,
|
|
'text-muted': status === 'normal',
|
|
}"
|
|
>
|
|
<UIcon
|
|
v-if="status === 'warn'"
|
|
name="i-lucide-triangle-alert"
|
|
class="size-4"
|
|
data-testid="character-counter-warning-icon"
|
|
/>
|
|
<UIcon
|
|
v-else-if="status === 'over'"
|
|
name="i-lucide-circle-alert"
|
|
class="size-4"
|
|
data-testid="character-counter-over-icon"
|
|
/>
|
|
<span>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span>
|
|
</div>
|
|
<p v-if="status === 'over'" class="text-sm text-error">
|
|
{{ overText }}
|
|
</p>
|
|
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
|
|
</div>
|
|
</template>
|