286 lines
6.5 KiB
Vue
286 lines
6.5 KiB
Vue
<script setup lang="ts">
|
||
import { ref, watchEffect, watch, computed } from 'vue'
|
||
|
||
import { color } from '~/composables/color'
|
||
|
||
import Pill from './Pill.vue'
|
||
import Layout from './Layout.vue'
|
||
|
||
const props = defineProps<{
|
||
icon?: string,
|
||
placeholder?: string,
|
||
label?: string,
|
||
}>()
|
||
|
||
const model = defineModel<{
|
||
current: string[],
|
||
others?: string[],
|
||
custom?: string[],
|
||
}>({ required: true })
|
||
|
||
const whenInteractive = (then:() => void) => {
|
||
if (!model.value.others) return; then()
|
||
}
|
||
|
||
const editingValue = ref('')
|
||
const additionalValue = ref('')
|
||
const componentKey = ref(0)
|
||
const forceRerender = () => componentKey.value++
|
||
|
||
const isStatic = computed(() =>
|
||
!model.value.others
|
||
)
|
||
|
||
const selectedLabel = ref('+')
|
||
|
||
// Dropdown changed -> select label
|
||
|
||
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 || [])]
|
||
}
|
||
}
|
||
|
||
// Editing value changed --> remove, add or replace a label
|
||
|
||
const remove = (value: string) => {
|
||
model.value = {
|
||
...model.value,
|
||
current: model.value.current.filter(v => v !== 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, 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 = {
|
||
...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>
|
||
<Layout
|
||
stack
|
||
no-gap
|
||
label
|
||
:class="$style.pills"
|
||
for="dropdown"
|
||
>
|
||
<!-- Label -->
|
||
<span
|
||
v-if="$slots['label']"
|
||
:class="$style.label"
|
||
>
|
||
<slot name="label" />
|
||
</span>
|
||
<span
|
||
v-if="props.label"
|
||
:class="$style.label"
|
||
>
|
||
{{ props.label }}
|
||
</span>
|
||
|
||
<!-- List of Pills -->
|
||
|
||
<Layout
|
||
flex
|
||
no-gap
|
||
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
|
||
no-underline
|
||
style="margin-right: 40px; height:32px; flex-grow: 1;"
|
||
/>
|
||
</Layout>
|
||
</Layout>
|
||
</template>
|
||
|
||
<style module lang="scss">
|
||
.pills {
|
||
>.label {
|
||
margin-top: -18px;
|
||
padding-bottom: 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
>.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;
|
||
|
||
> .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;
|
||
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;
|
||
}
|
||
}
|
||
&:hover:has(select)>.list {
|
||
box-shadow: inset 0 0 0 4px var(--border-color)
|
||
}
|
||
:has(>select:focus) {
|
||
box-shadow: inset 0 0 0 4px var(--focus-ring-color)
|
||
}
|
||
}
|
||
</style>
|