feat: article editor core
This commit is contained in:
1 parent
d493a181e1
commit
a4734b2c91
55 files changed
+4186
-496
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user