refactor(ui): improve textarea component
This commit is contained in:
parent
6e69a74b75
commit
61e6b3fa0f
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue