docs(ui): #2390 bind external sources and sinks to tags lists (Pills)

This commit is contained in:
upsiflu 2025-04-01 09:30:11 +02:00
parent 81cf009f96
commit c2c0a2aa79
3 changed files with 200 additions and 19 deletions

View File

@ -7,12 +7,14 @@ import Button from './Button.vue'
import Input from './Input.vue'
import Popover from './Popover.vue'
import PopoverItem from './popover/PopoverItem.vue'
import { uniqBy } from 'lodash-es'
/* Event */
const emit = defineEmits<{
confirmed: [],
closed: []
closed: [],
opened: []
}>()
/* Model */
@ -31,6 +33,9 @@ type Item = { type: 'custom' | 'preset', label: string }
const currentItem = defineModel<Item>('current'),
otherItems = defineModel<Item[]>('others')
// Make sure there are no duplicate labels
const unique = (value: Item[]) => uniqBy(value, item => item.label)
const isEditing = ref<boolean>(false)
/* Lifecycle */
@ -52,9 +57,11 @@ watch(isEditing, (isTrue, wasTrue) => {
if (!currentItem.value || !otherItems.value) return
// Cache the previous value, in case the user cancels later
if (isTrue && !wasTrue) {
emit('opened')
previousValue = { ...currentItem.value }
if (currentItem.value.type === 'preset') {
otherItems.value.push({...currentItem.value})
otherItems.value = unique(otherItems.value)
currentItem.value.type = 'custom'
}
// Shift focus between the input and the previously focused element
@ -170,13 +177,13 @@ const other = computed(() => (option: Item) => ({
onClick: () => {
if (!currentItem.value || !otherItems.value) return;
currentItem.value = { ...option, type: 'custom' }
otherItems.value = [...(
otherItems.value = unique([...(
currentItem.value.label.trim() === '' || otherItems.value.find(({ label }) => label === currentItem.value?.label.trim())
? []
: [{ ...currentItem.value }]
), ...otherItems.value.filter(
({ label, type }) => label !== option.label || type === 'preset'
)]
)])
isEditing.value = false
},
isMatch: match.value?.label === option.label,
@ -232,6 +239,7 @@ const current = computed(() => (
onClick: () => {
if (!otherItems.value || !currentItem.value || otherItems.value.find(({ label })=>label === currentItem.value?.label.trim())) return
otherItems.value.push({...currentItem.value})
otherItems.value = unique(otherItems.value)
}
} as const
: undefined

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch, onMounted } from 'vue'
import { color } from '~/composables/color'
@ -7,14 +7,20 @@ import Pill from './Pill.vue'
import Layout from './Layout.vue'
import Button from './Button.vue'
/**
* Use `get` to read the pills into your app.
* Use `set` to write your app's state back into the pills.
*/
const props = defineProps<{
icon?: string,
placeholder?: string,
label?: string,
cancel?: string,
get: (v: Model) => void,
set: (v: Model) => Model
}>()
const model = defineModel<Model>({ required: true })
const model = ref<Model>({ currents: [] })
type Item = { type: 'custom' | 'preset', label: string }
type Model = { currents: Item[], others?: Item[] }
@ -30,8 +36,10 @@ const emptyItem = {
const nextIndex = ref<number | undefined>(undefined)
const sanitize = () => {
if (model.value.others)
if (model.value.others) {
model.value.currents = [...model.value.currents.filter(({ label }) => label !== ''), { ...emptyItem }]
props.get({ ...model.value, currents: [...model.value.currents.filter(({ label }) => label !== '')] });
}
}
const next = (index: number) => nextTick(() => { nextIndex.value = index + 1 })
@ -41,6 +49,10 @@ watch(model, () => {
})
sanitize();
onMounted(() => {
model.value = props.set(model.value)
})
</script>
<template>
@ -89,7 +101,8 @@ sanitize();
? 'empty'
: model.currents[index].type
]]"
@closed="() => { sanitize() }"
@opened="() => { model = props.set(model); }"
@closed="() => { sanitize(); }"
@confirmed="() => { next(index) }"
>
<span
@ -139,6 +152,8 @@ sanitize();
gap: 8px;
padding: 2px;
min-height: 36px;
.empty {
flex-grow: 1;
}

View File

@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { unionBy, union } from 'lodash-es'
import Pills from '~/components/ui/Pills.vue';
import Button from '~/components/ui/Button.vue';
import Spacer from '~/components/ui/Spacer.vue';
import Layout from '~/components/ui/Layout.vue';
type Item = { type: 'custom' | 'preset', label: string }
type Model = {
@ -14,26 +17,45 @@ const nullModel = ref({
currents: []
} satisfies Model)
const staticModel = ref({
const staticModel = ref<Model>({
currents: [
{ label: "#noise", type: 'preset' },
{ label: "#fieldRecording", type: 'preset' },
{ label: "#experiment", type: 'preset' }
]
} satisfies Model);
});
const simpleCustomModel = ref({
const simpleCustomModel = ref<Model>({
currents: [],
others: []
})
const customModel = ref({
const customModel = ref<Model>({
...staticModel.value,
others: [
{ label: "#myTag1", type: 'custom' },
{ label: "#myTag2", type: 'custom' }
]
} satisfies Model);
});
const sharedOthers = ref<Model['others']>(customModel.value.others)
const currentA = ref<Model['currents']>([{ label: 'A', type: 'preset' }])
const currentB = ref<Model['currents']>([])
const updateSharedOthers = (others: Item[]) => {
sharedOthers.value
= unionBy(sharedOthers.value, others, 'label')
.filter(item => [...currentA.value, ...currentB.value].every(({ label }) => item.label !== label ))
}
const tags = ref<string[]>(['1', '2'])
const sharedTags = ref<string[]>(['3'])
const setTags = (v: string[]) => {
sharedTags.value
= [...tags.value, ...sharedTags.value].filter(tag => !(v.includes(tag)))
tags.value
= v
}
</script>
```ts
@ -75,7 +97,10 @@ const nullModel = ref({
<Pills v-model="nullModel" />
```
<Pills v-model="nullModel" />
<Pills
:get="(v) => { return }"
:set="() => nullModel"
/>
## Static list of pills
@ -90,10 +115,16 @@ const staticModel = ref({
```
```vue-html
<Pills v-model="staticModel"/>
<Pills
:get="(v) => { return }"
:set="() => staticModel"
/>
```
<Pills v-model="staticModel"/>
<Pills
:get="(v) => { return }"
:set="() => staticModel"
/>
## Let users add, remove and edit custom pills
@ -111,10 +142,16 @@ const simpleCustomModel = ref({
```
```vue-html
<Pills v-model="simpleCustomModel"/>
<Pills
:get="(v) => { simpleCustomModel = v }"
:set="() => staticModel"
/>
```
<Pills v-model="simpleCustomModel"/>
<Pills
:get="(v) => { simpleCustomModel = v }"
:set="() => staticModel"
/>
### Complex example
@ -130,7 +167,8 @@ const customModel = ref({
```vue-html
<Pills
v-model="customModel"
:get="(v) => { customModel = v }"
:set="() => customModel"
label="Custom Tags"
cancel="Cancel"
/>
@ -139,7 +177,127 @@ const customModel = ref({
<Spacer />
<Pills
v-model="customModel"
:get="(v) => { customModel = v }"
:set="() => customModel"
label="Custom Tags"
cancel="Cancel"
/>
## Bind data with an external sink
In the following example, `others` are shared among two `Pills` lists.
```ts
const sharedOthers = ref<Model['others']>(customModel.value.others)
const currentA = ref<Model['currents']>([{ label: 'A', type: 'preset' }])
const currentB = ref<Model['currents']>([])
const updateSharedOthers = (others: Item[]) => {
sharedOthers.value
= unionBy(sharedOthers.value, others, 'label')
.filter(item =>
[...currentA.value, ...currentB.value].every(({ label }) =>
item.label !== label
))
}
```
<!-- prettier-ignore-start -->
<Spacer />
<Pills
:get="({ currents, others }) => {
currentA = currents;
updateSharedOthers(others || []);
}"
:set="({ currents, others }) => ({ currents: currentA, others: unionBy(sharedOthers, others, 'label') })"
label="A"
cancel="Cancel"
/>
<Spacer />
<Pills
:get="({ currents, others }) => {
currentB = currents;
updateSharedOthers(others || []);
}"
:set="({ currents, others }) => ({ currents: currentB, others: unionBy(sharedOthers, others, 'label') })"
label="B"
cancel="Cancel"
/>
<template
v-for="_ in [1]"
:key="[...sharedOthers].join(',')"
>
<pre> Shared among A and B:
{{ (sharedOthers || []).map(({ label }) => label).join(', ') }}
</pre>
</template>
<!-- prettier-ignore-end -->
## Bind data with an external source
You can use the same pattern to influence the model from an outside source:
```ts
const tags = ref<string[]>(['1', '2'])
const sharedTags = ref<string[]>(['3'])
const setTags = (v: string[]) => {
sharedTags.value
= [...tags.value, ...sharedTags.value].filter(tag => !(v.includes(tag)))
tags.value
= v
}
```
<!-- prettier-ignore-start -->
<Layout
flex
gap-8
>
<Button
secondary
@click="setTags([])"
>
Set tags=[]
</Button>
<Button
secondary
@click="setTags(['1', '2', '3'])"
>
Set tags=['1', '2', '3']
</Button>
</Layout>
<hr />
<template
v-for="_ in [1]"
:key="[...tags, '*', ...sharedTags].join(',')"
>
<Layout flex>
<pre>{{ tags.join(', ') }}</pre>
<Spacer grow />
<pre>{{ sharedTags.join(', ') }}</pre>
</Layout>
<hr />
<Spacer />
<Pills
:get="({ currents, others }) => {
setTags(currents.map(({ label }) => label));
sharedTags
= union(sharedTags, (others || []).map(({ label }) => label))
}"
:set="({ currents, others }) => ({
currents: tags.map(l => ({ type: 'custom', label: l})),
others: sharedTags
.filter(l => currents.every(({ label }) => label !== l))
.map(l => ({ type: 'custom', label: l}))
})"
label="Two-way binding with an array of strings"
cancel="Cancel"
/>
</template>
<!-- prettier-ignore-end -->