feat(ui): [WIP] Pills list

environments/review-docs-feat-z0hkbz/deployments/20800
upsiflu 2025-03-12 14:58:07 +01:00
rodzic 713c2fe34f
commit 9b02f1840e
3 zmienionych plików z 125 dodań i 340 usunięć

Wyświetl plik

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

Wyświetl plik

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watchEffect, watch, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { color } from '~/composables/color'
@ -13,17 +13,13 @@ const props = defineProps<{
}>()
const model = defineModel<{
current: string[],
others?: string[],
custom?: string[],
currents: Item[],
others?: Item[],
}>({ required: true })
const whenInteractive = (then:() => void) => {
if (!model.value.others) return; then()
}
type Item = { type: 'custom' | 'preset', label: string }
const editingValue = ref('')
const additionalValue = ref('')
// Manually trigger rerendering
const componentKey = ref(0)
const forceRerender = () => componentKey.value++
@ -31,99 +27,51 @@ const isStatic = computed(() =>
!model.value.others
)
const selectedLabel = ref('+')
const emptyItem = {
label: '', type: 'custom'
} as const
// Dropdown changed -> select label
const pills = computed({
whenInteractive(() =>
watchEffect(() => {
if (!model.value.others) return
const newLabel = selectedLabel.value
selectedLabel.value = '+'
if (!newLabel || newLabel === '+') return
if (!model.value.current.includes(newLabel)) {
model.value.current.push(newLabel)
model.value.others = model.value.others.filter(value => value !== newLabel)
}
})
)
// Pill clicked --> edit or unselect label
const pillClicked = (value: string) => {
model.value.custom?.includes(value)
? edit(value)
: unselect(value)
}
const edit = (value: string) => {
editingValue.value = value
}
const unselect = (value: string) => {
model.value = {
...model.value,
current: model.value.current.filter(v => v !== value),
others: [value, ...(model.value.others || [])]
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 }))
}
}
})
// Editing value changed --> remove, add or replace a 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 remove = (value: string) => {
model.value = {
...model.value,
current: model.value.current.filter(v => v !== value),
custom: model.value.custom?.filter(v => v !== value)
}
}
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)
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 = ''
model.value.currents[index] = { ...pills.value[index].current }
model.value.others = { ...pills.value[index].others }
// We have to force rerender because else, Vue keeps the previous additionalValue for the new "additionalValue" input pill :-(
forceRerender()
}
const replace = (value: string) => {
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)
}
// Add empty current item if none is inside
watch( model, () => {
console.log("MODEL CHANGED", model.value)
})
watch(additionalValue, (newValue, oldValue) => {
if (newValue !== '') {
additionalValue.value = ''
add(newValue)
}
})
// Remove duplicates on each CONFIRM
// Remove duplicates
const unique = (a:string[]) => [...new Set(a)]
watch(model, () => {
model.value.current = unique(model.value.current)
model.value.others = model.value.others ? unique(model.value.others) : undefined
model.value.custom = model.value.custom ? unique(model.value.custom) : undefined
})
// const unique = (list:Item[]) =>
// list.reduce((acc, a) => ([a, ...acc]), [])
</script>
<template>
@ -146,75 +94,33 @@ 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 -->
<Layout
flex
no-gap
gap-4
v-bind="color({}, ['solid', 'default', 'secondary'])()"
: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
v-if="value !== editingValue"
outline
raised
no-underline
:class="[$style.pill, $style[isStatic ? 'static' : model.custom?.includes(value) ? 'custom' : 'preset']]"
@click="!isStatic && pillClicked(value)"
>
<span :class="$style['pill-content']">{{ value }}</span>
</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
v-for="(pill, index) in pills"
:key="index*1000+componentKey"
v-model="pills[index]"
outline
no-underline
style="margin-right: 40px; height:32px; flex-grow: 1;"
/>
:class="[$style.pill, $style[isStatic ? 'static' : pill.current.label === '' ? 'empty' : pill.current.type === 'custom' ? 'custom' : 'preset']]"
@changed="() => { console.log('CCCCCC', index); changed(index, pill) }"
>
<span
v-if="isStatic"
:class="$style['pill-content']"
>{{ pill.current.label }}</span>
{{ `${index} ${componentKey}` }}
</Pill>
</Layout>
</Layout>
</template>
@ -233,47 +139,14 @@ watch(model, () => {
// Compensation for round shapes -> https://en.wikipedia.org/wiki/Overshoot_(typography)
margin: 0 -4px;
padding:4px;
// padding: 4px;
border-radius: 22px;
min-height: 48px;
min-width: 160px;
gap: 8px;
padding: 2px;
gap: 4px;
> .pill {
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;
}
}
>.dropdown{
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;
.empty {
flex-grow: 1;
}
}
&:hover:has(select)>.list {

Wyświetl plik

@ -1,38 +1,41 @@
<script setup>
import { computed, ref } from 'vue'
<script setup lang="ts">
import { ref } from 'vue'
import Pill from '~/components/ui/Pill.vue';
import Pills from '~/components/ui/Pills.vue';
import Spacer from '~/components/ui/Spacer.vue';
import Layout from '~/components/ui/Layout.vue';
import Input from '~/components/ui/Input.vue';
type Item = { type: 'custom' | 'preset', label: string }
type Model = {
currents: Item[],
others?: Item[],
}
const nullModel = ref({
current: [],
});
currents: []
} satisfies Model)
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({
current: ["#Noise", "#FieldRecording", "#Experiment"],
others: ["#Melody", "#Rhythm"],
});
...staticModel.value,
others: [
{ label: "#Melody", type: 'preset' },
{ label: "#Rhythm", type: 'preset' }
]
} satisfies Model);
const customModel = ref({
current: ["custom", "#FieldRecording", "#Experiment"],
others: ["#Melody", "#Rhythm"],
custom: ["custom"],
});
const search = ref()
const countryInput = ref("")
const inputModel = ref({ input: "Custom", isEditing: false })
const update = (e)=>{console.log(e)}
...staticModel.value,
others: [
{ label: "#MyTag1", type: 'custom' },
{ label: "#MyTag2", type: 'custom' }
]
} satisfies Model);
</script>
```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:
- `current`: these pills 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.
- `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`.
- `currents`: these items are currently selected
- `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.
::: 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.
:::
## 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>
- `custom`: the user can edit its label or
- `preset`: the user cannot edit its label
## No pills
```ts
const nullModel = ref({
current: []
});
currents: []
}) as { currents: Item[] };
```
```vue-html
<Pills v-model="nullModel" />
```
<Layout class="preview" style="padding:16px">
<Pills v-model="nullModel" />
</Layout>
<Pills v-model="nullModel" />
## Predefined list of pills
```ts
const staticModel = ref({
current: ["#Noise", "#FieldRecording", "#Experiment"]
currents: [
{ label: "#Noise", type: 'preset' },
{ label: "#FieldRecording", type: 'preset' },
{ label: "#Experiment", type: 'preset' }
]
});
```
```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="Tags" />
</Layout>
<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({
current: ["#Noise", "#FieldRecording", "#Experiment"],
others: ["#Melody", "#Rhythm"]
...staticModel,
others: [
{ label: "#Melody", type: 'preset' },
{ label: "#Rhythm", type: 'preset' }
]
});
```
```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="Tags" />
</Layout>
<Pills v-model="interactiveModel" label="Interactive Tags" />
## Let users add, remove and edit custom pills
@ -180,48 +116,16 @@ Use [reactive](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#r
```ts
const customModel = ref({
current: ["custom", "#FieldRecording", "#Experiment"],
others: ["#Melody", "#Rhythm"],
custom: ["custom"]
...staticModel,
others: [
{ label: "#MyTag1", type: 'custom' },
{ label: "#MyTag2", type: 'custom' }
]
});
```
```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" />
</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>
<Pills v-model="customModel" label="Custom Tags" />