feat(ui): Pills list done 💊💊
This commit is contained in:
parent
9b02f1840e
commit
d0f42437ae
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import { ref, watch, nextTick, computed, onMounted } from 'vue'
|
||||
import { type ColorProps, type PastelProps, type VariantProps, type RaisedProps, color } from '~/composables/color'
|
||||
|
||||
import Layout from './Layout.vue'
|
||||
|
@ -11,13 +11,16 @@ import PopoverItem from './popover/PopoverItem.vue'
|
|||
/* Event */
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
confirmed: [],
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
/* Model */
|
||||
|
||||
const props = defineProps<{
|
||||
noUnderline?:true
|
||||
noUnderline?: true,
|
||||
cancel?: string,
|
||||
autofocus?: boolean
|
||||
} & (PastelProps | ColorProps)
|
||||
& VariantProps
|
||||
& RaisedProps
|
||||
|
@ -25,25 +28,34 @@ const props = defineProps<{
|
|||
|
||||
type Item = { type: 'custom' | 'preset', label: string }
|
||||
|
||||
const model = defineModel<{
|
||||
current: Item,
|
||||
others: Item[]
|
||||
}>()
|
||||
const currentItem = defineModel<Item>('current'),
|
||||
otherItems = defineModel<Item[]>('others')
|
||||
|
||||
const isEditing = ref<boolean>(false)
|
||||
|
||||
/* Lifecycle */
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
nextTick(() => {
|
||||
if (!currentItem.value || !otherItems.value) return
|
||||
clicked()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let previousValue: Item | undefined
|
||||
|
||||
let previouslyFocusedElement: Element | null
|
||||
|
||||
watch(isEditing, (isTrue, wasTrue) => {
|
||||
if (!model.value) return
|
||||
if (!currentItem.value || !otherItems.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'
|
||||
previousValue = { ...currentItem.value }
|
||||
if (currentItem.value.type === 'preset') {
|
||||
otherItems.value.push({...currentItem.value})
|
||||
currentItem.value.type = 'custom'
|
||||
}
|
||||
// Shift focus between the input and the previously focused element
|
||||
previouslyFocusedElement = document.activeElement
|
||||
|
@ -51,29 +63,28 @@ watch(isEditing, (isTrue, wasTrue) => {
|
|||
nextTick(() => (previouslyFocusedElement as HTMLElement)?.focus())
|
||||
|
||||
const matchInOthers
|
||||
= model.value?.others.find(({ label })=>label === model.value?.current.label.trim())
|
||||
= otherItems.value.find(({ label }) => label === currentItem.value?.label.trim())
|
||||
|
||||
if (matchInOthers) {
|
||||
model.value.current = { ...matchInOthers }
|
||||
currentItem.value = { ...matchInOthers }
|
||||
}
|
||||
model.value.others = model.value.others.filter(({ label }) => label !== model.value?.current.label)
|
||||
otherItems.value = otherItems.value.filter(({ label }) => label !== currentItem.value?.label)
|
||||
|
||||
emit('changed')
|
||||
emit('closed')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
/* Update */
|
||||
|
||||
const clicked = () => {
|
||||
if (!model.value) return
|
||||
if (!currentItem.value || !otherItems.value) return
|
||||
if (!isEditing.value) {
|
||||
isEditing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const pressedKey = (e: KeyboardEvent) => {
|
||||
if (!model.value) return
|
||||
if (!currentItem.value || !otherItems.value) return
|
||||
|
||||
// confirm or cancel
|
||||
switch (e.key) {
|
||||
|
@ -90,14 +101,19 @@ const pressedKey = (e: KeyboardEvent) => {
|
|||
}
|
||||
}
|
||||
|
||||
const releasedKey = () => {
|
||||
if (!otherItems.value || !currentItem.value) return
|
||||
currentItem.value.label = currentItem.value.label.replace(',', '').replace(' ', '').trim()
|
||||
}
|
||||
|
||||
const canceled = () => {
|
||||
if (!previousValue || !model.value) return
|
||||
if (!previousValue || !currentItem.value || !otherItems.value) return
|
||||
|
||||
const matchInOthers
|
||||
= model.value?.others.find(({ label })=>label === model.value?.current.label.trim())
|
||||
= otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim())
|
||||
|
||||
// Reset current label
|
||||
model.value.current
|
||||
currentItem.value
|
||||
= matchInOthers
|
||||
|| {...previousValue}
|
||||
|
||||
|
@ -106,35 +122,37 @@ const canceled = () => {
|
|||
}
|
||||
|
||||
const confirmed = () => {
|
||||
if (!previousValue || !model.value) return;
|
||||
if (!previousValue || !currentItem.value || !otherItems.value) return
|
||||
|
||||
const matchInOthers
|
||||
= model.value?.others.find(({ label })=>label === model.value?.current.label.trim())
|
||||
// Sanitize label
|
||||
currentItem.value.label = currentItem.value.label.replace(',', '').replace(' ', '').trim()
|
||||
|
||||
// Use the best match; otherwise the current input, sanitized
|
||||
model.value.current
|
||||
= matchInOthers
|
||||
// Apply the identical, otherwise the best match, if available
|
||||
currentItem.value
|
||||
= otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim())
|
||||
|| match.value
|
||||
|| { ...model.value.current,
|
||||
label : model.value.current.label.replace(',', '').replace(' ', '').trim()
|
||||
}
|
||||
|| currentItem.value
|
||||
|
||||
// Close dropdown
|
||||
isEditing.value = false
|
||||
|
||||
// Tell parent component
|
||||
if (previousValue !== currentItem.value)
|
||||
emit('confirmed')
|
||||
}
|
||||
|
||||
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
|
||||
otherItems.value && currentItem.value
|
||||
? otherItems.value.map((item) =>
|
||||
item.label.toLowerCase().includes(currentItem.value?.label.toLowerCase() || '')
|
||||
? [item.label.length - (currentItem.value?.label.length || 0), item] as const /* TODO: Use a more sophisticated algorithm for suggestions */
|
||||
: [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
|
||||
index===0 && delta < 99 && currentItem.value && currentItem.value.label.length>0 && currentItem.value.label !== previousValue?.label
|
||||
? [-1, item] as const /* It's a match */
|
||||
: [delta, item] as const /* It's not a match */
|
||||
)
|
||||
|
@ -147,95 +165,83 @@ const match = computed(()=>
|
|||
: 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
|
||||
const other = computed(() => (option: Item) => ({
|
||||
item: {
|
||||
onClick: () => {
|
||||
if (!currentItem.value || !otherItems.value) return;
|
||||
currentItem.value = { ...option, type: 'custom' }
|
||||
otherItems.value = [...(
|
||||
currentItem.value.label.trim() === '' || otherItems.value.find(({ label }) => label === currentItem.value?.label.trim())
|
||||
? []
|
||||
: [{ ...currentItem.value }]
|
||||
), ...otherItems.value.filter(
|
||||
({ label, type }) => label !== option.label || type === 'preset'
|
||||
)]
|
||||
isEditing.value = false
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
isMatch: match.value?.label === option.label,
|
||||
isSame: option.label === currentItem.value?.label
|
||||
},
|
||||
action: option.type === 'custom'
|
||||
? {
|
||||
title: 'Delete custom',
|
||||
icon: 'bi-trash',
|
||||
onClick: () => {
|
||||
if (!currentItem.value || !otherItems.value) return;
|
||||
otherItems.value = otherItems.value.filter(({ label }) => label !== option.label)
|
||||
}
|
||||
: undefined
|
||||
} as const
|
||||
))
|
||||
}
|
||||
: undefined
|
||||
} as const))
|
||||
|
||||
const current = computed(() => (
|
||||
!model.value
|
||||
!currentItem.value || !otherItems.value
|
||||
? undefined
|
||||
: model.value.current.label === '' && previousValue?.label !== ''
|
||||
: currentItem.value.label === '' && previousValue?.label !== ''
|
||||
? {
|
||||
attributes: {
|
||||
title: `Reset to ${previousValue?.label || model.value.current}`,
|
||||
title: `Reset to ${previousValue?.label || currentItem.value}`,
|
||||
icon: 'bi-arrow-counterclockwise'
|
||||
},
|
||||
onClick: () => {
|
||||
if (!model.value) return;
|
||||
model.value = {
|
||||
...model.value,
|
||||
current: previousValue || model.value.current
|
||||
}
|
||||
if (!currentItem.value || !otherItems.value) return;
|
||||
currentItem.value = previousValue || currentItem.value
|
||||
}
|
||||
} 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 !== ''
|
||||
: currentItem.value.label === previousValue?.label && currentItem.value.type==='custom' && !otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim()) && currentItem.value.label !== ''
|
||||
? {
|
||||
attributes: {
|
||||
title: `Delete ${model.value.current.label}`,
|
||||
title: `Delete ${currentItem.value.label}`,
|
||||
icon: 'bi-trash',
|
||||
destructive: true
|
||||
},
|
||||
onClick: () => {
|
||||
if (!model.value) return;
|
||||
model.value.current.label = ''
|
||||
if (!currentItem.value || !otherItems.value) return;
|
||||
currentItem.value.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())
|
||||
: currentItem.value.label !== match.value?.label && currentItem.value.type === 'custom' && currentItem.value.label.trim() !== '' && !otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim())
|
||||
? {
|
||||
attributes: {
|
||||
title: `Add ${model.value.current.label}`,
|
||||
title: `Add ${currentItem.value.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})
|
||||
if (!otherItems.value || !currentItem.value || otherItems.value.find(({ label })=>label === currentItem.value?.label.trim())) return
|
||||
otherItems.value.push({...currentItem.value})
|
||||
}
|
||||
} as const
|
||||
: undefined
|
||||
))
|
||||
))
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="['funkwhale', $style.pill, props.noUnderline && $style['no-underline']]"
|
||||
:class="['funkwhale', $style.pill, (props.noUnderline || currentItem) && $style['no-underline']]"
|
||||
type="button"
|
||||
@click="clicked"
|
||||
>
|
||||
|
@ -257,9 +263,9 @@ const current = computed(() => (
|
|||
<!-- Preset content -->
|
||||
<div :class="$style['pill-content']">
|
||||
<slot />
|
||||
{{ model?.current?.label }} ​
|
||||
{{ currentItem?.label }} {{ `​${''}` }}
|
||||
<Popover
|
||||
v-if="model"
|
||||
v-if="currentItem && otherItems"
|
||||
v-model="isEditing"
|
||||
>
|
||||
<div />
|
||||
|
@ -268,12 +274,13 @@ const current = computed(() => (
|
|||
|
||||
<PopoverItem>
|
||||
<Input
|
||||
v-model="model.current.label"
|
||||
v-model="currentItem.label"
|
||||
autofocus
|
||||
low-height
|
||||
:class="$style.input"
|
||||
@keydown.enter.stop.prevent="pressedKey"
|
||||
@keydown="pressedKey"
|
||||
@keyup="releasedKey"
|
||||
/>
|
||||
<template #after>
|
||||
<Button
|
||||
|
@ -317,9 +324,10 @@ const current = computed(() => (
|
|||
<hr>
|
||||
|
||||
<PopoverItem
|
||||
v-if="cancel"
|
||||
@click.stop.prevent="canceled"
|
||||
>
|
||||
Cancel
|
||||
{{ cancel }}
|
||||
</PopoverItem>
|
||||
</template>
|
||||
</Popover>
|
||||
|
|
|
@ -1,27 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import { color } from '~/composables/color'
|
||||
|
||||
import Pill from './Pill.vue'
|
||||
import Layout from './Layout.vue'
|
||||
import Button from './Button.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
icon?: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
cancel?: string,
|
||||
}>()
|
||||
|
||||
const model = defineModel<{
|
||||
currents: Item[],
|
||||
others?: Item[],
|
||||
}>({ required: true })
|
||||
const model = defineModel<Model>({ required: true })
|
||||
|
||||
type Item = { type: 'custom' | 'preset', label: string }
|
||||
|
||||
// Manually trigger rerendering
|
||||
const componentKey = ref(0)
|
||||
const forceRerender = () => componentKey.value++
|
||||
type Model = { currents: Item[], others?: Item[] }
|
||||
|
||||
const isStatic = computed(() =>
|
||||
!model.value.others
|
||||
|
@ -31,47 +27,20 @@ const emptyItem = {
|
|||
label: '', type: 'custom'
|
||||
} as const
|
||||
|
||||
const pills = computed({
|
||||
const nextIndex = ref<number | undefined>(undefined)
|
||||
|
||||
get: () => {
|
||||
console.log("OTHERS", model.value.others)
|
||||
return [...model.value.currents, { ...emptyItem }].map(
|
||||
(item) => ({ current: { ...item }, others: model.value.others ? model.value.others.map(item => ({ ...item })) : [] })
|
||||
)
|
||||
},
|
||||
set: (pills) => {
|
||||
console.log("SETTING PILLS", pills)
|
||||
model.value.currents = pills
|
||||
.filter(({ current }) => current.label !== '')
|
||||
.map(({ current }) => ({ ...current }))
|
||||
}
|
||||
})
|
||||
|
||||
const changed = (index: number, pill: (typeof pills.value)[number]) => {
|
||||
/*reduce<U>(
|
||||
callbackfn: (previousValue: U, currentValue: Item, currentIndex: number, array: Item[]) => U,
|
||||
initialValue: U): U */
|
||||
|
||||
console.log("NEW: #", index, "=", pills.value[index].current)
|
||||
console.log("OLD: #", index, "=", pill.current)
|
||||
// model.value.currents.push({ ...emptyItem })
|
||||
// console.log(model.value.currents.length)
|
||||
|
||||
model.value.currents[index] = { ...pills.value[index].current }
|
||||
model.value.others = { ...pills.value[index].others }
|
||||
|
||||
forceRerender()
|
||||
const sanitize = () => {
|
||||
if (model.value.others)
|
||||
model.value.currents = [...model.value.currents.filter(({ label }) => label !== ''), { ...emptyItem }]
|
||||
}
|
||||
|
||||
// Add empty current item if none is inside
|
||||
watch( model, () => {
|
||||
console.log("MODEL CHANGED", model.value)
|
||||
const next = (index: number) => nextTick(() => { nextIndex.value = index + 1 })
|
||||
|
||||
watch(model, () => {
|
||||
sanitize()
|
||||
})
|
||||
|
||||
// Remove duplicates on each CONFIRM
|
||||
|
||||
// const unique = (list:Item[]) =>
|
||||
// list.reduce((acc, a) => ([a, ...acc]), [])
|
||||
sanitize();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -94,8 +63,6 @@ watch( model, () => {
|
|||
:class="$style.label"
|
||||
>
|
||||
{{ props.label }}
|
||||
<div>PILLS {{ pills.map(({ current })=>current.label) }}</div>
|
||||
<div>MODEL {{ model.currents.map(({ label })=>label) }}</div>
|
||||
</span>
|
||||
|
||||
<!-- List of Pills -->
|
||||
|
@ -107,19 +74,46 @@ watch( model, () => {
|
|||
:class="$style.list"
|
||||
>
|
||||
<Pill
|
||||
v-for="(pill, index) in pills"
|
||||
:key="index*1000+componentKey"
|
||||
v-model="pills[index]"
|
||||
v-for="(_, index) in model.currents"
|
||||
:key="index+1000*(nextIndex || 0)"
|
||||
v-model:current="model.currents[index]"
|
||||
v-model:others="model.others"
|
||||
:cancel="cancel"
|
||||
:autofocus="index === nextIndex && nextIndex < model.currents.length"
|
||||
outline
|
||||
no-underline
|
||||
:class="[$style.pill, $style[isStatic ? 'static' : pill.current.label === '' ? 'empty' : pill.current.type === 'custom' ? 'custom' : 'preset']]"
|
||||
@changed="() => { console.log('CCCCCC', index); changed(index, pill) }"
|
||||
:class="[$style.pill, $style[
|
||||
isStatic
|
||||
? 'static'
|
||||
: model.currents[index].label === ''
|
||||
? 'empty'
|
||||
: model.currents[index].type
|
||||
]]"
|
||||
@closed="() => { sanitize() }"
|
||||
@confirmed="() => { next(index) }"
|
||||
>
|
||||
<span
|
||||
v-if="isStatic"
|
||||
:class="$style['pill-content']"
|
||||
>{{ pill.current.label }}</span>
|
||||
{{ `${index} ${componentKey}` }}
|
||||
>{{ model.currents[index].label }}</span>
|
||||
<template
|
||||
v-if="model.others && model.currents[index].label !== ''"
|
||||
#action
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
primary
|
||||
round
|
||||
icon="bi-x"
|
||||
title="Deselect"
|
||||
@click.stop.prevent="() => {
|
||||
if (!model.others) return
|
||||
model.others.push({...model.currents[index]});
|
||||
model.currents[index] = {label: '', type: 'custom'}
|
||||
sanitize()
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</Pill>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
|
|
@ -4,14 +4,12 @@ import { computed, ref } from 'vue'
|
|||
import Pill from '~/components/ui/Pill.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const customTag = ref({
|
||||
current: { type: 'custom', label: 'I-am-custom.-Change-me!' },
|
||||
others: [
|
||||
const current = ref({ type: 'custom', label: 'I-am-custom.-Change-me!' })
|
||||
const others = ref([
|
||||
{ type: 'preset', label: 'Preset-1' },
|
||||
{ type: 'preset', label: 'Preset-2' },
|
||||
{ type: 'preset', label: 'Preset-3' },
|
||||
]
|
||||
})
|
||||
])
|
||||
</script>
|
||||
|
||||
```ts
|
||||
|
@ -170,28 +168,35 @@ Add `v-model="..."` to link the pill content to a `ref` with one `current` item
|
|||
|
||||
```ts
|
||||
import { ref } from "vue"
|
||||
const customTag = ref({
|
||||
current: { type: 'custom', label: 'I-am-custom.-Change-me!' },
|
||||
others: [
|
||||
const current = ref({ type: 'custom', label: 'I-am-custom.-Change-me!' })
|
||||
const others = ref([
|
||||
{ type: 'preset', label: 'Preset-1' },
|
||||
{ type: 'preset', label: 'Preset-2' },
|
||||
{ type: 'preset', label: 'Preset-3' },
|
||||
]
|
||||
})
|
||||
])
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pill v-model="customTag" />
|
||||
<Pill
|
||||
v-model:current="current"
|
||||
v-model:others="others"
|
||||
/>
|
||||
```
|
||||
|
||||
<Pill no-underline v-model="customTag" />
|
||||
<Pill
|
||||
v-model:current="current"
|
||||
v-model:others="others"
|
||||
/>
|
||||
|
||||
## Add an action
|
||||
|
||||
<Button primary ghost icon="bi-trash"/>
|
||||
|
||||
```vue-html
|
||||
<Pill v-model="customTag">
|
||||
<Pill
|
||||
v-model:current="current"
|
||||
v-model:others="others"
|
||||
>
|
||||
<template #action>
|
||||
<Button ghost primary round icon="bi-x"
|
||||
title="Deselect"
|
||||
|
@ -207,19 +212,22 @@ const customTag = ref({
|
|||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
<Pill v-model="customTag">
|
||||
<Pill
|
||||
v-model:current="current"
|
||||
v-model:others="others"
|
||||
>
|
||||
<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'}
|
||||
if (current.type === 'custom')
|
||||
others.push({...current});
|
||||
current = {label: '', type: 'custom'}
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</Pill>
|
||||
|
||||
{{ customTag }}
|
||||
{{ current }} (+ {{ others.length }} other options)
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
import Pills from '~/components/ui/Pills.vue';
|
||||
import Spacer from '~/components/ui/Spacer.vue';
|
||||
|
||||
type Item = { type: 'custom' | 'preset', label: string }
|
||||
type Model = {
|
||||
|
@ -15,25 +16,22 @@ const nullModel = ref({
|
|||
|
||||
const staticModel = ref({
|
||||
currents: [
|
||||
{ label: "#Noise", type: 'preset' },
|
||||
{ label: "#FieldRecording", type: 'preset' },
|
||||
{ label: "#Experiment", type: 'preset' }
|
||||
{ label: "#noise", type: 'preset' },
|
||||
{ label: "#fieldRecording", type: 'preset' },
|
||||
{ label: "#experiment", type: 'preset' }
|
||||
]
|
||||
} satisfies Model);
|
||||
|
||||
const interactiveModel = ref({
|
||||
...staticModel.value,
|
||||
others: [
|
||||
{ label: "#Melody", type: 'preset' },
|
||||
{ label: "#Rhythm", type: 'preset' }
|
||||
]
|
||||
} satisfies Model);
|
||||
const simpleCustomModel = ref({
|
||||
currents: [],
|
||||
others: []
|
||||
})
|
||||
|
||||
const customModel = ref({
|
||||
...staticModel.value,
|
||||
others: [
|
||||
{ label: "#MyTag1", type: 'custom' },
|
||||
{ label: "#MyTag2", type: 'custom' }
|
||||
{ label: "#myTag1", type: 'custom' },
|
||||
{ label: "#myTag2", type: 'custom' }
|
||||
]
|
||||
} satisfies Model);
|
||||
</script>
|
||||
|
@ -57,12 +55,20 @@ Each item has a `label` of type `string` as well as a `type` of either:
|
|||
- `custom`: the user can edit its label or
|
||||
- `preset`: the user cannot edit its label
|
||||
|
||||
```ts
|
||||
type Item = { type: 'custom' | 'preset', label: string }
|
||||
type Model = {
|
||||
currents: Item[],
|
||||
others?: Item[],
|
||||
}
|
||||
```
|
||||
|
||||
## No pills
|
||||
|
||||
```ts
|
||||
const nullModel = ref({
|
||||
currents: []
|
||||
}) as { currents: Item[] };
|
||||
}) satisfies Model;
|
||||
```
|
||||
|
||||
```vue-html
|
||||
|
@ -71,48 +77,46 @@ const nullModel = ref({
|
|||
|
||||
<Pills v-model="nullModel" />
|
||||
|
||||
## Predefined list of pills
|
||||
## Static list of pills
|
||||
|
||||
```ts
|
||||
const staticModel = ref({
|
||||
currents: [
|
||||
{ label: "#Noise", type: 'preset' },
|
||||
{ label: "#FieldRecording", type: 'preset' },
|
||||
{ label: "#Experiment", type: 'preset' }
|
||||
{ label: "#noise", type: 'preset' },
|
||||
{ label: "#fieldRecording", type: 'preset' },
|
||||
{ label: "#experiment", type: 'preset' }
|
||||
]
|
||||
});
|
||||
} satisfies Model);
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pills v-model="staticModel" label="Static Tags" />
|
||||
<Pills v-model="staticModel"/>
|
||||
```
|
||||
|
||||
<Pills v-model="staticModel" label="Static Tags" />
|
||||
|
||||
## Let users select and unselect pills
|
||||
|
||||
Select a set of pills from presets, and add and remove custom ones
|
||||
|
||||
```ts
|
||||
|
||||
const interactiveModel = ref({
|
||||
...staticModel,
|
||||
others: [
|
||||
{ label: "#Melody", type: 'preset' },
|
||||
{ label: "#Rhythm", type: 'preset' }
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pills v-model="interactiveModel" label="Interactive Tags" />
|
||||
```
|
||||
|
||||
<Pills v-model="interactiveModel" label="Interactive Tags" />
|
||||
<Pills v-model="staticModel"/>
|
||||
|
||||
## Let users add, remove and edit custom pills
|
||||
|
||||
Use [reactive](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#reactive-variables-with-ref) methods [such as `computed(...)`](https://vuejs.org/guide/essentials/computed.html) and `watch(...)` to query the model.
|
||||
By adding `custom` options, you make the `Pills` instance interactive. Use [reactive](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#reactive-variables-with-ref) methods [such as `computed(...)`](https://vuejs.org/guide/essentials/computed.html) and `watch(...)` to bind the model.
|
||||
|
||||
Note that this component will automatically add an empty pill to the end of the model because it made the implementation more straightforward. Use `filter(({ label }) => label !== '') to ignore it when reading the model.
|
||||
|
||||
### Minimal example
|
||||
|
||||
```ts
|
||||
const simpleCustomModel = ref({
|
||||
currents: [],
|
||||
others: []
|
||||
})
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pills v-model="simpleCustomModel"/>
|
||||
```
|
||||
|
||||
<Pills v-model="simpleCustomModel"/>
|
||||
|
||||
### Complex example
|
||||
|
||||
```ts
|
||||
const customModel = ref({
|
||||
|
@ -121,11 +125,21 @@ const customModel = ref({
|
|||
{ label: "#MyTag1", type: 'custom' },
|
||||
{ label: "#MyTag2", type: 'custom' }
|
||||
]
|
||||
});
|
||||
} satisfies Model);
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pills v-model="customModel" label="Custom Tags" />
|
||||
<Pills
|
||||
v-model="customModel"
|
||||
label="Custom Tags"
|
||||
cancel="Cancel"
|
||||
/>
|
||||
```
|
||||
|
||||
<Pills v-model="customModel" label="Custom Tags" />
|
||||
<Spacer />
|
||||
|
||||
<Pills
|
||||
v-model="customModel"
|
||||
label="Custom Tags"
|
||||
cancel="Cancel"
|
||||
/>
|
||||
|
|
Loading…
Reference in New Issue