Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions resources/js/components/field-validation/Builder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,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 +273,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 @@ -18,8 +18,9 @@
<span class="text-gray-500 dark:text-gray-400" v-text="getSample(value)" />
</div>
</template>
<template #option="{ value, label, sample }">
<template v-if="value === 'language'">{{ label }}</template>
<template #option="{ value, label, sample, 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 @@ -18,10 +18,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 @@ -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 };
},
},
};
</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 @@ -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];
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;

Expand All @@ -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();
Expand Down Expand Up @@ -369,7 +404,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 All @@ -390,7 +431,7 @@ defineExpose({
<ComboboxTrigger
as="div"
ref="trigger"
:tabindex="disabled || readOnly ? -1 : 0"
:tabindex="disabled || readOnly || shouldShowInput ? -1 : 0"
:class="triggerClasses"
data-ui-combobox-trigger
@keydown.enter="openDropdown"
Expand All @@ -409,6 +450,7 @@ defineExpose({
type="search"
autocomplete="off"
v-model="searchQuery"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
@keydown.enter="pushTaggableOption"
Expand Down Expand Up @@ -507,12 +549,15 @@ 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)">
<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)">
<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 @@ -1076,6 +1076,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