feat(ui): [WIP] Pills list

This commit is contained in:
upsiflu 2025-03-12 14:58:07 +01:00
parent 713c2fe34f
commit 9b02f1840e
3 changed files with 125 additions and 340 deletions

View File

@ -8,6 +8,12 @@ import Input from './Input.vue'
import Popover from './Popover.vue' import Popover from './Popover.vue'
import PopoverItem from './popover/PopoverItem.vue' import PopoverItem from './popover/PopoverItem.vue'
/* Event */
const emit = defineEmits<{
changed: []
}>()
/* Model */ /* Model */
const props = defineProps<{ const props = defineProps<{
@ -51,6 +57,8 @@ watch(isEditing, (isTrue, wasTrue) => {
model.value.current = { ...matchInOthers } model.value.current = { ...matchInOthers }
} }
model.value.others = model.value.others.filter(({ label }) => label !== model.value?.current.label) model.value.others = model.value.others.filter(({ label }) => label !== model.value?.current.label)
emit('changed')
} }
}) })
@ -249,7 +257,7 @@ const current = computed(() => (
<!-- Preset content --> <!-- Preset content -->
<div :class="$style['pill-content']"> <div :class="$style['pill-content']">
<slot /> <slot />
{{ model?.current?.label }} {{ model?.current?.label }} &ZeroWidthSpace;
<Popover <Popover
v-if="model" v-if="model"
v-model="isEditing" v-model="isEditing"

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watchEffect, watch, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { color } from '~/composables/color' import { color } from '~/composables/color'
@ -13,17 +13,13 @@ const props = defineProps<{
}>() }>()
const model = defineModel<{ const model = defineModel<{
current: string[], currents: Item[],
others?: string[], others?: Item[],
custom?: string[],
}>({ required: true }) }>({ required: true })
const whenInteractive = (then:() => void) => { type Item = { type: 'custom' | 'preset', label: string }
if (!model.value.others) return; then()
}
const editingValue = ref('') // Manually trigger rerendering
const additionalValue = ref('')
const componentKey = ref(0) const componentKey = ref(0)
const forceRerender = () => componentKey.value++ const forceRerender = () => componentKey.value++
@ -31,99 +27,51 @@ const isStatic = computed(() =>
!model.value.others !model.value.others
) )
const selectedLabel = ref('+') const emptyItem = {
label: '', type: 'custom'
} as const
// Dropdown changed -> select label const pills = computed({
whenInteractive(() => get: () => {
watchEffect(() => { console.log("OTHERS", model.value.others)
if (!model.value.others) return return [...model.value.currents, { ...emptyItem }].map(
const newLabel = selectedLabel.value (item) => ({ current: { ...item }, others: model.value.others ? model.value.others.map(item => ({ ...item })) : [] })
selectedLabel.value = '+' )
if (!newLabel || newLabel === '+') return },
if (!model.value.current.includes(newLabel)) { set: (pills) => {
model.value.current.push(newLabel) console.log("SETTING PILLS", pills)
model.value.others = model.value.others.filter(value => value !== newLabel) model.value.currents = pills
.filter(({ current }) => current.label !== '')
.map(({ current }) => ({ ...current }))
} }
}) })
)
// Pill clicked --> edit or unselect label const changed = (index: number, pill: (typeof pills.value)[number]) => {
/*reduce<U>(
callbackfn: (previousValue: U, currentValue: Item, currentIndex: number, array: Item[]) => U,
initialValue: U): U */
const pillClicked = (value: string) => { console.log("NEW: #", index, "=", pills.value[index].current)
model.value.custom?.includes(value) console.log("OLD: #", index, "=", pill.current)
? edit(value) // model.value.currents.push({ ...emptyItem })
: unselect(value) // console.log(model.value.currents.length)
}
const edit = (value: string) => { model.value.currents[index] = { ...pills.value[index].current }
editingValue.value = value model.value.others = { ...pills.value[index].others }
}
const unselect = (value: string) => {
model.value = {
...model.value,
current: model.value.current.filter(v => v !== value),
others: [value, ...(model.value.others || [])]
}
}
// Editing value changed --> remove, add or replace a label
const remove = (value: string) => {
model.value = {
...model.value,
current: model.value.current.filter(v => v !== value),
custom: model.value.custom?.filter(v => v !== value)
}
}
const add = (value: string) => {
if (model.value.current.includes(value)) return
model.value = {
...model.value,
current: [...model.value.current, value],
custom: [...(model.value.custom || []), value]
}
additionalValue.value = ''
// We have to force rerender because else, Vue keeps the previous additionalValue for the new "additionalValue" input pill :-(
forceRerender() forceRerender()
} }
const replace = (value: string) => { // Add empty current item if none is inside
model.value = {
...model.value,
current: model.value.current.map(v => v === value ? editingValue.value : v),
custom: model.value.custom?.map(v => v === value ? editingValue.value : v)
}
}
watch(editingValue, (newValue, oldValue) => {
if (oldValue === '') return
if (newValue === '') {
remove(oldValue)
} else {
replace(oldValue)
}
})
watch(additionalValue, (newValue, oldValue) => {
if (newValue !== '') {
additionalValue.value = ''
add(newValue)
}
})
// Remove duplicates
const unique = (a:string[]) => [...new Set(a)]
watch( model, () => { watch( model, () => {
model.value.current = unique(model.value.current) console.log("MODEL CHANGED", model.value)
model.value.others = model.value.others ? unique(model.value.others) : undefined
model.value.custom = model.value.custom ? unique(model.value.custom) : undefined
}) })
// Remove duplicates on each CONFIRM
// const unique = (list:Item[]) =>
// list.reduce((acc, a) => ([a, ...acc]), [])
</script> </script>
<template> <template>
@ -146,75 +94,33 @@ 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 -->
<Layout <Layout
flex flex
no-gap gap-4
v-bind="color({}, ['solid', 'default', 'secondary'])()" v-bind="color({}, ['solid', 'default', 'secondary'])()"
:class="$style.list" :class="$style.list"
> >
<!-- Add predefined or previously unselected pill -->
<select
v-if="model.others"
v-model="selectedLabel"
name="dropdown"
:class="$style.dropdown"
@change="e => { (e.target as HTMLInputElement).value='+' }"
>
<option value="+" />
<option
v-for="value in model.others"
:key="value"
:value="value"
>
{{ value }}
</option>
</select>
<template
v-for="value in model.current"
:key="value"
>
<!-- List of current pills -->
<Pill <Pill
v-if="value !== editingValue" v-for="(pill, index) in pills"
:key="index*1000+componentKey"
v-model="pills[index]"
outline outline
raised
no-underline no-underline
:class="[$style.pill, $style[isStatic ? 'static' : model.custom?.includes(value) ? 'custom' : 'preset']]" :class="[$style.pill, $style[isStatic ? 'static' : pill.current.label === '' ? 'empty' : pill.current.type === 'custom' ? 'custom' : 'preset']]"
@click="!isStatic && pillClicked(value)" @changed="() => { console.log('CCCCCC', index); changed(index, pill) }"
> >
<span :class="$style['pill-content']">{{ value }}</span> <span
v-if="isStatic"
:class="$style['pill-content']"
>{{ pill.current.label }}</span>
{{ `${index} ${componentKey}` }}
</Pill> </Pill>
<Pill
v-if="value === editingValue"
v-model="editingValue"
outline
raised
no-underline
autofocus
:class="[$style.pill, $style.custom]"
@click="!isStatic && pillClicked(value)"
/>
</template>
<!-- Empty pill to add custom label -->
<!-- TODO: Add error state (or mitigation) if new label is already in `custom[]` -->
<Pill
v-if="model.custom"
:key="componentKey"
v-model="additionalValue"
solid
no-underline
style="margin-right: 40px; height:32px; flex-grow: 1;"
/>
</Layout> </Layout>
</Layout> </Layout>
</template> </template>
@ -233,47 +139,14 @@ watch(model, () => {
// Compensation for round shapes -> https://en.wikipedia.org/wiki/Overshoot_(typography) // Compensation for round shapes -> https://en.wikipedia.org/wiki/Overshoot_(typography)
margin: 0 -4px; margin: 0 -4px;
padding:4px; // padding: 4px;
border-radius: 22px; border-radius: 22px;
min-height: 48px; gap: 8px;
min-width: 160px;
gap: 4px;
> .pill {
padding: 2px; padding: 2px;
&.static {
text-decoration: none;
}
&.preset {
&:is(:hover, :focus-visible) .pill-content {
text-decoration: line-through;
}
.pill-content::after{
content:'×';
margin-left: 8px;
}
}
&.custom {
text-decoration: none;
}
} .empty {
>.dropdown{ flex-grow: 1;
position: absolute;
inset: 0;
border-radius: 15px;
padding: 2px 11.25px;
text-align: right;
background: transparent;
appearance: auto;
margin-right: 12px;
// From vitepress default, needed for app
border: 0;
color: inherit;
} }
} }
&:hover:has(select)>.list { &:hover:has(select)>.list {

View File

@ -1,38 +1,41 @@
<script setup> <script setup lang="ts">
import { computed, ref } from 'vue' import { ref } from 'vue'
import Pill from '~/components/ui/Pill.vue';
import Pills from '~/components/ui/Pills.vue'; import Pills from '~/components/ui/Pills.vue';
import Spacer from '~/components/ui/Spacer.vue';
import Layout from '~/components/ui/Layout.vue'; type Item = { type: 'custom' | 'preset', label: string }
import Input from '~/components/ui/Input.vue'; type Model = {
currents: Item[],
others?: Item[],
}
const nullModel = ref({ const nullModel = ref({
current: [], currents: []
}); } satisfies Model)
const staticModel = ref({ const staticModel = ref({
current: ["#Noise", "#FieldRecording", "#Experiment"], currents: [
}); { label: "#Noise", type: 'preset' },
{ label: "#FieldRecording", type: 'preset' },
{ label: "#Experiment", type: 'preset' }
]
} satisfies Model);
const interactiveModel = ref({ const interactiveModel = ref({
current: ["#Noise", "#FieldRecording", "#Experiment"], ...staticModel.value,
others: ["#Melody", "#Rhythm"], others: [
}); { label: "#Melody", type: 'preset' },
{ label: "#Rhythm", type: 'preset' }
]
} satisfies Model);
const customModel = ref({ const customModel = ref({
current: ["custom", "#FieldRecording", "#Experiment"], ...staticModel.value,
others: ["#Melody", "#Rhythm"], others: [
custom: ["custom"], { label: "#MyTag1", type: 'custom' },
}); { label: "#MyTag2", type: 'custom' }
]
const search = ref() } satisfies Model);
const countryInput = ref("")
const inputModel = ref({ input: "Custom", isEditing: false })
const update = (e)=>{console.log(e)}
</script> </script>
```ts ```ts
@ -46,133 +49,66 @@ Users can select a subset of given options and create new ones.
The model you provide will be mutated by this component: The model you provide will be mutated by this component:
- `current`: these pills are currently selected - `currents`: these items are currently selected
- `others`: these pills are currently not selected (but can be selected by the user). This prop is optional. By adding it, you allow users to change the selection. - `others`: these items are currently not selected (but can be selected by the user). This prop is optional. By adding it, you allow users to change the selection.
- `custom`: these pills were created by the user. This prop is optional. Users can edit, add and remove any pill defined in this array. Note that the `custom` array should only contain pills that are either in `current` or in `others`.
::: warning Each item has a `label` of type `string` as well as a `type` of either:
If you place custom pills into `others`, the user will be able to select, edit and delete them but not to deselect them. If there is a use case for this, we have to design a good UX for deselecting custom pills. - `custom`: the user can edit its label or
- `preset`: the user cannot edit its label
:::
## Test
<label for="selectTag">
<b>Select tag</b>
<Layout flex gap-12 style="background:transparent;padding:12px; outline-inset: -4px; border-radius: 24px;">
<Pill>
#Pell
</Pill>
<input autocomplete="off" style="flex-grow: 1; min-width: 44px; flex-basis: 44px; padding: 12px 22px; margin: -12px; border-radius: inherit; outline: 1px solid red;" value="pill"></input>
<Pill>
VeryLongPill
</Pill>
<Pill>
VeryLongEvenLongerPill
</Pill>
<Pill v-model="inputModel"/>
<input id="selectTag" size="50" list="tags" autocomplete="off" style="flex-grow: 1; min-width: 44px; flex-basis: 44px; padding: 12px 22px; margin: -12px; border-radius: inherit; outline: 1px solid red;" @input="update">
</input>
<!-- https://www.sitepoint.com/html5-datalist-autocomplete/ -->
<datalist id="tags">
<option>Russia</option>
<option>Germany</option>
<option>UnitedKingdom</option>
</datalist>
<style scoped>
*:has(> input){
outline: 4px solid transparent;
}
*:has(> input:focus){
outline-color:var(--focus-ring-color)
}
input:focus+datalist {
position: absolute;
max-height: 20em;
border: 0 none;
overflow-x: hidden;
overflow-y: auto;
}
datalist option {
font-size: 0.8em;
padding: 0.3em 1em;
background-color: #ccc;
cursor: pointer;
}
datalist option:hover, datalist option:focus {
color: #fff;
background-color: #036;
outline: 0 none;
}
</style>
</Layout>
</label>
## No pills ## No pills
```ts ```ts
const nullModel = ref({ const nullModel = ref({
current: [] currents: []
}); }) as { currents: Item[] };
``` ```
```vue-html ```vue-html
<Pills v-model="nullModel" /> <Pills v-model="nullModel" />
``` ```
<Layout class="preview" style="padding:16px">
<Pills v-model="nullModel" /> <Pills v-model="nullModel" />
</Layout>
## Predefined list of pills ## Predefined list of pills
```ts ```ts
const staticModel = ref({ const staticModel = ref({
current: ["#Noise", "#FieldRecording", "#Experiment"] currents: [
{ label: "#Noise", type: 'preset' },
{ label: "#FieldRecording", type: 'preset' },
{ label: "#Experiment", type: 'preset' }
]
}); });
``` ```
```vue-html ```vue-html
<Pills v-model="staticModel" label="Tags" /> <Pills v-model="staticModel" label="Static Tags" />
``` ```
<Layout class="preview" style="padding:16px"> <Pills v-model="staticModel" label="Static Tags" />
<Pills v-model="staticModel" label="Tags" />
</Layout>
## Let users select and unselect pills ## Let users select and unselect pills
Select a set of pills from presets, and add and remove custom ones Select a set of pills from presets, and add and remove custom ones
```ts ```ts
const interactiveModel = ref({ const interactiveModel = ref({
current: ["#Noise", "#FieldRecording", "#Experiment"], ...staticModel,
others: ["#Melody", "#Rhythm"] others: [
{ label: "#Melody", type: 'preset' },
{ label: "#Rhythm", type: 'preset' }
]
}); });
``` ```
```vue-html ```vue-html
<Pills v-model="interactiveModel" label="Tags" /> <Pills v-model="interactiveModel" label="Interactive Tags" />
``` ```
<Layout class="preview" style="padding:16px"> <Pills v-model="interactiveModel" label="Interactive Tags" />
<Pills v-model="interactiveModel" label="Tags" />
</Layout>
## Let users add, remove and edit custom pills ## Let users add, remove and edit custom pills
@ -180,48 +116,16 @@ Use [reactive](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#r
```ts ```ts
const customModel = ref({ const customModel = ref({
current: ["custom", "#FieldRecording", "#Experiment"], ...staticModel,
others: ["#Melody", "#Rhythm"], others: [
custom: ["custom"] { label: "#MyTag1", type: 'custom' },
{ label: "#MyTag2", type: 'custom' }
]
}); });
``` ```
```vue-html ```vue-html
<Pills v-model="customModel" label="Custom" /> <Pills v-model="customModel" label="Custom Tags" />
``` ```
<Layout class="preview" style="padding:16px"> <Pills v-model="customModel" label="Custom Tags" />
<Pills v-model="customModel" label="Custom" />
</Layout>
## Combine Pills with other input fields
<Spacer />
<Layout form flex>
<Input
v-model="search"
label="Search"
style="max-width: 150px;"
/>
<Pills
v-model="customModel"
label="Filter by tags"
style="max-width: 250px;"
/>
<Layout stack noGap label>
<span class="label"> Ordering </span>
<select>
<option
v-for="key in ['by date', 'by duration']"
:value="key"
>
key
</option>
</select>
</Layout>
<Input
v-model="search"
label="Option"
style="max-width: 50px;"
/>
</Layout>