feat(ui): users can change pill content with presets and custom labels

This commit is contained in:
upsiflu 2025-03-09 13:13:44 +01:00
parent 56fff9d583
commit 3ffa784027
3 changed files with 485 additions and 161 deletions

View File

@ -1,86 +1,448 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, watch, nextTick, computed } from 'vue'
import { type ColorProps, type PastelProps, type VariantProps, type RaisedProps, color } from '~/composables/color'
const input = ref<HTMLInputElement>()
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleClick = (event: MouseEvent) => {
emit('click', event)
if (model.value !== undefined) {
input.value?.focus()
}
}
const props = defineProps<{ noUnderline?:true, autofocus? : boolean } &(PastelProps | ColorProps) & VariantProps & RaisedProps>()
const model = defineModel<string>()
import Layout from './Layout.vue'
import Button from './Button.vue'
import Input from './Input.vue'
import Popover from './Popover.vue'
import PopoverItem from './popover/PopoverItem.vue'
onMounted(() => {
if (props.autofocus) input.value?.focus()
/* Model */
const props = defineProps<{
noUnderline?:true
} & (PastelProps | ColorProps)
& VariantProps
& RaisedProps
>()
type Item = { type: 'custom' | 'preset', label: string }
const model = defineModel<{
current: Item,
others: Item[]
}>()
const isEditing = ref<boolean>(false)
let previousValue: Item | undefined
let previouslyFocusedElement: Element | null
watch(isEditing, (isTrue, wasTrue) => {
if (!model.value) return
// Cache the previous value, in case the user cancels later
if (isTrue && !wasTrue) {
previousValue = { ...model.value.current }
if (model.value.current.type === 'preset') {
model.value.others.push({...model.value.current})
model.value.current.type = 'custom'
}
// Shift focus between the input and the previously focused element
previouslyFocusedElement = document.activeElement
} else if (wasTrue && !isTrue) {
nextTick(() => (previouslyFocusedElement as HTMLElement)?.focus())
const matchInOthers
= model.value?.others.find(({ label })=>label === model.value?.current.label.trim())
if (matchInOthers) {
model.value.current = { ...matchInOthers }
}
model.value.others = model.value.others.filter(({ label }) => label !== model.value?.current.label)
}
})
const sanitize = () => {
console.log('SANITIZE')
model.value = model.value?.replace(',', '')?.trim()
/* Update */
const clicked = () => {
if (!model.value) return
if (!isEditing.value) {
isEditing.value = true
}
}
const sanitizeAndBlur = () => {
sanitize()
input.value?.blur()
/* and bubble a confirmation event */
const pressedKey = (e: KeyboardEvent) => {
if (!model.value) return
// confirm or cancel
switch (e.key) {
case "Enter":
case "Tab":
case "ArrowLeft":
case "ArrowRight":
case "Space":
case ",":
case " ":
confirmed(); break;
case "Escape":
canceled(); break;
}
}
const canceled = () => {
if (!previousValue || !model.value) return
const matchInOthers
= model.value?.others.find(({ label })=>label === model.value?.current.label.trim())
// Reset current label
model.value.current
= matchInOthers
|| {...previousValue}
// Close dropdown
isEditing.value = false
}
const confirmed = () => {
if (!previousValue || !model.value) return;
const matchInOthers
= model.value?.others.find(({ label })=>label === model.value?.current.label.trim())
// Use the best match; otherwise the current input, sanitized
model.value.current
= matchInOthers
|| match.value
|| { ...model.value.current,
label : model.value.current.label.replace(',', '').replace(' ', '').trim()
}
// Close dropdown
isEditing.value = false
}
const sortedOthers = computed(()=>
model.value
? model.value.others.map((item) =>
item.label.toLowerCase().includes(model.value?.current.label.toLowerCase() || '')
? [item.label.length - (model.value?.current.label.length || 0), item] as const
: [99, item] as const
)
.sort(([deltaA, a], [deltaB, b]) =>
deltaA - deltaB
)
.map(([delta, item], index) =>
index===0 && delta < 99 && model.value && model.value.current.label.length>0 && model.value.current.label !== previousValue?.label
? [-1, item] as const /* It's a match */
: [delta, item] as const /* It's not a match */
)
: []
)
const match = computed(()=>
sortedOthers.value.at(0)?.[0] === -1
? sortedOthers.value.at(0)?.[1]
: undefined
)
const other = computed(() => (option: Item) => (
{
item: {
onClick: () => {
if (!model.value) return;
model.value = {
current: { ...option, type: 'custom' },
others: [...(
model.value.current.label.trim() === '' || model.value.others.find(({ label })=>label === model.value?.current.label.trim())
? []
: [{ ...model.value.current }]
), ...model.value.others.filter(
({ label, type }) => label !== option.label || type === 'preset'
)]
}
isEditing.value = false
},
isMatch: match.value?.label === option.label,
isSame: option.label === model.value?.current.label
},
action: option.type === 'custom'
? {
title: 'Delete custom',
icon: 'bi-trash',
onClick: () => {
if (!model.value) return;
model.value = {
...model.value,
others: model.value.others.filter(({ label }) => label !== option.label)
}
}
}
: undefined
} as const
))
const current = computed(() => (
!model.value
? undefined
: model.value.current.label === '' && previousValue?.label !== ''
? {
attributes: {
title: `Reset to ${previousValue?.label || model.value.current}`,
icon: 'bi-arrow-counterclockwise'
},
onClick: () => {
if (!model.value) return;
model.value = {
...model.value,
current: previousValue || model.value.current
}
}
} as const
: model.value.current.label === previousValue?.label && model.value.current.type==='custom' && !model.value?.others.find(({ label })=>label === model.value?.current.label.trim()) && model.value.current.label !== ''
? {
attributes: {
title: `Delete ${model.value.current.label}`,
icon: 'bi-trash',
destructive: true
},
onClick: () => {
if (!model.value) return;
model.value.current.label = ''
isEditing.value = false
}
} as const
: model.value.current.label !== match.value?.label && model.value.current.type === 'custom' && model.value.current.label.trim() !== '' && !model.value?.others.find(({ label })=>label === model.value?.current.label.trim())
? {
attributes: {
title: `Add ${model.value.current.label}`,
icon: 'bi-plus',
'aria-pressed': !match.value,
primary: true
},
onClick: () => {
if (!model.value
|| model.value.others.find(({ label })=>label === model.value?.current.label.trim())
) return
model.value.others.push({...model.value.current})
}
} as const
: undefined
))
</script>
<template>
<component
:is="model ? 'label' : 'button'"
class="funkwhale pill"
:class="props.noUnderline && 'no-underline'"
:type="model ? undefined : 'button'"
v-bind="color(props, ['interactive', 'secondary'])()"
@click.stop="handleClick"
<button
:class="['funkwhale', $style.pill, props.noUnderline && $style['no-underline']]"
type="button"
@click="clicked"
>
<div
v-if="!!$slots.image"
class="pill-image"
<Layout
flex
no-wrap
gap-4
:class="$style.container"
v-bind="color(props, ['solid', 'interactive', 'secondary'])()"
>
<slot name="image" />
</div>
<!-- TODO: Sanitize text on blur? -->
<span
v-if="model !== undefined"
ref="input"
contenteditable
class="pill-content"
style="flex-grow: 1; text-align: start;"
@keydown.enter.prevent="sanitizeAndBlur"
@keyup.space.prevent="sanitizeAndBlur"
@keyup.,.prevent="sanitizeAndBlur"
@blur="sanitize"
>
{{ model }}
</span>
<div
v-if="!!$slots.default"
class="pill-content"
>
<slot />
</div>
</component>
<!-- Image -->
<div
v-if="!!$slots.image"
:class="$style['pill-image']"
>
<slot name="image" />
</div>
<!-- Preset content -->
<div :class="$style['pill-content']">
<slot />
{{ model?.current?.label }}
<Popover
v-if="model"
v-model="isEditing"
>
<div />
<template #items>
<!-- Current item -->
<PopoverItem>
<Input
v-model="model.current.label"
autofocus
low-height
:class="$style.input"
@keydown.enter.stop.prevent="pressedKey"
@keydown="pressedKey"
/>
<template #after>
<Button
v-if="current"
ghost
v-bind="current?.attributes"
square-small
style="border-radius: 4px;"
:class="$style['input-delete-button']"
@click.stop.prevent="current?.onClick"
/>
</template>
</PopoverItem>
<hr>
<!-- Other items, Sorted by matchingness -->
<PopoverItem
v-for="[, option] in sortedOthers"
:key="option.label"
:aria-pressed="other(option).item.isMatch || other(option).item.isSame || undefined"
@click.stop.prevent="other(option).item.onClick"
>
<span :class="other(option).item.isMatch && $style.match">
{{ option.label }}
</span>
<template #after>
<Button
v-if="other(option).action"
round
ghost
square-small
destructive
:title="other(option).action?.title"
:icon="other(option).action?.icon"
@click.stop.prevent="other(option).action?.onClick"
/>
</template>
</PopoverItem>
<hr>
<PopoverItem
@click.stop.prevent="canceled"
>
Cancel
</PopoverItem>
</template>
</Popover>
</div>
<!-- Action -->
<label
v-if="!!$slots.action"
:class="$style['pill-action']"
>
<slot name="action" />
</label>
</Layout>
</button>
</template>
<style lang="scss">
@import './pill.scss';
label.funkwhale.pill.pill {
min-width: 40px;
min-height: 28px;
white-space: pre;
cursor: text;
> span[contenteditable] {
// Note that <Input>s can't be styled with `min-width` or `width` directly.
// SOLUTION: Contenteditable. Everything else is a dirty hack.
border-radius:16px;
outline: 1px solid transparent;
<style module lang="scss">
.pill {
position: relative;
display: block;
appearance: none;
background: transparent;
outline: transparent;
font-size: 12px;
line-height: 16px;
border-radius: 100vh;
// Negative margins for increased interactive area; visual correction for rounded shape
margin: -5px -7px;
padding: 5px;
border-radius: 100vh;
width: fit-content;
> .container {
border-radius: inherit;
> .pill-content {
// 1px border
padding: 4px 9px;
white-space: nowrap;
min-width: 56px;
border-radius: inherit;
//Works as anchor point for popup
position: relative;
&input {
min-width: 44px; flex-basis: 44px;
}
&:focus-visible, &:focus {
outline: 1px solid var(--focus-ring-color);
outline-offset: 2px;
}
&:has(+.pill-action) {
margin-right: -26px;
padding-right: 26px;
}
}
> .pill-image {
position: relative;
border-radius: inherit;
overflow: hidden;
height: 26px;
aspect-ratio: 1;
align-content: center;
> * {
height: 100%;
width: 100%;
}
> i.bi {
font-size: 18px;
}
> img {
object-fit: cover;
}
}
> .pill-action {
position: relative;
width: 44px;
height: 44px;
padding: 9px;
margin: -9px;
aspect-ratio: 1;
border-radius: inherit;
overflow: hidden;
align-content: center;
flex-shrink:0;
> * {
height: 100% !important;
width: 100% !important;
padding: 0 !important;
}
}
}
&:hover:not(.no-underline) {
text-decoration: underline;
}
&[disabled] {
font-weight: normal;
cursor: default;
}
&.is-focused,
&:focus {
box-shadow: none !important;
}
}
.input {
// Position the input label within a 40px high popover item
margin: -4px -16px;
position: relative;
top: -4px;
&:has(+* .input-delete-button:hover) input{
background: var(--background-color);
color: var(--disabled-color) !important;
}
}
}
</style>

View File

@ -1,72 +0,0 @@
.funkwhale {
&.pill {
color: var(--fw-text-color);
// @include dark-theme {
// --fw-darken-pastel: color-mix(in srgb, var(--fw-pastel-4) 90%, black);
// background-color: var(--fw-darken-pastel, var(--fw-bg-color));
// }
position: relative;
display: inline-flex;
font-family: $font-main;
line-height: 1em;
font-size: small;
border-radius: 100vh;
> .pill-content {
padding: 0.45em 0.75em 0.55em 0.75em;
white-space: nowrap;
min-width: 28px;
border-radius: inherit;
&:focus-visible, &:focus {
outline: 1px solid var(--focus-ring-color);
outline-offset: 2px;
}
}
> .pill-image {
position: relative;
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
height: calc(2em - 4px);
margin: 2px;
align-content: center;
+ .pill-content {
padding-left: 0.25em;
}
> * {
height: 100%;
width: 100%;
}
> i.bi {
font-size: 18px;
}
> img {
object-fit: cover;
}
}
&:hover:not(.no-underline) {
text-decoration: underline;
}
&[disabled] {
font-weight: normal;
cursor: default;
}
&.is-focused,
&:focus {
box-shadow: none !important;
}
}
}

View File

@ -4,8 +4,14 @@ import { computed, ref } from 'vue'
import Pill from '~/components/ui/Pill.vue'
import Button from '~/components/ui/Button.vue'
const customTag=ref('Custom Tag')
const isDeleted = computed(() => customTag.value==='' ? 'Pill was deleted' : '')
const customTag = ref({
current: { type: 'custom', label: 'I-am-custom.-Change-me!' },
others: [
{ type: 'preset', label: 'Preset-1' },
{ type: 'preset', label: 'Preset-2' },
{ type: 'preset', label: 'Preset-3' },
]
})
</script>
```ts
@ -141,7 +147,7 @@ Image pills contain a small circular image on their left. These can be used for
```vue-html{2-4}
<Pill>
<template #image>
<div style="background-color: #0004" />
<div style="background-color: #ff0" />
</template>
Awesome artist
</Pill>
@ -149,43 +155,71 @@ Image pills contain a small circular image on their left. These can be used for
<Pill>
<template #image>
<div style="background-color: #0004" />
<div style="background-color: #ff0" />
</template>
Awesome artist
</Pill>
## Editable pill
Add `v-model="..."` to link the pill content to a `ref`.
Add `v-model="..."` to link the pill content to a `ref` with one `current` item and zero or more `others`. Set each item's `type` to `preset` or `custom`.
- The `current` item can be changed by the user.
- The `other` items can be selected instead of the `current`.
- Items with type `custom` can be edited and deleted by the user. `preset` items can only be selected or deselected.
```ts
import { ref } from "vue"
const customTag = ref("Custom Tag")
const customTag = ref({
current: { type: 'custom', label: 'I-am-custom.-Change-me!' },
others: [
{ type: 'preset', label: 'Preset-1' },
{ type: 'preset', label: 'Preset-2' },
{ type: 'preset', label: 'Preset-3' },
]
})
```
```vue-html
<Pill v-model="customTag" />
<Button primary low-height
:disabled="customTag === ''"
:onClick="() => customTag = ''"
>
Reset: {{ customTag }}
</Button>
```
<Pill no-underline v-model="customTag" />
Edit the text, then hit Enter or click outside. The button will show the updated text.
## Add an action
<Button primary ghost icon="bi-trash"/>
```vue-html
<Pill v-model="customTag">
<template #action>
<Button ghost primary round icon="bi-x"
title="Deselect"
@click.stop.prevent="() => {
if (customTag.current.type === 'custom')
customTag.others.push({...customTag.current});
customTag.current = {label: '', type: 'custom'}
}"
/>
</template>
</Pill>
```
<!-- prettier-ignore-start -->
<Button primary low-height
:disabled="customTag === ''"
:onClick="() => customTag = ''"
>
Reset: {{ customTag }}
</Button>
<Pill v-model="customTag">
<template #action>
<Button ghost primary round icon="bi-x"
title="Deselect"
@click.stop.prevent="() => {
if (customTag.current.type === 'custom')
customTag.others.push({...customTag.current});
customTag.current = {label: '', type: 'custom'}
}"
/>
</template>
</Pill>
{{ customTag }}
<!-- prettier-ignore-end -->