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

View File

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

View File

@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { unionBy, union } from 'lodash-es'
import Pills from '~/components/ui/Pills.vue'; import Pills from '~/components/ui/Pills.vue';
import Button from '~/components/ui/Button.vue';
import Spacer from '~/components/ui/Spacer.vue'; import Spacer from '~/components/ui/Spacer.vue';
import Layout from '~/components/ui/Layout.vue';
type Item = { type: 'custom' | 'preset', label: string } type Item = { type: 'custom' | 'preset', label: string }
type Model = { type Model = {
@ -14,26 +17,45 @@ const nullModel = ref({
currents: [] currents: []
} satisfies Model) } satisfies Model)
const staticModel = ref({ const staticModel = ref<Model>({
currents: [ currents: [
{ label: "#noise", type: 'preset' }, { label: "#noise", type: 'preset' },
{ label: "#fieldRecording", type: 'preset' }, { label: "#fieldRecording", type: 'preset' },
{ label: "#experiment", type: 'preset' } { label: "#experiment", type: 'preset' }
] ]
} satisfies Model); });
const simpleCustomModel = ref({ const simpleCustomModel = ref<Model>({
currents: [], currents: [],
others: [] others: []
}) })
const customModel = ref({ const customModel = ref<Model>({
...staticModel.value, ...staticModel.value,
others: [ others: [
{ label: "#myTag1", type: 'custom' }, { label: "#myTag1", type: 'custom' },
{ label: "#myTag2", 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> </script>
```ts ```ts
@ -75,7 +97,10 @@ const nullModel = ref({
<Pills v-model="nullModel" /> <Pills v-model="nullModel" />
``` ```
<Pills v-model="nullModel" /> <Pills
:get="(v) => { return }"
:set="() => nullModel"
/>
## Static list of pills ## Static list of pills
@ -90,10 +115,16 @@ const staticModel = ref({
``` ```
```vue-html ```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 ## Let users add, remove and edit custom pills
@ -111,10 +142,16 @@ const simpleCustomModel = ref({
``` ```
```vue-html ```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 ### Complex example
@ -130,7 +167,8 @@ const customModel = ref({
```vue-html ```vue-html
<Pills <Pills
v-model="customModel" :get="(v) => { customModel = v }"
:set="() => customModel"
label="Custom Tags" label="Custom Tags"
cancel="Cancel" cancel="Cancel"
/> />
@ -139,7 +177,127 @@ const customModel = ref({
<Spacer /> <Spacer />
<Pills <Pills
v-model="customModel" :get="(v) => { customModel = v }"
:set="() => customModel"
label="Custom Tags" label="Custom Tags"
cancel="Cancel" 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 -->