refactor(ui): user can add pills to list, and delete custom pills
This commit is contained in:
parent
e5371cddaf
commit
3f0b9dde32
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue