funkwhale/front/src/components/ui/Pills.vue

286 lines
6.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>