Skip to content

[6.x] Add <ui-combobox> component (select fieldtype) #11771

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 36 commits into from
May 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
fe6d4e3
wip
duncanmcclean May 6, 2025
3bb9e8d
Remove `push_tags` config option from select fieldtype
duncanmcclean May 6, 2025
511e6f5
wip
duncanmcclean May 6, 2025
08e4591
wip
duncanmcclean May 6, 2025
ac86e9c
wip
duncanmcclean May 6, 2025
9f1026b
wip
duncanmcclean May 6, 2025
999daa9
wip
duncanmcclean May 7, 2025
dde0280
wip
duncanmcclean May 7, 2025
4503f14
wip
duncanmcclean May 7, 2025
c78f164
wip
duncanmcclean May 7, 2025
a7afb2e
wip
duncanmcclean May 7, 2025
7e92eb1
wip
duncanmcclean May 7, 2025
70cb333
wip
duncanmcclean May 7, 2025
56bed70
wip
duncanmcclean May 8, 2025
c331c0b
wip
duncanmcclean May 8, 2025
d5ae62c
wip
duncanmcclean May 8, 2025
c23bb4b
pass around the option object, not just its key
duncanmcclean May 9, 2025
f616491
allow option label & value keys to be different
duncanmcclean May 9, 2025
4263c87
wip
duncanmcclean May 9, 2025
4f20ef9
Merge remote-tracking branch 'origin/ui' into select-fieldtype
duncanmcclean May 9, 2025
3008d51
Revert "pass around the option object, not just its key"
duncanmcclean May 9, 2025
457f2e5
wip
duncanmcclean May 9, 2025
b84b241
wip
duncanmcclean May 9, 2025
b80ffbd
Add `input` event to `emits` array
duncanmcclean May 12, 2025
7637252
wip
duncanmcclean May 12, 2025
c689f3a
Change our approach to option data in relationship fieldtypes
duncanmcclean May 12, 2025
d5a9259
wip
duncanmcclean May 12, 2025
1b99da8
wip
duncanmcclean May 12, 2025
986a6ef
wip
duncanmcclean May 12, 2025
b9fd116
Display selected option using slot
duncanmcclean May 12, 2025
568578e
Add some polish to the awesome new combobox
jackmcdade May 14, 2025
9d7a95e
Merge branch 'ui' into select-fieldtype
jackmcdade May 14, 2025
2efa243
prettier
jasonvarga May 14, 2025
d53ec50
Make `ui/select` a wrapper around `ui/combobox`
duncanmcclean May 15, 2025
683e559
Add `w-full` to select on playground page
duncanmcclean May 15, 2025
29dfac5
Ensure placeholder is shown when `searchable="false"`
duncanmcclean May 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 78 additions & 124 deletions resources/js/components/fieldtypes/DictionaryFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,105 +1,79 @@
<template>
<div class="flex">
<v-select
ref="input"
:input-id="fieldId"
class="flex-1"
append-to-body
searchable
close-on-select
:calculate-position="positionOptions"
:name="name"
:disabled="config.disabled || isReadOnly || (multiple && limitReached)"
:options="normalizeInputOptions(options)"
:placeholder="__(config.placeholder)"
:multiple="multiple"
:model-value="selectedOptions"
:get-option-key="(option) => option.value"
@update:model-value="vueSelectUpdated"
@focus="$emit('focus')"
@search="search"
@search:focus="$emit('focus')"
search:blur="$emit('blur')"
>
<template #selected-option-container v-if="multiple"><i class="hidden"></i></template>
<template #search="{ events, attributes }" v-if="multiple">
<input
:placeholder="__(config.placeholder)"
class="vs__search"
type="search"
v-on="events"
v-bind="attributes"
/>
</template>
<template #option="{ label }">
<div v-html="label" />
</template>
<template #selected-option="{ label }">
<div v-html="label" />
</template>
<template #no-options>
<div
class="px-4 py-2 text-sm text-gray-700 ltr:text-left rtl:text-right"
v-text="__('No options to choose from.')"
/>
</template>
<template #footer="{ deselect }" v-if="multiple">
<sortable-list
item-class="sortable-item"
handle-class="sortable-item"
:model-value="value"
:distance="5"
:mirror="false"
@update:model-value="update"
>
<div class="vs__selected-options-outside flex flex-wrap">
<span
v-for="option in selectedOptions"
:key="option.value"
class="vs__selected sortable-item mt-2"
:class="{ invalid: option.invalid }"
>
<div v-html="option.label" />
<Combobox
class="w-full"
searchable
ignore-filter
:disabled="config.disabled || isReadOnly"
:max-selections="config.max_items"
:options="normalizedOptions"
:placeholder="__(config.placeholder)"
:multiple
:model-value="value"
@update:modelValue="comboboxUpdated"
@search="search"
>
<!--
This slot is *basically* exactly the same as the default selected-options slot in Combobox. We're just looping
through the Dictionary Fieldtype's selectedOptions state, rather than the one maintained by the Combobox component.
-->
<template #selected-options="{ disabled, getOptionLabel, getOptionValue, labelHtml, deselect }">
<sortable-list
v-if="multiple"
item-class="sortable-item"
handle-class="sortable-item"
:distance="5"
:mirror="false"
:disabled
:model-value="value"
@update:modelValue="comboboxUpdated"
>
<div class="vs__selected-options-outside flex flex-wrap gap-2 pt-3">
<div
v-for="option in selectedOptions"
:key="getOptionValue(option)"
class="vs__selected sortable-item cursor-grab"
>
<Badge pill size="lg">
<div v-if="labelHtml" v-html="getOptionLabel(option)"></div>
<div v-else>{{ __(getOptionLabel(option)) }}</div>

