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">
|
<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'
|
import { type ColorProps, type PastelProps, type VariantProps, type RaisedProps, color } from '~/composables/color'
|
||||||
|
|
||||||
const input = ref<HTMLInputElement>()
|
import Layout from './Layout.vue'
|
||||||
const emit = defineEmits<{
|
import Button from './Button.vue'
|
||||||
click: [event: MouseEvent]
|
import Input from './Input.vue'
|
||||||
}>()
|
import Popover from './Popover.vue'
|
||||||
const handleClick = (event: MouseEvent) => {
|
import PopoverItem from './popover/PopoverItem.vue'
|
||||||
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>()
|
|
||||||
|
|
||||||
onMounted(() => {
|
/* Model */
|
||||||
if (props.autofocus) input.value?.focus()
|
|
||||||
|
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')
|
/* Update */
|
||||||
model.value = model.value?.replace(',', '')?.trim()
|
|
||||||
|
const clicked = () => {
|
||||||
|
if (!model.value) return
|
||||||
|
if (!isEditing.value) {
|
||||||
|
isEditing.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizeAndBlur = () => {
|
const pressedKey = (e: KeyboardEvent) => {
|
||||||
sanitize()
|
if (!model.value) return
|
||||||
input.value?.blur()
|
|
||||||
/* and bubble a confirmation event */
|
// 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<button
|
||||||
:is="model ? 'label' : 'button'"
|
:class="['funkwhale', $style.pill, props.noUnderline && $style['no-underline']]"
|
||||||
class="funkwhale pill"
|
type="button"
|
||||||
:class="props.noUnderline && 'no-underline'"
|
@click="clicked"
|
||||||
:type="model ? undefined : 'button'"
|
|
||||||
v-bind="color(props, ['interactive', 'secondary'])()"
|
|
||||||
@click.stop="handleClick"
|
|
||||||
>
|
>
|
||||||
|
<Layout
|
||||||
|
flex
|
||||||
|
no-wrap
|
||||||
|
gap-4
|
||||||
|
:class="$style.container"
|
||||||
|
v-bind="color(props, ['solid', 'interactive', 'secondary'])()"
|
||||||
|
>
|
||||||
|
<!-- Image -->
|
||||||
<div
|
<div
|
||||||
v-if="!!$slots.image"
|
v-if="!!$slots.image"
|
||||||
class="pill-image"
|
:class="$style['pill-image']"
|
||||||
>
|
>
|
||||||
<slot name="image" />
|
<slot name="image" />
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO: Sanitize text on blur? -->
|
|
||||||
<span
|
<!-- Preset content -->
|
||||||
v-if="model !== undefined"
|
<div :class="$style['pill-content']">
|
||||||
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 />
|
<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>
|
</div>
|
||||||
</component>
|
|
||||||
|
<!-- Action -->
|
||||||
|
<label
|
||||||
|
v-if="!!$slots.action"
|
||||||
|
:class="$style['pill-action']"
|
||||||
|
>
|
||||||
|
<slot name="action" />
|
||||||
|
</label>
|
||||||
|
</Layout>
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style module lang="scss">
|
||||||
@import './pill.scss';
|
.pill {
|
||||||
label.funkwhale.pill.pill {
|
position: relative;
|
||||||
min-width: 40px;
|
display: block;
|
||||||
min-height: 28px;
|
appearance: none;
|
||||||
white-space: pre;
|
background: transparent;
|
||||||
cursor: text;
|
outline: transparent;
|
||||||
> span[contenteditable] {
|
|
||||||
// Note that <Input>s can't be styled with `min-width` or `width` directly.
|
font-size: 12px;
|
||||||
// SOLUTION: Contenteditable. Everything else is a dirty hack.
|
line-height: 16px;
|
||||||
border-radius:16px;
|
|
||||||
outline: 1px solid transparent;
|
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>
|
</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 Pill from '~/components/ui/Pill.vue'
|
||||||
import Button from '~/components/ui/Button.vue'
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
const customTag=ref('Custom Tag')
|
const customTag = ref({
|
||||||
const isDeleted = computed(() => customTag.value==='' ? 'Pill was deleted' : '')
|
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>
|
</script>
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
@ -141,7 +147,7 @@ Image pills contain a small circular image on their left. These can be used for
|
||||||
```vue-html{2-4}
|
```vue-html{2-4}
|
||||||
<Pill>
|
<Pill>
|
||||||
<template #image>
|
<template #image>
|
||||||
<div style="background-color: #0004" />
|
<div style="background-color: #ff0" />
|
||||||
</template>
|
</template>
|
||||||
Awesome artist
|
Awesome artist
|
||||||
</Pill>
|
</Pill>
|
||||||
|
@ -149,43 +155,71 @@ Image pills contain a small circular image on their left. These can be used for
|
||||||
|
|
||||||
<Pill>
|
<Pill>
|
||||||
<template #image>
|
<template #image>
|
||||||
<div style="background-color: #0004" />
|
<div style="background-color: #ff0" />
|
||||||
</template>
|
</template>
|
||||||
Awesome artist
|
Awesome artist
|
||||||
</Pill>
|
</Pill>
|
||||||
|
|
||||||
## Editable 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
|
```ts
|
||||||
import { ref } from "vue"
|
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
|
```vue-html
|
||||||
<Pill v-model="customTag" />
|
<Pill v-model="customTag" />
|
||||||
|
|
||||||
<Button primary low-height
|
|
||||||
:disabled="customTag === ''"
|
|
||||||
:onClick="() => customTag = ''"
|
|
||||||
>
|
|
||||||
Reset: {{ customTag }}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<Pill no-underline v-model="customTag" />
|
<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 -->
|
<!-- prettier-ignore-start -->
|
||||||
|
|
||||||
<Button primary low-height
|
<Pill v-model="customTag">
|
||||||
:disabled="customTag === ''"
|
<template #action>
|
||||||
:onClick="() => customTag = ''"
|
<Button ghost primary round icon="bi-x"
|
||||||
>
|
title="Deselect"
|
||||||
Reset: {{ customTag }}
|
@click.stop.prevent="() => {
|
||||||
</Button>
|
if (customTag.current.type === 'custom')
|
||||||
|
customTag.others.push({...customTag.current});
|
||||||
|
customTag.current = {label: '', type: 'custom'}
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Pill>
|
||||||
|
|
||||||
|
{{ customTag }}
|
||||||
|
|
||||||
<!-- prettier-ignore-end -->
|
<!-- prettier-ignore-end -->
|
||||||
|
|
Loading…
Reference in New Issue