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