<button
v-if="!readOnly"
@click="deselect(option)"
v-if="!disabled"
type="button"
class="-mx-3 cursor-pointer px-3 text-gray-400 hover:text-gray-700"
:aria-label="__('Deselect option')"
class="vs__deselect"
@click="deselect(option.value)"
>
<span>×</span>
<span>&times;</span>
</button>
<button v-else type="button" class="vs__deselect">
<span class="text-gray-500">×</span>
<button
v-else
type="button"
class="-mx-3 cursor-pointer px-3 text-gray-400 hover:text-gray-700"
>
<span>&times;</span>
</button>
</span>
</Badge>
</div>
</sortable-list>
</template>
</v-select>
<div class="mt-3 text-xs ltr:ml-2 rtl:mr-2" :class="limitIndicatorColor" v-if="config.max_items > 1">
<span v-text="currentLength"></span>/<span v-text="config.max_items"></span>
</div>
</div>
</div>
</sortable-list>
</template>
</Combobox>
</template>

<style scoped>
.draggable-source--is-dragging {
@apply border-dashed bg-transparent opacity-75;
}
</style>

<script>
import Fieldtype from './Fieldtype.vue';
import HasInputOptions from './HasInputOptions.js';
import { SortableList } from '../sortable/Sortable';
import PositionsSelectOptions from '../../mixins/PositionsSelectOptions';
import debounce from '@statamic/util/debounce.js';
import { Badge, Combobox } from '@statamic/ui';

