-
-
-
+
+
+
$refs.trigger.$el.focus())"
+ @escape-key-down="focus"
>
{
if (event.defaultPrevented) return;
- nextTick(() => $refs.trigger?.$el?.focus());
+ focus();
event.preventDefault();
}"
>
-
-
-
+
+
+
+
+ {{ getOptionLabel(option) }}
+
+
+
+
{{ __('No options available.') }}
-
+
-
-
- {{ __(getOptionLabel(option)) }}
+
+
+ {{ __(getOptionLabel(option)) }}
-
-
-
-
-
+
-
-
@@ -538,18 +527,18 @@ defineExpose({
v-if="!disabled && !readOnly"
type="button"
class="opacity-75 hover:opacity-100 cursor-pointer"
- :aria-label="__('Deselect option')"
+ :aria-label="__('Remove :label', { label: getOptionLabel(option) })"
@click="deselect(option.value)"
>
×
-
+
×
-
+
-
+
diff --git a/resources/js/components/ui/Combobox/Scrollbar.vue b/resources/js/components/ui/Combobox/Scrollbar.vue
deleted file mode 100644
index a568c91f69e..00000000000
--- a/resources/js/components/ui/Combobox/Scrollbar.vue
+++ /dev/null
@@ -1,128 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/resources/js/components/ui/Select/Select.vue b/resources/js/components/ui/Select/Select.vue
index e5c5b2c5875..f3fd3b88e4b 100644
--- a/resources/js/components/ui/Select/Select.vue
+++ b/resources/js/components/ui/Select/Select.vue
@@ -5,24 +5,29 @@ import Combobox from '../Combobox/Combobox.vue';
const emit = defineEmits(['update:modelValue']);
const props = defineProps({
- clearable: { type: Boolean, default: false },
- disabled: { type: Boolean, default: false },
- /** Icon name. [Browse available icons](/?path=/story/components-icon--all-icons) */
- icon: { type: String, default: null },
- /** The controlled value of the select. */
- modelValue: { type: [Object, String, Number], default: null },
- /** Key of the option's label in the option's object. */
- optionLabel: { type: String, default: 'label' },
- /** Array of option objects */
- options: { type: Array, default: null },
- /** Key of the option's value in the option's object. */
- optionValue: { type: String, default: 'value' },
- placeholder: { type: String, default: () => __('Select...') },
- readOnly: { type: Boolean, default: false },
- /** Controls the size of the select.
Options: `xs`, `sm`, `base`, `lg`, `xl` */
- size: { type: String, default: 'base' },
- /** Controls the appearance of the select.
Options: `default`, `filled`, `ghost`, `subtle` */
- variant: { type: String, default: 'default' },
+ /** When `true`, the dropdown will expand to fit longer option labels. */
+ adaptiveWidth: { type: Boolean, default: false },
+ /** The preferred alignment against the trigger. May change when collisions occur.
Options: `start`, `center`, `end` */
+ align: { type: String, default: 'start' },
+ /** When `true`, the selected value will be clearable. */
+ clearable: { type: Boolean, default: false },
+ disabled: { type: Boolean, default: false },
+ /** Icon name. [Browse available icons](/?path=/story/components-icon--all-icons) */
+ icon: { type: String, default: null },
+ /** The controlled value of the select. */
+ modelValue: { type: [Object, String, Number], default: null },
+ /** Key of the option's label in the option's object. */
+ optionLabel: { type: String, default: 'label' },
+ /** Array of option objects */
+ options: { type: Array, default: null },
+ /** Key of the option's value in the option's object. */
+ optionValue: { type: String, default: 'value' },
+ placeholder: { type: String, default: () => __('Select...') },
+ readOnly: { type: Boolean, default: false },
+ /** Controls the size of the select.
Options: `xs`, `sm`, `base`, `lg`, `xl` */
+ size: { type: String, default: 'base' },
+ /** Controls the appearance of the select.
Options: `default`, `filled`, `ghost`, `subtle` */
+ variant: { type: String, default: 'default' },
});
defineOptions({
@@ -52,6 +57,8 @@ const usingOptionSlot = !!slots['option'];
:searchable="false"
:size
:variant
+ :align
+ :adaptive-width
@update:modelValue="emit('update:modelValue', $event)"
>
diff --git a/resources/js/stories/Combobox.stories.ts b/resources/js/stories/Combobox.stories.ts
new file mode 100644
index 00000000000..013e7cc9225
--- /dev/null
+++ b/resources/js/stories/Combobox.stories.ts
@@ -0,0 +1,991 @@
+import type {Meta, StoryObj} from '@storybook/vue3';
+import {expect, fn, userEvent, within} from 'storybook/test';
+import {Combobox} from '@ui';
+import {ref} from 'vue';
+import {icons} from "@/stories/icons";
+
+const meta = {
+ title: 'Forms/Combobox',
+ component: Combobox,
+ argTypes: {
+ icon: {
+ control: 'select',
+ options: icons,
+ },
+ size: {
+ control: 'select',
+ options: ['xs', 'sm', 'base', 'lg', 'xl'],
+ },
+ variant: {
+ control: 'select',
+ options: ['default', 'filled', 'ghost', 'subtle'],
+ },
+ align: {
+ control: 'select',
+ options: ['start', 'center', 'end'],
+ },
+ 'update:modelValue': {
+ description: 'Event handler called when the selected option changes.',
+ table: {
+ category: 'events',
+ type: { summary: '(value: string | string[]) => void' },
+ },
+ },
+ search: {
+ description: 'Event handler called when the search query changes.',
+ table: {
+ category: 'events',
+ type: { summary: '(query: string) => void' },
+ },
+ },
+ selected: {
+ description: 'Event handler called when an option is selected.',
+ table: {
+ category: 'events',
+ type: { summary: '(value: string) => void' },
+ },
+ },
+ added: {
+ description: 'Event handler called when a taggable option is added.',
+ table: {
+ category: 'events',
+ type: { summary: '(value: string) => void' },
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ const options = [
+ { label: 'The Midnight', value: 'the_midnight' },
+ { label: 'The 1975', value: 'the_1975' },
+ { label: 'Sunglasses Kid', value: 'sunglasses_kid' },
+ { label: 'FM-84', value: 'fm_84' },
+ { label: 'Timecop1983', value: 'timecop1983' },
+ ];
+ return { value, options };
+ },
+ template: `
+
+ `,
+ }),
+};
+
+const defaultOptions = [
+ { label: 'The Midnight', value: 'the_midnight' },
+ { label: 'The 1975', value: 'the_1975' },
+ { label: 'Sunglasses Kid', value: 'sunglasses_kid' },
+ { label: 'FM-84', value: 'fm_84' },
+ { label: 'Timecop1983', value: 'timecop1983' },
+];
+
+const defaultCode = `
+
+`;
+
+export const _DocsIntro: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: defaultCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ const options = defaultOptions;
+ return { value, options };
+ },
+ template: `
+
+ `,
+ }),
+};
+
+const sizesCode = `
+
+
+
+
+
+`;
+
+export const _Sizes: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: sizesCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const options = defaultOptions;
+ return { options };
+ },
+ template: `${sizesCode}
`,
+ }),
+};
+
+const variantsCode = `
+
+
+
+
+`;
+
+export const _Variants: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: variantsCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const options = defaultOptions;
+ return { options };
+ },
+ template: `${variantsCode}
`,
+ }),
+};
+
+const clearableCode = `
+
+`;
+
+export const _Clearable: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: clearableCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref('the_midnight');
+ const options = defaultOptions;
+ return { value, options };
+ },
+ template: `
+
+ `,
+ }),
+};
+
+const multipleCode = `
+
+`;
+
+export const _Multiple: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: multipleCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(['the_midnight', 'fm_84']);
+ const options = defaultOptions;
+ return { value, options };
+ },
+ template: `
+
+ `,
+ }),
+};
+
+const maxSelectionsCode = `
+
+`;
+
+export const _MaxSelections: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: maxSelectionsCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(['the_midnight', 'fm_84']);
+ const options = defaultOptions;
+ return { value, options };
+ },
+ template: `
+
+ `,
+ }),
+};
+
+const taggableCode = `
+
+`;
+
+export const _Taggable: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: taggableCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(['the_midnight']);
+ const options = defaultOptions;
+ return { value, options };
+ },
+ template: `
+
+ `,
+ }),
+};
+
+const searchDisabledCode = `
+
+`;
+
+export const _SearchDisabled: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: searchDisabledCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ const options = defaultOptions;
+ return { value, options };
+ },
+ template: `
+
+ `,
+ }),
+};
+
+const ignoreFilterCode = `
+
+`;
+
+export const _IgnoreFilter: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: ignoreFilterCode },
+ description: {
+ story: 'When `ignoreFilter` is true, the Combobox will not filter options locally. Use this when you want to handle filtering yourself via the `search` event, such as with server-side filtering.',
+ },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ const allOptions = defaultOptions;
+ const filteredOptions = ref([...allOptions]);
+
+ const onSearch = (query: string) => {
+ if (!query) {
+ filteredOptions.value = [...allOptions];
+ return;
+ }
+ filteredOptions.value = allOptions.filter((opt) =>
+ opt.label.toLowerCase().includes(query.toLowerCase())
+ );
+ };
+
+ return { value, filteredOptions, onSearch };
+ },
+ template: `
+
+ `,
+ }),
+};
+
+const optionSlotsCode = `
+
+
+
+
+
+
+
+
+
+
+`;
+
+export const _OptionSlots: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: optionSlotsCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref('tyler');
+ const options = [
+ { label: 'Tyler Lyle', image: 'https://i.pravatar.cc/100?u=tyler', value: 'tyler' },
+ { label: 'Tim McEwan', image: 'https://i.pravatar.cc/100?u=tim', value: 'tim' },
+ { label: 'Nikki Flores', image: 'https://i.pravatar.cc/100?u=nikki', value: 'nikki' },
+ ];
+ return { value, options };
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+const adaptiveWidthCode = `
+
+`;
+
+export const _AdaptiveWidth: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: adaptiveWidthCode },
+ },
+ },
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ const options = [
+ { label: 'Short', value: 'short' },
+ { label: 'A much longer option label', value: 'long' },
+ { label: 'An extremely long option that demonstrates adaptive width', value: 'very_long' },
+ ];
+ return { value, options };
+ },
+ template: `
+
+
+
+ `,
+ }),
+};
+
+export const TestTriggerWidth: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ return { value, options: defaultOptions };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+
+ const content = document.querySelector('[data-ui-combobox-content]');
+ await expect(content).toBeTruthy();
+ expect(content?.classList.contains('w-max')).toBe(false);
+ },
+};
+
+export const TestAdaptiveWidth: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ const options = [
+ { label: 'A very long option label that should make the dropdown wider', value: 'long' },
+ ];
+ return { value, options };
+ },
+ template: `
`,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+
+ const content = document.querySelector('[data-ui-combobox-content]');
+ await expect(content).toBeTruthy();
+ expect(content?.classList.contains('w-max')).toBe(true);
+ },
+};
+
+export const TestCanSelectOption: Story = {
+ tags: ['!dev', 'test'],
+ args: {
+ 'onUpdate:modelValue': fn(),
+ },
+ render: (args) => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ return { value, options: defaultOptions, onUpdate: args['onUpdate:modelValue'] };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+
+ const option = await within(document.body).findByText('The Midnight');
+ await userEvent.click(option);
+
+ expect(args['onUpdate:modelValue']).toHaveBeenCalledWith('the_midnight');
+
+ await new Promise((r) => setTimeout(r, 100));
+ const selectedOption = canvasElement.querySelector('[data-ui-combobox-selected-option]');
+ expect(selectedOption?.textContent).toContain('The Midnight');
+ },
+};
+
+export const TestCanSelectMultipleOptions: Story = {
+ tags: ['!dev', 'test'],
+ args: {
+ 'onUpdate:modelValue': fn(),
+ },
+ render: (args) => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(['the_midnight']);
+ return { value, options: defaultOptions, onUpdate: args['onUpdate:modelValue'] };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+
+ const option = await within(document.body).findByText('FM-84');
+ await userEvent.click(option);
+
+ await expect(args['onUpdate:modelValue']).toHaveBeenCalled();
+ const lastCall = args['onUpdate:modelValue'].mock.calls[args['onUpdate:modelValue'].mock.calls.length - 1];
+ expect(lastCall[0]).toContain('the_midnight');
+ expect(lastCall[0]).toContain('fm_84');
+
+ const selectedOptions = canvasElement.querySelector('[data-ui-combobox-selected-options]');
+ expect(selectedOptions).toBeTruthy();
+ expect(selectedOptions?.textContent).toContain('The Midnight');
+ expect(selectedOptions?.textContent).toContain('FM-84');
+ },
+};
+
+export const TestDropdownClosesOnSelection: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ return { value, options: defaultOptions };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeTruthy();
+
+ const option = await within(document.body).findByText('The Midnight');
+ await userEvent.click(option);
+
+ await new Promise((r) => setTimeout(r, 100));
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeFalsy();
+ },
+};
+
+export const TestCanClearSelection: Story = {
+ tags: ['!dev', 'test'],
+ args: {
+ 'onUpdate:modelValue': fn(),
+ },
+ render: (args) => ({
+ components: { Combobox },
+ setup() {
+ const value = ref('the_midnight');
+ return { value, options: defaultOptions, onUpdate: args['onUpdate:modelValue'] };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const clearButton = canvas.getByRole('button', { name: /clear/i });
+
+ await userEvent.click(clearButton);
+
+ expect(args['onUpdate:modelValue']).toHaveBeenCalledWith(null);
+ },
+};
+
+export const TestMaxSelectionsLimit: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(['the_midnight', 'the_1975']);
+ return { value, options: defaultOptions };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+
+ const disabledOption = document.querySelector('[data-ui-combobox-item="sunglasses_kid"]');
+ expect(disabledOption?.hasAttribute('data-disabled')).toBe(true);
+
+ // Deselect one option by clicking it
+ const selectedOption = document.querySelector('[data-ui-combobox-item="the_midnight"]');
+ await userEvent.click(selectedOption!);
+ await new Promise((r) => setTimeout(r, 100));
+
+ // Reopen dropdown to check state
+ await userEvent.click(trigger);
+ await new Promise((r) => setTimeout(r, 100));
+
+ const nowEnabledOption = document.querySelector('[data-ui-combobox-item="sunglasses_kid"]');
+ expect(nowEnabledOption).toBeTruthy();
+ expect(nowEnabledOption!.hasAttribute('data-disabled')).toBe(false);
+ },
+};
+
+export const TestCanDeselectOptions: Story = {
+ tags: ['!dev', 'test'],
+ args: {
+ 'onUpdate:modelValue': fn(),
+ },
+ render: (args) => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(['the_midnight', 'fm_84']);
+ return { value, options: defaultOptions, onUpdate: args['onUpdate:modelValue'] };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const removeButtons = canvas.getAllByRole('button', { name: /remove/i });
+
+ await userEvent.click(removeButtons[0]);
+
+ await expect(args['onUpdate:modelValue']).toHaveBeenCalled();
+
+ const lastCall = args['onUpdate:modelValue'].mock.calls[args['onUpdate:modelValue'].mock.calls.length - 1];
+ expect(lastCall[0]).toContain('fm_84');
+ expect(lastCall[0]).not.toContain('the_midnight');
+ },
+};
+
+export const TestCanSearchOptions: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ 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, '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 TestSearchDisabledWhenNotSearchable: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref('the_midnight');
+ return { value, options: defaultOptions };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement }) => {
+ const input = canvasElement.querySelector('input[type="search"]');
+ expect(input).toBeFalsy();
+ },
+};
+
+export const TestIgnoreFilterDoesNotFilter: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ 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, 'xyz');
+
+ await new Promise((r) => setTimeout(r, 100));
+
+ const options = document.querySelectorAll('[data-ui-combobox-item]');
+ expect(options.length).toBe(5);
+ },
+};
+
+export const TestTaggableCanAddOptions: 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.click(input);
+ await userEvent.type(input, 'new-tag');
+ await userEvent.keyboard('{Enter}');
+
+ await expect(args['onUpdate:modelValue']).toHaveBeenCalled();
+ expect(args.onAdded).toHaveBeenCalledWith('new-tag');
+ },
+};
+
+export const TestDropdownOpensOnSpace: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ return { value, options: defaultOptions };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ trigger.focus();
+ await userEvent.keyboard(' ');
+
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeTruthy();
+ },
+};
+
+export const TestDropdownOpensOnEnter: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ return { value, options: defaultOptions };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ trigger.focus();
+ await userEvent.keyboard('{Enter}');
+
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeTruthy();
+ },
+};
+
+export const TestDropdownClosesOnEscape: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(null);
+ return { value, options: defaultOptions };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole('combobox');
+
+ await userEvent.click(trigger);
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeTruthy();
+
+ await userEvent.keyboard('{Escape}');
+
+ await new Promise((r) => setTimeout(r, 100));
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeFalsy();
+ },
+};
+
+const manyOptions = [
+ { label: 'Option 1', value: 'option_1' },
+ { label: 'Option 2', value: 'option_2' },
+ { label: 'Option 3', value: 'option_3' },
+ { label: 'Option 4', value: 'option_4' },
+ { label: 'Option 5', value: 'option_5' },
+ { label: 'Option 6', value: 'option_6' },
+ { label: 'Option 7', value: 'option_7' },
+ { label: 'Option 8', value: 'option_8' },
+ { label: 'Option 9', value: 'option_9' },
+ { label: 'Option 10', value: 'option_10' },
+ { label: 'Option 11', value: 'option_11' },
+ { label: 'Option 12', value: 'option_12' },
+ { label: 'Option 13', value: 'option_13' },
+ { label: 'Option 14', value: 'option_14' },
+ { label: 'Option 15', value: 'option_15' },
+];
+
+export const TestScrollsToSelectedOption: Story = {
+ tags: ['!dev', 'test'],
+ render: () => ({
+ components: { Combobox },
+ setup() {
+ const value = ref('option_10');
+ return { value, options: manyOptions };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement }) => {
+ const trigger = canvasElement.querySelector('[data-ui-combobox-trigger]');
+ await expect(trigger).toBeTruthy();
+
+ await userEvent.click(trigger!);
+ await new Promise((r) => setTimeout(r, 150));
+
+ // The selected option (Option 10) should be highlighted
+ const highlightedOption = document.querySelector('[data-ui-combobox-item][data-highlighted]');
+ await expect(highlightedOption).toBeTruthy();
+ expect(highlightedOption?.getAttribute('data-ui-combobox-item')).toBe('option_10');
+
+ // Arrow down should go to Option 11, not Option 1
+ await userEvent.keyboard('{ArrowDown}');
+ await new Promise((r) => setTimeout(r, 50));
+
+ const newHighlightedOption = document.querySelector('[data-ui-combobox-item][data-highlighted]');
+ expect(newHighlightedOption?.getAttribute('data-ui-combobox-item')).toBe('option_11');
+
+ // Arrow up twice should go to Option 10 then Option 9
+ await userEvent.keyboard('{ArrowUp}');
+ await userEvent.keyboard('{ArrowUp}');
+ await new Promise((r) => setTimeout(r, 50));
+
+ const upHighlightedOption = document.querySelector('[data-ui-combobox-item][data-highlighted]');
+ expect(upHighlightedOption?.getAttribute('data-ui-combobox-item')).toBe('option_9');
+ },
+};
+
+export const TestDisabledStatePreventsInteraction: Story = {
+ tags: ['!dev', 'test'],
+ args: {
+ 'onUpdate:modelValue': fn(),
+ },
+ render: (args) => ({
+ components: { Combobox },
+ setup() {
+ const value = ref('the_midnight');
+ return { value, options: defaultOptions, onUpdate: args['onUpdate:modelValue'] };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement, args }) => {
+ const trigger = canvasElement.querySelector('[data-ui-combobox-trigger]');
+ await expect(trigger).toBeTruthy();
+
+ // Click should not open dropdown
+ await userEvent.click(trigger!);
+ await new Promise((r) => setTimeout(r, 100));
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeFalsy();
+
+ // Clear button should not work (it exists but click shouldn't trigger update)
+ const clearButton = canvasElement.querySelector('[data-ui-combobox-clear-button]');
+ if (clearButton) {
+ await userEvent.click(clearButton);
+ await new Promise((r) => setTimeout(r, 100));
+ }
+
+ // Model value should not have changed
+ await expect(args['onUpdate:modelValue']).not.toHaveBeenCalled();
+ },
+};
+
+export const TestDisabledStateMultiplePreventsInteraction: Story = {
+ tags: ['!dev', 'test'],
+ args: {
+ 'onUpdate:modelValue': fn(),
+ },
+ render: (args) => ({
+ components: { Combobox },
+ setup() {
+ const value = ref(['the_midnight', 'fm_84']);
+ return { value, options: defaultOptions, onUpdate: args['onUpdate:modelValue'] };
+ },
+ template: ` `,
+ }),
+ play: async ({ canvasElement, args }) => {
+ const trigger = canvasElement.querySelector('[data-ui-combobox-trigger]');
+ await expect(trigger).toBeTruthy();
+
+ // Click should not open dropdown
+ await userEvent.click(trigger!);
+ await new Promise((r) => setTimeout(r, 100));
+ await expect(document.querySelector('[data-ui-combobox-content]')).toBeFalsy();
+
+ // Remove buttons should not exist on badges when disabled
+ const removeButtons = canvasElement.querySelectorAll('[data-ui-combobox-selected-options] button[aria-label*="Remove"]');
+ expect(removeButtons.length).toBe(0);
+
+ // Model value should not have changed
+ await expect(args['onUpdate:modelValue']).not.toHaveBeenCalled();
+ },
+};
diff --git a/resources/js/stories/Select.stories.ts b/resources/js/stories/Select.stories.ts
index 146b569b850..6a1a1c2b5ce 100644
--- a/resources/js/stories/Select.stories.ts
+++ b/resources/js/stories/Select.stories.ts
@@ -1,6 +1,7 @@
import type {Meta, StoryObj} from '@storybook/vue3';
import {Select} from '@ui';
import {icons} from "@/stories/icons";
+import {ref} from "vue";
const meta = {
title: 'Forms/Select',
@@ -31,13 +32,23 @@ const meta = {
export default meta;
type Story = StoryObj;
+const defaultOptions = [
+ { label: 'The Midnight', value: 'the_midnight' },
+ { label: 'The 1975', value: 'the_1975' },
+ { label: 'Sunglasses Kid', value: 'sunglasses_kid' },
+ { label: 'FM-84', value: 'fm_84' },
+ { label: 'Timecop1983', value: 'timecop1983' },
+];
+
const defaultCode = `
`;
@@ -46,147 +57,149 @@ export const _DocsIntro: Story = {
tags: ['!dev'],
parameters: {
docs: {
- source: { code: defaultCode }
- }
+ source: { code: defaultCode },
+ },
},
render: () => ({
components: { Select },
- template: defaultCode,
+ setup() {
+ const value = ref(null);
+ const options = defaultOptions;
+ return { value, options };
+ },
+ template: `
+
+ `,
}),
};
const sizesCode = `
-
+
+
+
+
+
`;
export const _Sizes: Story = {
tags: ['!dev'],
parameters: {
docs: {
- source: { code: sizesCode }
- }
+ source: { code: sizesCode },
+ },
},
render: () => ({
components: { Select },
- template: sizesCode,
+ setup() {
+ const options = defaultOptions;
+ return { options };
+ },
+ template: `${sizesCode}
`,
}),
};
const variantsCode = `
-
-
-
-
+
+
+
+
`;
export const _Variants: Story = {
tags: ['!dev'],
parameters: {
docs: {
- source: { code: variantsCode }
- }
+ source: { code: variantsCode },
+ },
},
render: () => ({
components: { Select },
- template: variantsCode,
+ setup() {
+ const options = defaultOptions;
+ return { options };
+ },
+ template: `${variantsCode}
`,
}),
};
const clearableCode = `
-
+
`;
export const _Clearable: Story = {
tags: ['!dev'],
parameters: {
docs: {
- source: { code: clearableCode }
- }
+ source: { code: clearableCode },
+ },
},
render: () => ({
components: { Select },
- template: clearableCode,
+ setup() {
+ const value = ref('the_midnight');
+ const options = defaultOptions;
+ return { value, options };
+ },
+ template: `
+
+ `,
}),
};
const iconCode = `
-
+
`;
export const _Icon: Story = {
tags: ['!dev'],
parameters: {
docs: {
- source: { code: iconCode }
- }
+ source: { code: iconCode },
+ },
},
render: () => ({
components: { Select },
- template: iconCode,
+ setup() {
+ const value = ref('the_midnight');
+ return { value };
+ },
+ template: `
+
+ `,
}),
};
-const customListItemsCode = `
-
+const optionSlotsCode = `
+
+
+
+
+
@@ -194,15 +207,82 @@ const customListItemsCode = `
`;
-export const _CustomListItems: Story = {
+export const _OptionSlots: Story = {
tags: ['!dev'],
parameters: {
docs: {
- source: { code: customListItemsCode }
- }
+ source: { code: optionSlotsCode },
+ },
},
render: () => ({
components: { Select },
- template: customListItemsCode,
+ setup() {
+ const value = ref('tyler');
+ const options = [
+ { label: 'Tyler Lyle', image: 'https://i.pravatar.cc/100?u=tyler', value: 'tyler' },
+ { label: 'Tim McEwan', image: 'https://i.pravatar.cc/100?u=tim', value: 'tim' },
+ { label: 'Nikki Flores', image: 'https://i.pravatar.cc/100?u=nikki', value: 'nikki' },
+ ];
+ return { value, options };
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+ `,
}),
};
+
+const adaptiveWidthCode = `
+
+`;
+
+export const _AdaptiveWidth: Story = {
+ tags: ['!dev'],
+ parameters: {
+ docs: {
+ source: { code: adaptiveWidthCode },
+ },
+ },
+ render: () => ({
+ components: { Select },
+ setup() {
+ const value = ref(null);
+ const options = [
+ { label: 'Short', value: 'short' },
+ { label: 'A much longer option label', value: 'long' },
+ { label: 'An extremely long option that demonstrates adaptive width', value: 'very_long' },
+ ];
+ return { value, options };
+ },
+ template: `
+
+
+
+ `,
+ }),
+};
\ No newline at end of file
diff --git a/resources/js/stories/docs/Combobox.mdx b/resources/js/stories/docs/Combobox.mdx
new file mode 100644
index 00000000000..fba290cc178
--- /dev/null
+++ b/resources/js/stories/docs/Combobox.mdx
@@ -0,0 +1,58 @@
+import { Canvas, Meta, ArgTypes } from '@storybook/addon-docs/blocks';
+import * as ComboboxStories from '../Combobox.stories';
+
+
+
+# Combobox
+A flexible selection component that combines a text input with a dropdown list. Supports single and multiple selection, search/filtering, tagging, and keyboard navigation.
+
+
+
+You should provide an array of option objects via the `options` prop. The objects must contain `value` and `label` keys by default, but you can customize the keys using the `optionValue` and `optionLabel` props.
+
+## Sizes
+Use the `size` prop to control the input's height and type scale. Available sizes: `xs`, `sm`, `base`, `lg`, `xl`.
+
+
+## Variants
+Use the `variant` prop to change the appearance of the combobox. Available variants: `default`, `filled`, `ghost`, `subtle`.
+
+
+## Clearable
+Add a clear button to your combobox by setting the `clearable` prop.
+
+
+## Multiple Selections
+Enable multiple selection mode with the `multiple` prop. Selected items appear as badges below the input and can be reordered via drag and drop.
+
+
+## Max Selections
+Limit the number of selections with the `maxSelections` prop. A counter is displayed showing the current selection count.
+
+
+## Taggable
+Allow users to add new options by typing and pressing enter. Set `taggable` to `true` to enable this feature. Users can also paste comma-separated values to add multiple tags at once.
+
+
+## Disable Search
+By default, the combobox is searchable. You may disable search by setting the `searchable` prop to `false`.
+
+
+## Server-side Searching
+When `ignoreFilter` is `true`, the combobox won't filter options locally. Use this with the `search` event to implement server-side searching.
+
+
+## Customize Options
+You may customize how options are rendered using the `selected-option` and `option` slots.
+
+- The `selected-option` slot is used to display the currently selected option in the "trigger"
+- The `option` slot is used to display individual options in the dropdown
+
+
+
+## Adaptive Width
+By default, the dropdown matches the trigger width. Set `adaptiveWidth` to `true` to allow the dropdown to expand to fit longer option labels.
+
+
+## Arguments
+
diff --git a/resources/js/stories/docs/Select.mdx b/resources/js/stories/docs/Select.mdx
index 09d0a2ba85a..b955af130d0 100644
--- a/resources/js/stories/docs/Select.mdx
+++ b/resources/js/stories/docs/Select.mdx
@@ -4,31 +4,39 @@ import * as SelectStories from '../Select.stories';
# Select
-Displays a list of options for the user to pick from - triggered by an input style button.
+Displays a list of options for the user to pick from - triggered by an input style button. Built on top of the [Combobox](/?path=/docs/forms-combobox--docs) component.
+
-## Data Format
-Pass data into the select with the `options` prop as an array of objects. The objects must contain a `value` and a `label`, but can contain any other day you would like as well.
+You should provide an array of option objects via the `options` prop. The objects must contain `value` and `label` keys by default, but you can customize the keys using the `optionValue` and `optionLabel` props.
## Sizes
-Use the `size` prop to control the input's height and type scale. The scales match the button sizes to make sure everything lines up nicely.
+Use the `size` prop to control the input's height and type scale. Available sizes: `xs`, `sm`, `base`, `lg`, `xl`.
## Variants
-Use the `variant` prop to change the appearance of the select.
+Use the `variant` prop to change the appearance of the combobox. Available variants: `default`, `filled`, `ghost`, `subtle`.
## Clearable
-Add a clear button to your select by setting the `clearable` prop.
+Add a clear button to your combobox by setting the `clearable` prop.
## Icon
Adds an icon to your select box.
-## Custom List Items
-You can customize the markup and display of the list items inside of an `#options` slot, with full access to whatever data is passed into the select.
-
+## Customize Options
+You may customize how options are rendered using the `selected-option` and `option` slots.
+
+- The `selected-option` slot is used to display the currently selected option in the "trigger"
+- The `option` slot is used to display individual options in the dropdown
+
+
+
+## Adaptive Width
+By default, the dropdown matches the trigger width. Set `adaptiveWidth` to `true` to allow the dropdown to expand to fit longer option labels.
+
## Arguments