docs(ui): #2390 bind external sources and sinks to tags lists (Pills)
This commit is contained in:
parent
81cf009f96
commit
c2c0a2aa79
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
Loading…
Reference in New Issue