refactor(ui): user can add pills to list, and delete custom pills

This commit is contained in:
upsiflu 2025-01-30 23:27:05 +01:00
parent e5371cddaf
commit 3f0b9dde32
4 changed files with 134 additions and 47 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, 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'
@ -13,8 +13,12 @@ const handleClick = (event: MouseEvent) => {
(input.value as HTMLInputElement).focus(); (input.value as HTMLInputElement).focus();
} }
} }
const props = defineProps<{ noUnderline?:true } & (PastelProps | ColorProps) & VariantProps & RaisedProps>() const props = defineProps<{ noUnderline?:true, autofocus? : boolean } & (PastelProps | ColorProps) & VariantProps & RaisedProps>()
const model = defineModel<string>() const model = defineModel<string>()
onMounted(() => {
if (props.autofocus) input.value.focus();
})
</script> </script>
<template> <template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watchEffect, computed } from 'vue' import { ref, watchEffect, watch, computed } from 'vue'
import { color } from '~/composables/color'; import { color } from '~/composables/color';
@ -10,8 +10,6 @@ const props = defineProps<{
icon?: string, icon?: string,
placeholder?: string, placeholder?: string,
label?: string, label?: string,
autofocus?: boolean,
}>(); }>();
const model = defineModel<{ const model = defineModel<{
@ -24,13 +22,21 @@ const whenInteractive = (then:() => void) => {
if(!model.value.others) return; then(); if(!model.value.others) return; then();
} }
const editingValue = ref('      '); const editingValue = ref('');
const additionalValue = ref('');
const componentKey = ref(0);
const forceRerender = () => componentKey.value++;
const isCustom = (value:string) =>
model.value.current.includes(value) const isStatic = computed(()=>
!model.value.others
)
const selectedLabel = ref("+"); const selectedLabel = ref("+");
// Dropdown changed -> select label
whenInteractive(()=> whenInteractive(()=>
watchEffect(() => { watchEffect(() => {
if (!model.value.others) return if (!model.value.others) return
@ -43,20 +49,79 @@ whenInteractive(()=>
} }
}) })
) )
// Pill clicked --> edit or unselect label
const pillClicked = (value: string) => const pillClicked = (value: string) =>
model.value.custom?.includes(value) ? edit(value) : unselect(value) model.value.custom?.includes(value)
? edit(value)
: unselect(value)
const edit = (value: string) =>
editingValue.value = (console.log("edit", value), value)
const unselect = (value: string) => const unselect = (value: string) =>
whenInteractive(() => model.value = model.value =
{...model.value, {...model.value,
current : model.value.current.filter(v=>v!==value), current : model.value.current.filter(v=>v!==value),
others : [value, ...(model.value.others || [])] others : [value, ...(model.value.others || [])]
} }
)
const edit = (value: string) => // Editing value changed --> remove, add or replace a label
whenInteractive(()=> editingValue.value = value)
const remove = (value: string) =>
model.value = {
...model.value,
current: model.value.current.filter(v=>v!==(console.log("remove", value), 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, (console.log("add", value), 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()
}
const replace = (value: string) => {
model.value = {
...(console.log("replace", 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, () => {
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
})
</script> </script>
<template> <template>
@ -72,7 +137,7 @@ const edit = (value: string) =>
<!-- List of Pills --> <!-- List of Pills -->
<Layout flex gap-8 <Layout flex no-gap
v-bind="color({}, ['solid', 'default', 'secondary'])()" v-bind="color({}, ['solid', 'default', 'secondary'])()"
:class="$style.list" :class="$style.list"
> >
@ -91,45 +156,39 @@ const edit = (value: string) =>
{{ value }} {{ value }}
</option> </option>
</select> </select>
<!-- Static pills -->
<Pill outline raised
v-if="!model.others"
v-for="value in model.current"
:class="$style.pill"
>
<span>{{ value }}</span>
</Pill>
<!-- Dynamic pills -->
<template v-for="value in model.current"> <template v-for="value in model.current">
<!-- List of current pills -->
<Pill outline raised no-underline <Pill outline raised no-underline
v-if="value !== editingValue" v-if="value !== editingValue"
@click="pillClicked(value)" :class="[$style.pill, $style[isStatic ? 'static' : model.custom?.includes(value) ? 'custom' : 'preset']]"
:class="$style.pill" @click="!isStatic && pillClicked(value)"
> >
<span :class="$style['pill-content']">{{ value }}</span> <span :class="$style['pill-content']">{{ value }}</span>
{{ isCustom(value) ? '   ×' : ''}}
</Pill> </Pill>
<Pill outline raised no-underline <Pill outline raised no-underline
v-else v-if="value === editingValue"
autofocus
:class="[$style.pill, $style.custom]"
@click="!isStatic && pillClicked(value)"
v-model="editingValue" v-model="editingValue"
@click="pillClicked(value)"
:class="$style.pill"
/> />
</template> </template>
<!-- Add custom pill --> <!-- Empty pill to add custom label -->
<!-- TODO: Add error state (or mitigation) if new label is already in `custom[]` -->
<Pill outline raised no-underline <Pill outline raised no-underline
v-if="model.custom" v-if="model.custom"
:class="$style.pill" :class="$style.pill"
v-model="editingValue" v-model="additionalValue"
:key = "componentKey"
style="margin-right: 40px; height:32px;" style="margin-right: 40px; height:32px;"
> />
</Pill>
</Layout> </Layout>
</Layout> </Layout>
</template> </template>
@ -145,18 +204,34 @@ const edit = (value: string) =>
>.list { >.list {
position: relative; position: relative;
// Compensation for round shapes -> https://en.wikipedia.org/wiki/Overshoot_(typography)
margin: 0 -4px;
padding:4px; padding:4px;
border-radius: 24px; border-radius: 24px;
min-height: 48px; min-height: 48px;
min-width: 160px; min-width: 160px;
>:is(:hover, :focus-visible) .pill-content {
text-decoration: line-through !important;
}
> .pill { > .pill {
margin: 4px; margin: 4px;
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;
}
} }
>.dropdown{ >.dropdown{
position: absolute; position: absolute;

View File

@ -18,7 +18,7 @@
border-radius: 100vh; border-radius: 100vh;
> .pill-content { > .pill-content {
padding: 0.5em 0.75em; padding: 0.45em 0.75em 0.55em 0.75em;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -19,9 +19,9 @@ const interactiveModel = ref({
}); });
const customModel = ref({ const customModel = ref({
current: ["#Noise", "#FieldRecording", "#Experiment"], current: ["custom", "#FieldRecording", "#Experiment"],
others: ["#Melody", "#Rhythm"], others: ["#Melody", "#Rhythm"],
custom: ["#Noise"], custom: ["custom"],
}); });
</script> </script>
@ -40,6 +40,12 @@ The model you provide will be mutated by this component:
- `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 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`. - `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
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.
:::
## No pills ## No pills
```ts ```ts
@ -93,18 +99,20 @@ const interactiveModel = ref({
## 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.
```ts ```ts
const customModel = ref({ const customModel = ref({
current: ["#Noise", "#FieldRecording", "#Experiment"], current: ["custom", "#FieldRecording", "#Experiment"],
others: ["#Melody", "#Rhythm"], others: ["#Melody", "#Rhythm"],
custom: ["#Noise"], custom: ["custom"],
}); });
``` ```
```vue-html ```vue-html
<Pills v-model="customModel" label="Custom Tags" can-add-pills /> <Pills v-model="customModel" label="Custom" />
``` ```
<Layout class="preview" style="padding:16px"> <Layout class="preview" style="padding:16px">
<Pills v-model="customModel" label="Custom Tags" /> <Pills v-model="customModel" label="Custom" />
</Layout> </Layout>