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 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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 -->
|
||||
|
|
Loading…
Reference in New Issue