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">
|
<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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue