refactor(ui): improve textarea component

This commit is contained in:
upsiflu 2025-02-02 20:16:16 +01:00
parent 6e69a74b75
commit 61e6b3fa0f
3 changed files with 208 additions and 129 deletions

View File

@ -1,14 +1,22 @@
<script setup lang="ts">
import { nextTick, computed, ref, type ComputedRef } from 'vue'
import { nextTick, computed, ref, type ComputedRef, onMounted } from 'vue'
import { useTextareaAutosize, computedWithControl, useManualRefHistory, watchDebounced } from '@vueuse/core'
import Button from './Button.vue'
import Spacer from './Spacer.vue'
import Markdown from './Markdown.vue'
import Layout from '~/components/ui/Layout.vue'
import Layout from './Layout.vue'
const { max=Infinity, placeholder='', ...restProps } = defineProps<{ max?:number,placeholder?:string, label?:string }>()
const { charLimit = Infinity, placeholder = '', initialLines: minLines=3, ...props } = defineProps<{
label?: string,
placeholder?: string,
charLimit?: number,
initialLines?: number | string,
autofocus?: true,
required?: true
}>()
const model = defineModel<string>({ default: '' })
const model = defineModel<string>({ required: true })
const { undo, redo, commit: commitHistory, last } = useManualRefHistory(model)
const { textarea, triggerResize } = useTextareaAutosize({ input: model })
@ -37,7 +45,7 @@ const lineNumber = computedWithControl(
const updateLineNumber = () => setTimeout(lineNumber.trigger, 0)
const currentLine = computed({
get: () => model.value.split('\n')[lineNumber.value],
get: () => (model.value.split('\n')[model.value.split('\n').length>lineNumber.value ? lineNumber.value : 0]),
set: (line) => {
const content = model.value.split('\n')
content[lineNumber.value] = line
@ -175,17 +183,20 @@ const link = async () => {
// Fix focus
const focus = () => textarea.value.focus()
onMounted(() => {
if (props.autofocus) focus();
})
</script>
<template>
<Layout stack no-gap label
<Layout stack gap-8 label
class="funkwhale textarea-label"
>
<span v-if="$slots['label']" class="label">
<slot name="label" />
</span>
<span v-if="restProps.label" class="label">
{{ restProps.label }}
<span v-if="props.label" class="label">
{{ props.label }}
</span>
<div :class="{ 'has-preview': preview }" class="funkwhale textarea" @mousedown.prevent="focus" @mouseup.prevent="focus">
<Markdown :md="model" class="preview" />
@ -193,31 +204,47 @@ const focus = () => textarea.value.focus()
@keydown.right="updateLineNumber" @keydown.up="updateLineNumber" @keydown.down="updateLineNumber"
@keydown.enter="newline" @keydown.ctrl.shift.z.exact.prevent="redo" @keydown.ctrl.z.exact.prevent="undo"
@keydown.ctrl.b.exact.prevent="bold" @keydown.ctrl.i.exact.prevent="italics"
@keydown.ctrl.shift.x.exact.prevent="strikethrough" @keydown.ctrl.k.exact.prevent="link" :maxlength="max"
:placeholder="placeholder" v-model="model" id="textarea_id" />
<div class="textarea-buttons">
<Button ghost @click="preview = !preview" icon="bi-eye" color="secondary" :aria-pressed="preview || undefined" />
<div class="separator" />
<Button ghost @click="heading1" icon="bi-type-h1" color="secondary" :aria-pressed="isHeading1 || undefined" :disabled="preview" />
<Button ghost @click="heading2" icon="bi-type-h2" color="secondary" :aria-pressed="isHeading2 || undefined" :disabled="preview" />
<Button ghost @click="paragraph" icon="bi-paragraph" color="secondary" :aria-pressed="isParagraph || undefined" :disabled="preview" />
<Button ghost @click="quote" icon="bi-quote" color="secondary" :aria-pressed="isQuote || undefined" :disabled="preview" />
<Button ghost @click="orderedList" icon="bi-list-ol" color="secondary" :aria-pressed="isOrderedList || undefined"
@keydown.ctrl.shift.x.exact.prevent="strikethrough" @keydown.ctrl.k.exact.prevent="link" :maxlength="charLimit"
v-bind="$attrs"
:required="required"
:placeholder="placeholder" v-model="model" id="textarea_id" :rows="initialLines"
:style="`min-height:${((typeof(initialLines) === 'string' ? parseInt(initialLines) : (initialLines ?? 3)) + 1.2) * 1.5}rem`"
/>
<label class="textarea-buttons"
:for="preview ? 'expanded-preview-button' : 'nothing'"
>
<Button ghost square-small @click="paragraph" icon="bi-paragraph" :aria-pressed="isParagraph || undefined" :disabled="preview" />
<Button ghost square-small @click="heading1" icon="bi-type-h1" :aria-pressed="isHeading1 || undefined" :disabled="preview" />
<Button ghost square-small @click="heading2" icon="bi-type-h2" :aria-pressed="isHeading2 || undefined" :disabled="preview" />
<Button ghost square-small @click="quote" icon="bi-quote" :aria-pressed="isQuote || undefined" :disabled="preview" />
<Button ghost square-small @click="orderedList" icon="bi-list-ol" :aria-pressed="isOrderedList || undefined"
:disabled="preview" />
<Button ghost @click="unorderedList" icon="bi-list-ul" color="secondary" :aria-pressed="isUnorderedList || undefined"
<Button ghost square-small @click="unorderedList" icon="bi-list-ul" :aria-pressed="isUnorderedList || undefined"
:disabled="preview" />
<div class="separator" />
<Spacer />
<Button ghost @click="bold" icon="bi-type-bold" color="secondary" :disabled="preview" />
<Button ghost @click="italics" icon="bi-type-italic" color="secondary" :disabled="preview" />
<Button ghost @click="strikethrough" icon="bi-type-strikethrough" color="secondary" :disabled="preview" />
<Button ghost @click="link" icon="bi-link-45deg" color="secondary" :disabled="preview" />
<Button ghost square-small @click="bold" icon="bi-type-bold" :disabled="preview" />
<Button ghost square-small @click="italics" icon="bi-type-italic" :disabled="preview" />
<Button ghost square-small @click="strikethrough" icon="bi-type-strikethrough" :disabled="preview" />
<Button ghost square-small @click="link" icon="bi-link-45deg" :disabled="preview" />
<span v-if="max !== Infinity && typeof max === 'number'" class="letter-count">{{ max - model.length }}</span>
</div>
<span v-if="charLimit !== Infinity && typeof charLimit === 'number'" class="letter-count">{{ charLimit - model.length }}</span>
<Spacer />
<Spacer h grow v-if="!$slots.default"/>
<slot />
<Button ghost low-height min-content
id="expanded-preview-button"
@click="preview = !preview"
icon="bi-eye"
:aria-pressed="preview || undefined">
Preview
</Button>
</label>
</div>
</Layout>
</template>

View File

@ -1,10 +1,9 @@
.funkwhale {
&.textarea-label {
display: block;
> .label {
padding-bottom: 4px;
font-size:14px;
font-weight:600;
margin-top: -18px;
font-size: 14px;
font-weight: 600;
}
}
&.textarea {
@ -15,19 +14,27 @@
background-color: var(--fw-bg-color);
}
> textarea {
resize: none;
order: 0;
outline: none;
border: none;
}
overflow: hidden;
> .textarea-buttons {
border-top: 1px solid var(--fw-buttons-border-color);
flex-wrap: wrap;
// Offset padding of the textarea
margin: -8px;
> .funkwhale.button:not(:hover):not(:active):not(.is-active) {
--fw-bg-color: transparent;
> button+button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
> .separator {
background-color: var(--fw-buttons-border-color);
}
> .letter-count {
color: var(--fw-buttons-border-color);
> button:has(+button) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
@ -42,8 +49,8 @@
}
&.has-preview,
&:focus-within {
--fw-border-color: var(--fw-primary) !important;
&:hover:focus-within {
--fw-border-color: transparent;
--fw-bg-color: var(--fw-blue-010);
}
}
@ -59,16 +66,17 @@
}
&.has-preview,
&:focus-within {
--fw-border-color: var(--fw-primary) !important;
&:hover:focus-within {
--fw-border-color: transparent;
--fw-bg-color: var(--fw-gray-800);
}
}
position: relative;
padding: 8px;
border-radius: var(--fw-border-radius);
// Make border trace the radius of the buttons smoothly
border-radius: calc(var(--fw-border-radius) + 4px);
&.has-preview,
&:focus-within {
@ -100,10 +108,10 @@
}
> textarea {
line-height: 1.5rem;
display: block;
width: 100%;
min-height: 160px;
padding: 8px 12px 40px;
padding: 8px 12px 8px;
font-family: monospace;
background: transparent;
@ -116,33 +124,12 @@
display: flex;
align-items: center;
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
position: relative;
opacity: 0;
transform: translateY(.5rem);
transform: translateY(1rem) scale(1.03);
pointer-events: none;
transition: all .2s ease;
padding-top: 4px;
> .funkwhale.button {
font-size: 1.2rem;
padding: 0.2em;
}
> .separator {
width: 1px;
height: 28px;
margin: 0 8px;
}
> .letter-count {
margin-left: auto;
padding-right: 16px;
}
}
}
}

View File

@ -1,86 +1,71 @@
<script setup lang="ts">
import Textarea from '~/components/ui/Textarea.vue'
import { ref } from 'vue'
import Textarea from '~/components/ui/Textarea.vue'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
const text = ref('# Funk\nwhale')
const text1 = ref('# Funk\nwhale')
const text2 = ref('# Funk\nwhale')
const text2 = ref('0123456789abcdefghij')
const text3 = ref('')
const reset = () => { text.value = '' }
</script>
```ts
import Textarea from "~/components/ui/Textarea.vue"
import Textarea from "~/components/ui/Textarea.vue";
```
# Textarea
Textareas are input blocks that enable users to write in textual information. These blocks are used throughout the Funkwhale interface for entering item descriptions, moderation notes, and custom notifications.
Textareas are input blocks that enable users to write formatted text (format: Markdown). These blocks are used throughout the Funkwhale interface for entering item descriptions, moderation notes, and custom notifications.
::: details Props
```ts
const {
charLimit = 5000,
placeholder = "",
minLines = 3,
...props
} = defineProps<{
label?: string;
placeholder?: string;
charLimit?: number;
minLines?: number | string;
autofocus?: true;
required?: true;
}>();
```
::: tip
Funkwhale supports Markdown syntax in textarea blocks.
:::
| Prop | Data type | Required? | Description |
| --------------- | --------- | --------- | ------------------------------------------------------------------ |
| `max` | Number | No | The maximum number of characters a user can enter in the textarea. |
| `placeholder` | String | No | The placeholder text shown on an empty textarea. |
| `v-model:value` | String | Yes | The text entered into the textarea. |
Create a textarea and attach its input to a value using a `v-model` of type `string` (required):
## Textarea model
Create a textarea and attach its input to a value using a `v-model` directive.
```ts
const text = ref("# Funk\nwhale");
```
```vue-html{2}
<Textarea
v-model="text" label="My Area"
/>
<Textarea v-model="text" />
```
<ClientOnly>
<Textarea v-model="text1" label="My Area"/>
</ClientOnly>
<Textarea v-model="text1" />
## Textarea max length
## Add a label
You can set the maximum length (in characters) that a user can enter in a textarea by passing a `max` prop.
```vue-html{3}
<Textarea
v-model="text"
:max="20"
/>
```
<ClientOnly>
<Textarea v-model="text2" :max="20" />
</ClientOnly>
## Textarea placeholder
Add a placeholder to a textarea to guide users on what they should enter by passing a `placeholder` prop.
```vue-html{3}
<Textarea
v-model="text"
placeholder="Describe this track here…"
/>
```
<ClientOnly>
<Textarea v-model="text3" placeholder="Describe this track here…" />
</ClientOnly>
## Label slot
```vue-html{2-4}
```vue-html{3-5}
<Spacer size="16" />
<Textarea>
<template #label>
About my music
About my music <span style="color:red; float:right;">*required</span>
</template>
</Textarea>
```
<Textarea>
<Spacer size="16" />
<Textarea v-model="text">
<template #label>
About my music <span style="color:red; float:right;">*required</span>
</template>
@ -89,7 +74,87 @@ Add a placeholder to a textarea to guide users on what they should enter by pass
If you just have a string, we have a convenience prop, so instead you can write:
```vue-html
<Textarea label="About my music" />
<Spacer size="16" />
<Textarea v-model="text" label="About my music" />
```
<Textarea label="About my music" />
<Spacer size="16" />
<Textarea v-model="text" label="About my music" />
Note that the label text sits atop of the upper end of the component. This way, you can define the distance between baselines (the vertical rhythm) with spacers or gaps.
## Add a placeholder
```vue-html{3}
<Textarea
v-model="text"
placeholder="Describe this track here…"
/>
```
<Textarea v-model="text3" placeholder="Describe this track here…" />
## Limit the number of characters
You can set the maximum length (in characters) that a user can enter in a textarea by passing a `charLimit` prop.
```vue-html{3}
<Textarea v-model="text" :charLimit="20" />
```
<Textarea v-model="text2" :charLimit="20" />
::: warning Caveats
- You can still set the model longer than allowed by changing it via a script or another Textarea
- A line break counts as one character, which may confuse users
- The character counting algorithm has not been tested on non-Latin based languages
:::
## Set the initial height
Specify the number of lines with the `initial-lines` prop (the default is 5):
```vue-html
<Textarea v-model="text" initial-lines="1" />
```
<Textarea v-model="text3" initialLines="1" />
## Autofocus
If you add the `autofocus` attribute, the text input field will receive focus as soon as it is rendered on the page. Make sure to only add this prop to a single component per page!
## Require this form to be filled before submitting
The `required` attribute prevents the `submit` button of this form.
Make sure to also add an indicator such as the text "required" or a star to prevent confusion.
See [mdn on the `required` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/required) if you want to know more.
## Additional attributes
Any prop that is not declared in the `Props` type will be added to the `<textarea>` element directly. See [mdn on `textarea` attributes here](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea). Examples: `autocomplete`, `cols`, `minlength`, `readonly`, `wrap`, `disabled` etc.
## Add custom toolbar items
By default, there are text formatting buttons and a 'preview' toggle button in the toolbar. You can add custom content there by adding them into the default slot. They will be drawn before the `preview` button.
```vue-html
<Textarea v-model="text">
<Button ghost low-height min-content
v-if="text !== ''"
icon="bi-arrow-counterclockwise"
@click.prevent="reset()">Reset</Button>
</Textarea>
```
<Textarea v-model="text">
<Spacer no-size grow />
<Button ghost low-height min-content
v-if="text !== ''"
icon="bi-arrow-counterclockwise"
@click.prevent="reset()">Reset</Button>
</Textarea>