Skip to content
Merged
3 changes: 2 additions & 1 deletion resources/js/components/field-conditions/Condition.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
>
<template #no-options><div class="hidden" /></template>
<template #option="option">
<div class="flex items-center">
<span v-if="option.create" class="truncate">{{ __('Add ":value"', { value: option.value }) }}</span>
<div v-else class="flex items-center">
<span class="flex-shrink-0 truncate" v-text="option.label" />
<span
v-text="option.value"
Expand Down
10 changes: 7 additions & 3 deletions resources/js/components/field-validation/Builder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@
@added="ifSearchNotFoundAddCustom"
>
<template #option="option">
{{ option.display }} <code class="px-1 rounded-sm text-[0.8rem]">{{ valueWithoutTrailingColon(option.value) }}</code>
<template v-if="option.create">{{ __('Add ":value"', { value: option.value }) }}</template>
<template v-else>
{{ option.display }} <code class="px-1 rounded-sm text-[0.8rem]">{{ valueWithoutTrailingColon(option.value) }}</code>
</template>
</template>

<template #selected-options>
Expand Down Expand Up @@ -256,8 +259,9 @@ export default {

ifSearchNotFoundAddCustom() {
let rulesSelect = this.$refs.rulesSelect;
let rule = rulesSelect.searchQuery.value;
let rule = rulesSelect?.searchQuery;

if (!rule) return;
if (this.searchNotFound(rulesSelect) || this.hasUnfinishedParameters(rule)) return;

this.add(rule);
Expand All @@ -272,7 +276,7 @@ export default {
},

searchNotFound(rulesSelect) {
return rulesSelect.searchQuery.value?.length === 0 || rulesSelect?.filteredOptions.length === 0;
return rulesSelect.filteredOptions.length === 0;
},

updated(rules) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
<span class="text-gray-500 dark:text-gray-400" v-text="getSample(value)" />
</div>
</template>
<template #option="{ value, label }">
<template v-if="value === 'language'">{{ label }}</template>
<template #option="{ value, label, create }">
<template v-if="create">{{ __('Add ":value"', { value }) }}</template>
<template v-else-if="value === 'language'">{{ label }}</template>
<div v-else class="w-full flex justify-between">
<div class="text-start flex-1">
{{ label }}
Expand Down
10 changes: 3 additions & 7 deletions resources/js/components/inputs/relationship/SelectField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
@update:modelValue="itemsSelected"
@search="search"
>
<template #option="{ title, hint, status }">
<template #option="{ title, hint, status, create }">
<div class="flex w-full text-left items-center gap-2">
<StatusIndicator v-if="status" :status="status" />
<div v-text="title" class="truncate grow" />
<div v-if="create" class="truncate grow">{{ __('Add ":value"', { value: title }) }}</div>
<div v-else v-text="title" class="truncate grow" />
<ui-badge v-if="hint" size="sm" v-text="hint" />
</div>
</template>
Expand Down Expand Up @@ -189,11 +190,6 @@ export default {

this.$emit('input', items);
},

createOption(value) {
const existing = this.options.find((option) => option.title === value);
return existing || { id: value, title: value };
},
},
};
</script>
91 changes: 68 additions & 23 deletions resources/js/components/ui/Combobox/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,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];
Expand Down Expand Up @@ -241,37 +246,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();
}
Expand Down Expand Up @@ -314,6 +331,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;

Expand All @@ -340,6 +374,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();
Expand Down Expand Up @@ -378,7 +413,13 @@ defineExpose({
</script>

<template>
<div :class="wrapperClasses" v-bind="wrapperAttrs">
<div
ref="wrapper"
:class="wrapperClasses"
v-bind="wrapperAttrs"
@pointerdown="focusedByPointer = true"
@click="focusedByPointer = false"
>
<div class="flex w-full min-w-0">
<ComboboxRoot
ref="root"
Expand Down Expand Up @@ -424,6 +465,7 @@ defineExpose({
type="search"
autocomplete="off"
v-model="searchQuery"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
@keydown.enter="pushTaggableOption"
Expand Down Expand Up @@ -522,13 +564,16 @@ defineExpose({
:class="itemClasses({ size: size, selected: isSelected(option) })"
:data-ui-combobox-item="getOptionValue(option)"
:title="getOptionLabel(option)"
@select="select"
@select="select(option)"
>
<slot name="option" v-bind="option">
<img v-if="option.image" :src="option.image" class="size-5 rounded-full" :alt="getOptionLabel(option)">
<Icon v-else-if="option.icon" :name="option.icon" />
<span v-if="labelHtml" class="truncate" v-html="getOptionLabel(option)" />
<span class="truncate" v-else>{{ __(getOptionLabel(option)) }}</span>
<span v-if="option.create" class="truncate">{{ __('Add ":value"', { value: getOptionLabel(option) }) }}</span>
<template v-else>
<img v-if="option.image" :src="option.image" class="size-5 rounded-full" :alt="getOptionLabel(option)">
<Icon v-else-if="option.icon" :name="option.icon" />
<span v-if="labelHtml" class="truncate" v-html="getOptionLabel(option)" />
<span class="truncate" v-else>{{ __(getOptionLabel(option)) }}</span>
</template>
</slot>
</ComboboxItem>
</div>
Expand Down
115 changes: 115 additions & 0 deletions resources/js/stories/Combobox.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,121 @@ export const TestTaggableSinglePasteDoesNotCommitTags: Story = {
},
};

export const TestTaggableShowsAddOptionAlongsideMatches: Story = {
tags: ['!dev', 'test'],
render: () => ({
components: { Combobox },
setup() {
const value = ref<string[]>([]);
return { value, options: defaultOptions };
},
template: `<Combobox v-model="value" :options="options" multiple taggable placeholder="Add tags..." />`,
}),
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<string[]>([]);
return { value, options: defaultOptions };
},
template: `<Combobox v-model="value" :options="options" multiple taggable placeholder="Add tags..." />`,
}),
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<string[]>([]);
return { value, options: defaultOptions, onUpdate: args['onUpdate:modelValue'], onAdded: args.onAdded };
},
template: `<Combobox v-model="value" :options="options" multiple taggable placeholder="Add tags..." @update:modelValue="onUpdate" @added="onAdded" />`,
}),
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<string[]>([]);
return { value, options: defaultOptions };
},
template: `
<input data-testid="before" />
<Combobox v-model="value" :options="options" multiple taggable placeholder="Add tags..." />
`,
}),
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: () => ({
Expand Down
Loading