Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Textarea): add char counter #987

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/components/content/examples/TextareaExampleSlotCounter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup>
const input = ref('I love NuxtUI!')

const badgeColor = (remaining)=>{

Check failure on line 4 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Missing space before =>

Check failure on line 4 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Missing space after =>
switch (true) {
case remaining < 5: return 'red';

Check failure on line 6 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Expected indentation of 2 spaces but found 4 spaces

Check failure on line 6 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Extra semicolon
case remaining < 10: return 'amber';

Check failure on line 7 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Expected indentation of 2 spaces but found 4 spaces

Check failure on line 7 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Extra semicolon
case remaining < 15: return 'orange';

Check failure on line 8 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Expected indentation of 2 spaces but found 4 spaces

Check failure on line 8 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Extra semicolon
default: return 'green';

Check failure on line 9 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Expected indentation of 2 spaces but found 4 spaces

Check failure on line 9 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Extra semicolon
}
}
</script>

<template>
<UTextarea v-model="input" :counter="15">
<template #counter="{focused, letterCount, maxValue}">
<UBadge :color="badgeColor(maxValue - letterCount)">
{{ maxValue - letterCount }} Characters remaining!

Check warning on line 18 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Multiple spaces found before '}}'

<UIcon class="ml-1" v-if="focused" name="i-heroicons-eye" />

Check warning on line 20 in docs/components/content/examples/TextareaExampleSlotCounter.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

Attribute "v-if" should go before "class"
</UBadge>
</template>
</UTextarea>
</template>
25 changes: 25 additions & 0 deletions docs/content/3.forms/2.textarea.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,31 @@ props:
---
::

### Counter

Use the `counter` prop to enable the counter and to set a maximum value. The counter will show if you are focusing the textarea or when setting `persistent-counter`. This is only visual and not validating the input.

::component-card
---
baseProps:
modelValue: 'This is an example of the counter prop'
props:
counter: '540'
persistentCounter: false
excludedProps:
- counter

---
::

## Slots

### `counter`

You can use the `#counter` slot to show a custom counter. It receives `focused` `letterCount` and `maxValue` of the textarea.

:component-example{component="textarea-example-slot-counter"}

## Props

:component-props
Expand Down
44 changes: 42 additions & 2 deletions src/runtime/components/forms/Textarea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@
@blur="onBlur"
@change="onChange"
/>
<slot name="counter" v-bind="{ focused, letterCount, maxValue }">
<span v-if="counterVisible" :class="counterClass">{{ maxValue ? `${letterCount} / ${maxValue}` :
String(letterCount) }}</span>
</slot>
</div>
</template>

<script lang="ts">
import { ref, computed, toRef, watch, onMounted, nextTick, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { useFocus } from '@vueuse/core'
import { twMerge, twJoin } from 'tailwind-merge'
import { defu } from 'defu'
import { useUI } from '../../composables/useUI'
Expand Down Expand Up @@ -125,6 +130,14 @@ export default defineComponent({
modelModifiers: {
type: Object as PropType<{ trim?: boolean, lazy?: boolean, number?: boolean }>,
default: () => ({})
},
counter: {
type: [Boolean, Number, String] as PropType<true | number | string>,
default: false
},
persistentCounter: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'blur'],
Expand All @@ -136,6 +149,21 @@ export default defineComponent({
const modelModifiers = ref(defu({}, props.modelModifiers, { trim: false, lazy: false, number: false }))

const textarea = ref<HTMLTextAreaElement | null>(null)

const { focused } = useFocus(textarea)
const counterVisible = computed(() => (props.counter && focused.value) || props.persistentCounter)

const maxValue = computed(() => {
if (
!props.counter ||
(typeof props.counter !== 'number' &&
typeof props.counter !== 'string')
) return undefined

return props.counter
})

const letterCount = ref(props.modelValue.toString().length)

const autoFocus = () => {
if (props.autofocus) {
Expand Down Expand Up @@ -175,13 +203,15 @@ export default defineComponent({
value = looseToNumber(value)
}

letterCount.value = value.length

emit('update:modelValue', value)
emitFormInput()
}

const onInput = (event: InputEvent) => {
autoResize()
if (!modelModifiers.value.lazy) {
if (!modelModifiers.value.lazy) {
updateInput((event.target as HTMLInputElement).value)
}
}
Expand Down Expand Up @@ -221,6 +251,11 @@ export default defineComponent({
}, 100)
})

const counterClass = computed(() => {
return twJoin(ui.value.counter.wrapper,
ui.value.size[size.value])
})

const textareaClass = computed(() => {
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]

Expand All @@ -245,9 +280,14 @@ export default defineComponent({
textarea,
// eslint-disable-next-line vue/no-dupe-keys
textareaClass,
counterClass,
onInput,
onChange,
onBlur
onBlur,
focused,
counterVisible,
maxValue,
letterCount
}
}
})
Expand Down
25 changes: 4 additions & 21 deletions src/runtime/ui.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,9 @@ export const formGroup = {

export const textarea = {
...input,
counter: {
wrapper: 'absolute right-0'
},
default: {
size: 'sm',
color: 'white',
Expand Down Expand Up @@ -1124,7 +1127,7 @@ export const pagination = {
nextButton: {
color: 'white',
class: 'rtl:[&_span:last-child]:rotate-180',
icon: 'i-heroicons-chevron-right-20-solid'
icon: 'i-heroicons-chevron-right-20-solid '
}
}
}
Expand Down Expand Up @@ -1163,26 +1166,6 @@ export const tabs = {
}
}

export const breadcrumb = {
wrapper: 'relative',
ol: 'flex items-center gap-x-1.5',
li: 'flex items-center gap-x-1.5 text-gray-500 dark:text-gray-400 text-sm',
base: 'flex items-center gap-x-1.5 group font-semibold',
icon: {
base: 'flex-shrink-0 w-4 h-4',
active: '',
inactive: ''
},
divider: {
base: 'flex-shrink-0 w-5 h-5'
},
active: 'text-primary-500 dark:text-primary-400',
inactive: ' hover:text-gray-700 dark:hover:text-gray-200',
default: {
divider: 'i-heroicons-chevron-right-20-solid'
}
}

// Overlays

export const modal = {
Expand Down