export default {
mixins: [Fieldtype, HasInputOptions, PositionsSelectOptions],
mixins: [Fieldtype, HasInputOptions],

components: {
Badge,
Combobox,
SortableList,
},

Expand All @@ -115,6 +89,10 @@ export default {
return this.config.max_items !== 1;
},

normalizedOptions() {
return this.normalizeInputOptions(this.options);
},

selectedOptions() {
let selections = this.value || [];

Expand All @@ -137,36 +115,6 @@ export default {
return this.selectedOptions.map((option) => option.label).join(', ');
},

limitReached() {
if (!this.config.max_items) return false;

return this.currentLength >= this.config.max_items;
},

limitExceeded() {
if (!this.config.max_items) return false;

return this.currentLength > this.config.max_items;
},

currentLength() {
if (this.value) {
return typeof this.value == 'string' ? 1 : this.value.length;
}

return 0;
},

limitIndicatorColor() {
if (this.limitExceeded) {
return 'text-red-500';
} else if (this.limitReached) {
return 'text-green-600';
}

return 'text-gray';
},

configParameter() {
return utf8btoa(JSON.stringify(this.config));
},
Expand All @@ -181,18 +129,24 @@ export default {
this.$refs.input.focus();
},

vueSelectUpdated(value) {
if (this.multiple) {
this.update(value.map((v) => v.value));
value.forEach((option) => this.selectedOptionData.push(option));
} else {
if (value) {
this.update(value.value);
this.selectedOptionData.push(value);
} else {
this.update(null);
}
comboboxUpdated(value) {
this.update(value || null);

let selections = value || [];

if (typeof selections === 'string' || typeof selections === 'number') {
selections = [selections];
}

selections.forEach((value) => {
if (this.selectedOptionData.find((option) => option.value === value)) {
return;
}

let option = this.normalizedOptions.find((option) => option.value === value);

this.selectedOptionData.push(option);
});
},

request(params = {}) {
Expand Down
97 changes: 36 additions & 61 deletions resources/js/components/fieldtypes/IconFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,60 +1,47 @@
<template>
<div class="icon-fieldtype-wrapper flex">
<v-select
v-if="!loading"
ref="input"
class="w-full"
append-to-body
:calculate-position="positionOptions"
clearable
:name="name"
:disabled="config.disabled || isReadOnly"
:options="options"
:placeholder="__(config.placeholder || 'Search...')"
:searchable="true"
:multiple="false"
:close-on-select="true"
:model-value="selectedOption"
:create-option="(value) => ({ value, label: value })"
@update:model-value="vueSelectUpdated"
@search:focus="$emit('focus')"
search:blur="$emit('blur')"
>
<template #option="option">
<div class="flex items-center">
<svg-icon v-if="!option.html" :name="`${meta.set}/${option.label}`" class="h-5 w-5" />
<div v-if="option.html" v-html="option.html" class="h-5 w-5" />
<span class="truncate text-xs text-gray-800 dark:text-dark-150 ltr:ml-4 rtl:mr-4">{{
__(option.label)
}}</span>
</div>
</template>
<template #selected-option="option">
<div class="flex items-center">
<svg-icon
v-if="!option.html"
:name="`${meta.set}/${option.label}`"
class="flex h-5 w-5 items-center"
/>
<div v-if="option.html" v-html="option.html" class="h-5 w-5" />
<span class="truncate text-xs text-gray-800 dark:text-dark-150 ltr:ml-4 rtl:mr-4">{{
__(option.label)
}}</span>
</div>
</template>
</v-select>
</div>
<Combobox
v-if="!loading"
class="w-full"
clearable
:disabled="config.disabled || isReadOnly"
:options="options"
:placeholder="__(config.placeholder || 'Search...')"
:searchable="true"
:multiple="false"
:model-value="value"
@update:modelValue="comboboxUpdated"
>
<template #option="option">
<div class="flex items-center">
<svg-icon v-if="!option.html" :name="`${meta.set}/${option.label}`" class="size-4" />
<div v-if="option.html" v-html="option.html" class="size-4" />
<span class="ms-3 truncate">
{{ __(option.label) }}
</span>
</div>
</template>
<template #selected-option="{ option }">
<div class="flex items-center">
<svg-icon v-if="!option.html" :name="`${meta.set}/${option.label}`" class="flex size-4 items-center" />
<div v-if="option.html" v-html="option.html" class="size-4" />
<span class="ms-3 truncate text-sm text-gray-800 dark:text-gray-200">
{{ __(option.label) }}
</span>
</div>
</template>
</Combobox>
</template>

<script>
import Fieldtype from './Fieldtype.vue';
import PositionsSelectOptions from '../../mixins/PositionsSelectOptions';
import { ref, watch } from 'vue';
import { Combobox } from '@statamic/ui';
const iconsCache = ref({});
const loaders = ref({});

export default {
mixins: [Fieldtype, PositionsSelectOptions],
components: { Combobox },
mixins: [Fieldtype],

data() {
return {
Expand All @@ -79,10 +66,6 @@ export default {
}
return options;
},

selectedOption() {
return this.options.find((option) => option.value === this.value);
},
},

created() {
Expand All @@ -98,16 +81,8 @@ export default {
},

methods: {
focus() {
this.$refs.input.focus();
},

vueSelectUpdated(value) {
if (value) {
this.update(value.value);
} else {
this.update(null);
}
comboboxUpdated(value) {
this.update(value || null);
},

request() {
Expand Down
Loading
Loading