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">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
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();
}
}
const props = defineProps<{ noUnderline?:true } & (PastelProps | ColorProps) & VariantProps & RaisedProps>()
const props = defineProps<{ noUnderline?:true, autofocus? : boolean } & (PastelProps | ColorProps) & VariantProps & RaisedProps>()
const model = defineModel<string>()
onMounted(() => {
if (props.autofocus) input.value.focus();
})
</script>
<template>

View File

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

View File

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

View File

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