feat(ui): users can change pill content with presets and custom labels
This commit is contained in:
parent
56fff9d583
commit
3ffa784027
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 -->
|
||||
|
|
Loading…
Reference in New Issue