diff --git a/resources/js/components/inputs/relationship/SelectField.vue b/resources/js/components/inputs/relationship/SelectField.vue
index 6bedba8c661..628d716ae12 100644
--- a/resources/js/components/inputs/relationship/SelectField.vue
+++ b/resources/js/components/inputs/relationship/SelectField.vue
@@ -12,6 +12,7 @@
:read-only="readOnly"
:taggable="isTaggable"
:close-on-select="isTaggable"
+ :search-keys="searchKeys"
option-label="title"
option-value="id"
@update:modelValue="itemsSelected"
@@ -91,6 +92,12 @@ export default {
};
},
+ // The `users` fieldtype falls back to displaying a user's email as their title when
+ // they have no name, but doesn't show it otherwise, so it needs to be searchable too.
+ searchKeys() {
+ return this.config.type === 'users' ? ['title', 'email'] : null;
+ },
+
cacheKey() {
return JSON.stringify({ ...this.parameters, url: this.url });
},
diff --git a/resources/js/components/ui/Combobox/Combobox.vue b/resources/js/components/ui/Combobox/Combobox.vue
index 478da310f21..8e46a26295b 100644
--- a/resources/js/components/ui/Combobox/Combobox.vue
+++ b/resources/js/components/ui/Combobox/Combobox.vue
@@ -61,6 +61,8 @@ const props = defineProps({
readOnly: { type: Boolean, default: false },
/** When `true`, the options will be searchable. */
searchable: { type: Boolean, default: true },
+ /** Keys of the option object to search against. Defaults to just `optionLabel`. */
+ searchKeys: { type: Array, default: null },
/** Determines if the dropdown should open */
shouldOpenDropdown: { type: Function, default: () => true },
/** Controls the size of the combobox.
Options: `xs`, `sm`, `base`, `lg`, `xl` */
@@ -236,7 +238,7 @@ const filteredOptions = computed(() => {
fuzzysort
.go(searchQuery.value, props.options, {
all: true,
- key: props.optionLabel,
+ ...(props.searchKeys?.length ? { keys: props.searchKeys } : { key: props.optionLabel }),
})
.map((result) => result.obj)
);
diff --git a/resources/js/stories/Combobox.stories.ts b/resources/js/stories/Combobox.stories.ts
index ea53ab5a6cd..3b2e73b2aef 100644
--- a/resources/js/stories/Combobox.stories.ts
+++ b/resources/js/stories/Combobox.stories.ts
@@ -383,6 +383,50 @@ export const _IgnoreFilter: Story = {
}),
};
+const searchKeysCode = `
+
+`;
+
+export const _SearchKeys: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: searchKeysCode },
+ description: {
+ story: 'By default, search only matches against `optionLabel`. Use `searchKeys` to also match against other keys on the option object — useful when an option has a value that isn\'t displayed but should still be searchable, such as an email address. Try searching "nightowl" below — it only appears in Tim McEwan\'s email, not his label.',
+ },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ const options = [
+ { label: 'Tyler Lyle', email: 'workhorse92@example.com', value: 'tyler' },
+ { label: 'Tim McEwan', email: 'nightowl47@example.com', value: 'tim' },
+ { label: 'Nikki Flores', email: 'skyline08@example.com', value: 'nikki' },
+ ];
+ return { value, options };
+ },
+ template: `
+
+ `,
+ }),
+};
+
const optionSlotsCode = `
({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ const options = [
+ { label: 'Tyler Lyle', email: 'workhorse92@example.com', value: 'tyler' },
+ { label: 'Tim McEwan', email: 'nightowl47@example.com', value: 'tim' },
+ { label: 'Nikki Flores', email: 'skyline08@example.com', value: 'nikki' },
+ ];
+ return { value, options };
+ },
+ template: ``,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+
+ const input = document.querySelector('input[type="search"]') as HTMLInputElement;
+
+ // "nightowl" only appears in Tim McEwan's email, not in any label.
+ await userEvent.type(input, 'nightowl');
+
+ await new Promise((r) => setTimeout(r, 100));
+
+ const options = document.querySelectorAll('[data-ui-combobox-item]');
+ expect(options.length).toBe(1);
+ expect(options[0].getAttribute('data-ui-combobox-item')).toBe('tim');
+ },
+};
+
export const TestSearchDisabledWhenNotSearchable: Story = {
tags: ['!dev', 'test'],
render: () => ({
diff --git a/resources/js/stories/docs/Combobox.mdx b/resources/js/stories/docs/Combobox.mdx
index fba290cc178..eaf5d845f17 100644
--- a/resources/js/stories/docs/Combobox.mdx
+++ b/resources/js/stories/docs/Combobox.mdx
@@ -42,6 +42,10 @@ By default, the combobox is searchable. You may disable search by setting the `s
When `ignoreFilter` is `true`, the combobox won't filter options locally. Use this with the `search` event to implement server-side searching.
+## Search by Multiple Keys
+By default, search only matches against the `optionLabel` key. Use the `searchKeys` prop to also match against other keys on the option object — useful when an option has a value that isn't displayed but should still be searchable, such as an email address.
+
+
## Customize Options
You may customize how options are rendered using the `selected-option` and `option` slots.
diff --git a/resources/js/tests/components/inputs/relationship/SelectField.test.js b/resources/js/tests/components/inputs/relationship/SelectField.test.js
index 322f3f5216b..856ab21a57c 100644
--- a/resources/js/tests/components/inputs/relationship/SelectField.test.js
+++ b/resources/js/tests/components/inputs/relationship/SelectField.test.js
@@ -17,12 +17,12 @@ const stubs = {
StatusIndicator: true,
};
-function mountSelectField({ items = [] } = {}) {
+function mountSelectField({ items = [], config = {} } = {}) {
return mount(SelectField, {
props: {
items,
url: '/test/select-field',
- config: {},
+ config,
},
global: {
mocks: {
@@ -51,3 +51,21 @@ describe('SelectField comboboxOptions', () => {
wrapper.unmount();
});
});
+
+describe('SelectField searchKeys', () => {
+ test('searches by title and email for the users fieldtype', () => {
+ const wrapper = mountSelectField({ config: { type: 'users' } });
+
+ expect(wrapper.vm.searchKeys).toEqual(['title', 'email']);
+
+ wrapper.unmount();
+ });
+
+ test('is null for other relationship fieldtypes', () => {
+ const wrapper = mountSelectField({ config: { type: 'entries' } });
+
+ expect(wrapper.vm.searchKeys).toBeNull();
+
+ wrapper.unmount();
+ });
+});