{{ label }}
diff --git a/resources/js/components/inputs/relationship/SelectField.vue b/resources/js/components/inputs/relationship/SelectField.vue
index 628d716ae12..0d383f608c9 100644
--- a/resources/js/components/inputs/relationship/SelectField.vue
+++ b/resources/js/components/inputs/relationship/SelectField.vue
@@ -18,10 +18,11 @@
@update:modelValue="itemsSelected"
@search="search"
>
-
+
-
+
{{ __('Add ":value"', { value: title }) }}
+
@@ -191,11 +192,6 @@ export default {
this.$emit('input', items);
},
-
- createOption(value) {
- const existing = this.options.find((option) => option.title === value);
- return existing || { id: value, title: value };
- },
},
};
diff --git a/resources/js/components/ui/Combobox/Combobox.vue b/resources/js/components/ui/Combobox/Combobox.vue
index 67684f59fd9..67cea7d79ce 100644
--- a/resources/js/components/ui/Combobox/Combobox.vue
+++ b/resources/js/components/ui/Combobox/Combobox.vue
@@ -142,12 +142,17 @@ const itemClasses = cva({
const searchQuery = ref('');
const dropdownOpen = ref(false);
+const focusedByPointer = ref(false);
const rootRef = useTemplateRef('root');
+const wrapperRef = useTemplateRef('wrapper');
const triggerRef = useTemplateRef('trigger');
const searchInputRef = useTemplateRef('search');
watch(searchQuery, (value) => emit('search', value, () => {}));
-watch(dropdownOpen, () => searchQuery.value = '');
+
+watch(dropdownOpen, (open) => {
+ if (!open) searchQuery.value = '';
+});
const getOptionLabel = (option) => {
const label = option?.[props.optionLabel];
@@ -232,37 +237,49 @@ const placeholder = computed(() => {
});
const filteredOptions = computed(() => {
- if (!props.searchable || props.ignoreFilter) {
- return props.options;
+ let results = [...props.options];
+
+ if (props.searchable && !props.ignoreFilter) {
+ const matches = new Set(
+ fuzzysort
+ .go(searchQuery.value, props.options, {
+ all: true,
+ ...(props.searchKeys?.length ? { keys: props.searchKeys } : { key: props.optionLabel }),
+ })
+ .map((result) => result.obj)
+ );
+
+ results = props.options.filter((option) => matches.has(option));
}
- const matches = new Set(
- fuzzysort
- .go(searchQuery.value, props.options, {
- all: true,
- ...(props.searchKeys?.length ? { keys: props.searchKeys } : { key: props.optionLabel }),
- })
- .map((result) => result.obj)
- );
-
- const results = props.options.filter((option) => matches.has(option));
-
- if (props.taggable && searchQuery.value && results.length === 0) {
- results.push({
+ if (shouldShowCreateOption.value) {
+ results.unshift({
[props.optionLabel]: searchQuery.value,
[props.optionValue]: searchQuery.value,
+ create: true,
});
}
return results;
});
+const shouldShowCreateOption = computed(() => {
+ if (!props.taggable || !searchQuery.value) return false;
+
+ const matchesSearchQuery = (option) =>
+ getOptionLabel(option) === searchQuery.value || String(getOptionValue(option)) === searchQuery.value;
+
+ return !props.options.some(matchesSearchQuery) && !selectedOptions.value.some(matchesSearchQuery);
+});
+
function clear() {
searchQuery.value = '';
emit('update:modelValue', null);
}
-function select() {
+function select(option) {
+ if (option.create) emit('added', getOptionValue(option));
+
dropdownOpen.value = !shouldCloseOnSelect.value;
if (shouldCloseOnSelect.value) triggerRef.value?.$el?.focus();
}
@@ -305,6 +322,23 @@ function openDropdown(e) {
updateDropdownOpen(true);
}
+function onFocus(e) {
+ const byPointer = focusedByPointer.value;
+ focusedByPointer.value = false;
+
+ if (!props.taggable || byPointer || dropdownOpen.value) return;
+ if (!focusCameFromOutside(e)) return;
+
+ updateDropdownOpen(true);
+}
+
+function focusCameFromOutside(e) {
+ if (!e.relatedTarget) return false;
+ if ('rekaCollectionItem' in e.relatedTarget.dataset) return false;
+
+ return !wrapperRef.value?.contains(e.relatedTarget);
+}
+
function onBlur(e) {
if (!props.taggable) return;
@@ -331,6 +365,7 @@ function onPaste(e) {
function pushTaggableOption(e) {
if (!props.taggable) return;
+ if (e.defaultPrevented) return; // Reka prevents the event's default when it selects a highlighted option.
if (e.target.value === '') return;
e.preventDefault();
@@ -369,7 +404,13 @@ defineExpose({
-
+
-
-
- {{ __(getOptionLabel(option)) }}
+ {{ __('Add ":value"', { value: getOptionLabel(option) }) }}
+
+
+
+ {{ __(getOptionLabel(option)) }}
+
diff --git a/resources/js/stories/Combobox.stories.ts b/resources/js/stories/Combobox.stories.ts
index 11d1b945793..0e983dcf6e8 100644
--- a/resources/js/stories/Combobox.stories.ts
+++ b/resources/js/stories/Combobox.stories.ts
@@ -1076,6 +1076,121 @@ export const TestTaggableSinglePasteDoesNotCommitTags: Story = {
},
};
+export const TestTaggableShowsAddOptionAlongsideMatches: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref
([]);
+ return { value, options: defaultOptions };
+ },
+ 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;
+ await userEvent.type(input, 'the');
+
+ await new Promise((r) => setTimeout(r, 100));
+
+ const options = document.querySelectorAll('[data-ui-combobox-item]');
+ expect(options.length).toBe(3);
+ expect(options[0].getAttribute('data-ui-combobox-item')).toBe('the');
+ expect(options[0].textContent).toContain('Add "the"');
+ },
+};
+
+export const TestTaggableHidesAddOptionWhenQueryMatchesExistingOption: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref([]);
+ return { value, options: defaultOptions };
+ },
+ 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;
+ await userEvent.type(input, 'The Midnight');
+
+ 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('the_midnight');
+ },
+};
+
+export const TestTaggableEnterSelectsHighlightedOption: Story = {
+ tags: ['!dev', 'test'],
+ args: {
+ 'onUpdate:modelValue': fn(),
+ onAdded: fn(),
+ },
+ render: (args) => ({
+ components: { Combobox },
+ setup() {
+ const value = ref([]);
+ return { value, options: defaultOptions, onUpdate: args['onUpdate:modelValue'], onAdded: args.onAdded };
+ },
+ template: ``,
+ }),
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+
+ const input = document.querySelector('input[type="search"]') as HTMLInputElement;
+ await userEvent.type(input, 'the');
+
+ await new Promise((r) => setTimeout(r, 100));
+
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{Enter}');
+
+ await expect(args['onUpdate:modelValue']).toHaveBeenCalledWith(['the_midnight']);
+ expect(args.onAdded).not.toHaveBeenCalled();
+ },
+};
+
+export const TestTaggableDropdownOpensOnTabFocus: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref([]);
+ return { value, options: defaultOptions };
+ },
+ template: `
+
+
+ `,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ canvas.getByTestId('before').focus();
+ await userEvent.tab();
+
+ await new Promise((r) => setTimeout(r, 100));
+
+ const input = document.querySelector('input[type="search"]') as HTMLInputElement;
+ expect(document.activeElement).toBe(input);
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeTruthy();
+ },
+};
+
export const TestDropdownOpensOnSpace: Story = {
tags: ['!dev', 'test'],
render: () => ({