feat: article editor core

This commit is contained in:
fsylvain committed 2026-09-27 02:11:38 +02:00
1 parent d493a181e1
commit a4734b2c91
55 files changed
+4186 -496

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
<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'
}>(),
{
min: 0,
warnAt: 0.9,
variant: 'inline',
},
)
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'>(() => {
if (overBy.value > 0) {
return 'over'
}
if (props.value >= props.max * props.warnAt) {
return 'warn'
}
return 'normal'
})
const overText = computed(() =>
overBy.value === 1
? '1 character over. Shorten the text or remove an image. Formatting counts too.'
: `${formatThousands(overBy.value)} characters over. Shorten the text or remove an image. Formatting counts too.`,
)
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 = ''
}
})
</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': status === 'over',
'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': status === 'over',
'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>
<div v-else class="flex flex-col gap-1">
<div
class="flex items-center gap-1.5 font-mono text-sm"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'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>