kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
feat(ui): [WIP] Pills list
rodzic
713c2fe34f
commit
9b02f1840e
|
@ -8,6 +8,12 @@ import Input from './Input.vue'
|
|||
import Popover from './Popover.vue'
|
||||
import PopoverItem from './popover/PopoverItem.vue'
|
||||
|
||||
/* Event */
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
/* Model */
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -51,6 +57,8 @@ watch(isEditing, (isTrue, wasTrue) => {
|
|||
model.value.current = { ...matchInOthers }
|
||||
}
|
||||
model.value.others = model.value.others.filter(({ label }) => label !== model.value?.current.label)
|
||||
|
||||
emit('changed')
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -249,7 +257,7 @@ const current = computed(() => (
|
|||
<!-- Preset content -->
|
||||
<div :class="$style['pill-content']">
|
||||
<slot />
|
||||
{{ model?.current?.label }}
|
||||
{{ model?.current?.label }} ​
|
||||
<Popover
|
||||
v-if="model"
|
||||
v-model="isEditing"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watchEffect, watch, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
import { color } from '~/composables/color'
|
||||
|
||||
|
@ -13,17 +13,13 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const model = defineModel<{
|
||||
current: string[],
|
||||
others?: string[],
|
||||
custom?: string[],
|
||||
currents: Item[],
|
||||
others?: Item[],
|
||||
}>({ required: true })
|
||||
|
||||
const whenInteractive = (then:() => void) => {
|
||||
if (!model.value.others) return; then()
|
||||
}
|
||||
type Item = { type: 'custom' | 'preset', label: string }
|
||||
|
||||
const editingValue = ref('')
|
||||
const additionalValue = ref('')
|
||||
// Manually trigger rerendering
|
||||
const componentKey = ref(0)
|
||||
const forceRerender = () => componentKey.value++
|
||||
|
||||
|
@ -31,99 +27,51 @@ const isStatic = computed(() =>
|
|||
!model.value.others
|
||||
)
|
||||
|
||||
const selectedLabel = ref('+')
|
||||
const emptyItem = {
|
||||
label: '', type: 'custom'
|
||||
} as const
|
||||
|
||||
// Dropdown changed -> select label
|
||||
const pills = computed({
|
||||
|
||||
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 || [])]
|
||||
get: () => {
|
||||
console.log("OTHERS", model.value.others)
|
||||
return [...model.value.currents, { ...emptyItem }].map(
|
||||
(item) => ({ current: { ...item }, others: model.value.others ? model.value.others.map(item => ({ ...item })) : [] })
|
||||
)
|
||||
},
|
||||
set: (pills) => {
|
||||
console.log("SETTING PILLS", pills)
|
||||
model.value.currents = pills
|
||||
.filter(({ current }) => current.label !== '')
|
||||
.map(({ current }) => ({ ...current }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Editing value changed --> remove, add or replace a label
|
||||
const changed = (index: number, pill: (typeof pills.value)[number]) => {
|
||||
/*reduce<U>(
|
||||
callbackfn: (previousValue: U, currentValue: Item, currentIndex: number, array: Item[]) => U,
|
||||
initialValue: U): U */
|
||||
|
||||
const remove = (value: string) => {
|
||||
model.value = {
|
||||
...model.value,
|
||||
current: model.value.current.filter(v => v !== value),
|
||||
custom: model.value.custom?.filter(v => v !== value)
|
||||
}
|
||||
}
|
||||
console.log("NEW: #", index, "=", pills.value[index].current)
|
||||
console.log("OLD: #", index, "=", pill.current)
|
||||
// model.value.currents.push({ ...emptyItem })
|
||||
// console.log(model.value.currents.length)
|
||||
|
||||
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 = ''
|
||||
model.value.currents[index] = { ...pills.value[index].current }
|
||||
model.value.others = { ...pills.value[index].others }
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Add empty current item if none is inside
|
||||
watch( model, () => {
|
||||
console.log("MODEL CHANGED", model.value)
|
||||
})
|
||||
|
||||
watch(additionalValue, (newValue, oldValue) => {
|
||||
if (newValue !== '') {
|
||||
additionalValue.value = ''
|
||||
add(newValue)
|
||||
}
|
||||
})
|
||||
// Remove duplicates on each CONFIRM
|
||||
|
||||
// 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
|
||||
})
|
||||
// const unique = (list:Item[]) =>
|
||||
// list.reduce((acc, a) => ([a, ...acc]), [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -146,75 +94,33 @@ watch(model, () => {
|
|||
:class="$style.label"
|
||||
>
|
||||
{{ props.label }}
|
||||
<div>PILLS {{ pills.map(({ current })=>current.label) }}</div>
|
||||
<div>MODEL {{ model.currents.map(({ label })=>label) }}</div>
|
||||
</span>
|
||||
|
||||
<!-- List of Pills -->
|
||||
|
||||
<Layout
|
||||
flex
|
||||
no-gap
|
||||
gap-4
|
||||
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
|
||||
v-for="(pill, index) in pills"
|
||||
:key="index*1000+componentKey"
|
||||
v-model="pills[index]"
|
||||
outline
|
||||
no-underline
|
||||
style="margin-right: 40px; height:32px; flex-grow: 1;"
|
||||
/>
|
||||
:class="[$style.pill, $style[isStatic ? 'static' : pill.current.label === '' ? 'empty' : pill.current.type === 'custom' ? 'custom' : 'preset']]"
|
||||
@changed="() => { console.log('CCCCCC', index); changed(index, pill) }"
|
||||
>
|
||||
<span
|
||||
v-if="isStatic"
|
||||
:class="$style['pill-content']"
|
||||
>{{ pill.current.label }}</span>
|
||||
{{ `${index} ${componentKey}` }}
|
||||
</Pill>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</template>
|
||||
|
@ -233,47 +139,14 @@ watch(model, () => {
|
|||
// Compensation for round shapes -> https://en.wikipedia.org/wiki/Overshoot_(typography)
|
||||
margin: 0 -4px;
|
||||
|
||||
padding:4px;
|
||||
// padding: 4px;
|
||||
border-radius: 22px;
|
||||
|
||||
min-height: 48px;
|
||||
min-width: 160px;
|
||||
gap: 8px;
|
||||
padding: 2px;
|
||||
|
||||
gap: 4px;
|
||||
|
||||
> .pill {
|
||||
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;
|
||||
.empty {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
&:hover:has(select)>.list {
|
||||
|
|
|
@ -1,38 +1,41 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Pill from '~/components/ui/Pill.vue';
|
||||
import Pills from '~/components/ui/Pills.vue';
|
||||
import Spacer from '~/components/ui/Spacer.vue';
|
||||
import Layout from '~/components/ui/Layout.vue';
|
||||
import Input from '~/components/ui/Input.vue';
|
||||
|
||||
type Item = { type: 'custom' | 'preset', label: string }
|
||||
type Model = {
|
||||
currents: Item[],
|
||||
others?: Item[],
|
||||
}
|
||||
|
||||
const nullModel = ref({
|
||||
current: [],
|
||||
});
|
||||
currents: []
|
||||
} satisfies Model)
|
||||
|
||||
const staticModel = ref({
|
||||
current: ["#Noise", "#FieldRecording", "#Experiment"],
|
||||
});
|
||||
currents: [
|
||||
{ label: "#Noise", type: 'preset' },
|
||||
{ label: "#FieldRecording", type: 'preset' },
|
||||
{ label: "#Experiment", type: 'preset' }
|
||||
]
|
||||
} satisfies Model);
|
||||
|
||||
const interactiveModel = ref({
|
||||
current: ["#Noise", "#FieldRecording", "#Experiment"],
|
||||
others: ["#Melody", "#Rhythm"],
|
||||
});
|
||||
...staticModel.value,
|
||||
others: [
|
||||
{ label: "#Melody", type: 'preset' },
|
||||
{ label: "#Rhythm", type: 'preset' }
|
||||
]
|
||||
} satisfies Model);
|
||||
|
||||
const customModel = ref({
|
||||
current: ["custom", "#FieldRecording", "#Experiment"],
|
||||
others: ["#Melody", "#Rhythm"],
|
||||
custom: ["custom"],
|
||||
});
|
||||
|
||||
const search = ref()
|
||||
|
||||
const countryInput = ref("")
|
||||
|
||||
const inputModel = ref({ input: "Custom", isEditing: false })
|
||||
|
||||
const update = (e)=>{console.log(e)}
|
||||
...staticModel.value,
|
||||
others: [
|
||||
{ label: "#MyTag1", type: 'custom' },
|
||||
{ label: "#MyTag2", type: 'custom' }
|
||||
]
|
||||
} satisfies Model);
|
||||
</script>
|
||||
|
||||
```ts
|
||||
|
@ -46,133 +49,66 @@ Users can select a subset of given options and create new ones.
|
|||
|
||||
The model you provide will be mutated by this component:
|
||||
|
||||
- `current`: these pills are currently selected
|
||||
- `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`.
|
||||
- `currents`: these items are currently selected
|
||||
- `others`: these items 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.
|
||||
|
||||
::: warning
|
||||
Each item has a `label` of type `string` as well as a `type` of either:
|
||||
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
## Test
|
||||
|
||||
<label for="selectTag">
|
||||
|
||||
<b>Select tag</b>
|
||||
|
||||
<Layout flex gap-12 style="background:transparent;padding:12px; outline-inset: -4px; border-radius: 24px;">
|
||||
|
||||
<Pill>
|
||||
#Pell
|
||||
</Pill>
|
||||
|
||||
<input autocomplete="off" style="flex-grow: 1; min-width: 44px; flex-basis: 44px; padding: 12px 22px; margin: -12px; border-radius: inherit; outline: 1px solid red;" value="pill"></input>
|
||||
|
||||
<Pill>
|
||||
VeryLongPill
|
||||
</Pill>
|
||||
|
||||
<Pill>
|
||||
VeryLongEvenLongerPill
|
||||
</Pill>
|
||||
|
||||
<Pill v-model="inputModel"/>
|
||||
|
||||
<input id="selectTag" size="50" list="tags" autocomplete="off" style="flex-grow: 1; min-width: 44px; flex-basis: 44px; padding: 12px 22px; margin: -12px; border-radius: inherit; outline: 1px solid red;" @input="update">
|
||||
</input>
|
||||
|
||||
<!-- https://www.sitepoint.com/html5-datalist-autocomplete/ -->
|
||||
|
||||
<datalist id="tags">
|
||||
<option>Russia</option>
|
||||
<option>Germany</option>
|
||||
<option>UnitedKingdom</option>
|
||||
</datalist>
|
||||
|
||||
<style scoped>
|
||||
*:has(> input){
|
||||
outline: 4px solid transparent;
|
||||
}
|
||||
*:has(> input:focus){
|
||||
outline-color:var(--focus-ring-color)
|
||||
}
|
||||
input:focus+datalist {
|
||||
position: absolute;
|
||||
max-height: 20em;
|
||||
border: 0 none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
datalist option {
|
||||
font-size: 0.8em;
|
||||
padding: 0.3em 1em;
|
||||
background-color: #ccc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
datalist option:hover, datalist option:focus {
|
||||
color: #fff;
|
||||
background-color: #036;
|
||||
outline: 0 none;
|
||||
}
|
||||
</style>
|
||||
|
||||
</Layout>
|
||||
|
||||
</label>
|
||||
- `custom`: the user can edit its label or
|
||||
- `preset`: the user cannot edit its label
|
||||
|
||||
## No pills
|
||||
|
||||
```ts
|
||||
const nullModel = ref({
|
||||
current: []
|
||||
});
|
||||
currents: []
|
||||
}) as { currents: Item[] };
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pills v-model="nullModel" />
|
||||
```
|
||||
|
||||
<Layout class="preview" style="padding:16px">
|
||||
<Pills v-model="nullModel" />
|
||||
</Layout>
|
||||
<Pills v-model="nullModel" />
|
||||
|
||||
## Predefined list of pills
|
||||
|
||||
```ts
|
||||
const staticModel = ref({
|
||||
current: ["#Noise", "#FieldRecording", "#Experiment"]
|
||||
currents: [
|
||||
{ label: "#Noise", type: 'preset' },
|
||||
{ label: "#FieldRecording", type: 'preset' },
|
||||
{ label: "#Experiment", type: 'preset' }
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pills v-model="staticModel" label="Tags" />
|
||||
<Pills v-model="staticModel" label="Static Tags" />
|
||||
```
|
||||
|
||||
<Layout class="preview" style="padding:16px">
|
||||
<Pills v-model="staticModel" label="Tags" />
|
||||
</Layout>
|
||||
<Pills v-model="staticModel" label="Static Tags" />
|
||||
|
||||
## Let users select and unselect pills
|
||||
|
||||
Select a set of pills from presets, and add and remove custom ones
|
||||
|
||||
```ts
|
||||
|
||||
const interactiveModel = ref({
|
||||
current: ["#Noise", "#FieldRecording", "#Experiment"],
|
||||
others: ["#Melody", "#Rhythm"]
|
||||
...staticModel,
|
||||
others: [
|
||||
{ label: "#Melody", type: 'preset' },
|
||||
{ label: "#Rhythm", type: 'preset' }
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pills v-model="interactiveModel" label="Tags" />
|
||||
<Pills v-model="interactiveModel" label="Interactive Tags" />
|
||||
```
|
||||
|
||||
<Layout class="preview" style="padding:16px">
|
||||
<Pills v-model="interactiveModel" label="Tags" />
|
||||
</Layout>
|
||||
<Pills v-model="interactiveModel" label="Interactive Tags" />
|
||||
|
||||
## Let users add, remove and edit custom pills
|
||||
|
||||
|
@ -180,48 +116,16 @@ Use [reactive](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#r
|
|||
|
||||
```ts
|
||||
const customModel = ref({
|
||||
current: ["custom", "#FieldRecording", "#Experiment"],
|
||||
others: ["#Melody", "#Rhythm"],
|
||||
custom: ["custom"]
|
||||
...staticModel,
|
||||
others: [
|
||||
{ label: "#MyTag1", type: 'custom' },
|
||||
{ label: "#MyTag2", type: 'custom' }
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Pills v-model="customModel" label="Custom" />
|
||||
<Pills v-model="customModel" label="Custom Tags" />
|
||||
```
|
||||
|
||||
<Layout class="preview" style="padding:16px">
|
||||
<Pills v-model="customModel" label="Custom" />
|
||||
</Layout>
|
||||
|
||||
## Combine Pills with other input fields
|
||||
|
||||
<Spacer />
|
||||
<Layout form flex>
|
||||
<Input
|
||||
v-model="search"
|
||||
label="Search"
|
||||
style="max-width: 150px;"
|
||||
/>
|
||||
<Pills
|
||||
v-model="customModel"
|
||||
label="Filter by tags"
|
||||
style="max-width: 250px;"
|
||||
/>
|
||||
<Layout stack noGap label>
|
||||
<span class="label"> Ordering </span>
|
||||
<select>
|
||||
<option
|
||||
v-for="key in ['by date', 'by duration']"
|
||||
:value="key"
|
||||
>
|
||||
key
|
||||
</option>
|
||||
</select>
|
||||
</Layout>
|
||||
<Input
|
||||
v-model="search"
|
||||
label="Option"
|
||||
style="max-width: 50px;"
|
||||
/>
|
||||
</Layout>
|
||||
<Pills v-model="customModel" label="Custom Tags" />
|
||||
|
|
Ładowanie…
Reference in New Issue