feat(ui): Pills list done 💊💊

This commit is contained in:
upsiflu 2025-03-12 20:18:49 +01:00
parent 9b02f1840e
commit d0f42437ae
4 changed files with 234 additions and 210 deletions

View File

@ -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 }} &ZeroWidthSpace;
{{ currentItem?.label }} {{ `&ZeroWidthSpace;${''}` }}
<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>

View File

@ -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>

View File

@ -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 -->

View File

@ -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"
/>