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

151 lines
3.5 KiB
Vue

<script setup lang="ts">
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) {
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
? 'Shorten the text.'
: 'Shorten the text or remove an image. Formatting counts too.'
return overBy.value === 1
? `1 character over. ${suffix}`
: `${formatThousands(overBy.value)} characters over. ${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 warnText
}
return ''
})
const announcement = ref('')
watch(status, (next, previous) => {
if (next === previous) {
return
}
if (next === 'over') {
announcement.value = overText.value
} else if (next === 'warn') {
announcement.value = '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>