diff --git a/docs/components/content/examples/FormExampleElements.vue b/docs/components/content/examples/FormExampleElements.vue index a25f3412..26c8fd15 100644 --- a/docs/components/content/examples/FormExampleElements.vue +++ b/docs/components/content/examples/FormExampleElements.vue @@ -10,6 +10,7 @@ const options = [ const state = reactive({ input: undefined, + inputMenu: undefined, textarea: undefined, select: undefined, selectMenu: undefined, @@ -23,6 +24,9 @@ const state = reactive({ const schema = z.object({ input: z.string().min(10), + inputMenu: z.any().refine(option => option?.value === 'option-2', { + message: 'Select Option 2' + }), textarea: z.string().min(10), select: z.string().refine(value => value === 'option-2', { message: 'Select Option 2' @@ -61,6 +65,10 @@ async function onSubmit (event: FormSubmitEvent) { + + + + diff --git a/docs/components/content/examples/InputMenuExampleBasic.vue b/docs/components/content/examples/InputMenuExampleBasic.vue new file mode 100644 index 00000000..97b1e4d8 --- /dev/null +++ b/docs/components/content/examples/InputMenuExampleBasic.vue @@ -0,0 +1,9 @@ + + + diff --git a/docs/components/content/examples/InputMenuExampleEmptySlot.vue b/docs/components/content/examples/InputMenuExampleEmptySlot.vue new file mode 100644 index 00000000..3a7285b7 --- /dev/null +++ b/docs/components/content/examples/InputMenuExampleEmptySlot.vue @@ -0,0 +1,13 @@ + + + diff --git a/docs/components/content/examples/InputMenuExampleObjects.vue b/docs/components/content/examples/InputMenuExampleObjects.vue new file mode 100644 index 00000000..f6527d43 --- /dev/null +++ b/docs/components/content/examples/InputMenuExampleObjects.vue @@ -0,0 +1,36 @@ + + + diff --git a/docs/components/content/examples/InputMenuExampleObjectsValueAttribute.vue b/docs/components/content/examples/InputMenuExampleObjectsValueAttribute.vue new file mode 100644 index 00000000..500e259c --- /dev/null +++ b/docs/components/content/examples/InputMenuExampleObjectsValueAttribute.vue @@ -0,0 +1,26 @@ + + + diff --git a/docs/components/content/examples/InputMenuExampleOptionEmptySlot.vue b/docs/components/content/examples/InputMenuExampleOptionEmptySlot.vue new file mode 100644 index 00000000..409329e1 --- /dev/null +++ b/docs/components/content/examples/InputMenuExampleOptionEmptySlot.vue @@ -0,0 +1,13 @@ + + + diff --git a/docs/components/content/examples/InputMenuExampleOptionSlot.vue b/docs/components/content/examples/InputMenuExampleOptionSlot.vue new file mode 100644 index 00000000..666087a1 --- /dev/null +++ b/docs/components/content/examples/InputMenuExampleOptionSlot.vue @@ -0,0 +1,25 @@ + + + diff --git a/docs/components/content/examples/InputMenuExamplePopperArrow.vue b/docs/components/content/examples/InputMenuExamplePopperArrow.vue new file mode 100644 index 00000000..45527692 --- /dev/null +++ b/docs/components/content/examples/InputMenuExamplePopperArrow.vue @@ -0,0 +1,9 @@ + + + diff --git a/docs/components/content/examples/InputMenuExamplePopperOffset.vue b/docs/components/content/examples/InputMenuExamplePopperOffset.vue new file mode 100644 index 00000000..2b3ef6fb --- /dev/null +++ b/docs/components/content/examples/InputMenuExamplePopperOffset.vue @@ -0,0 +1,9 @@ + + + diff --git a/docs/components/content/examples/InputMenuExamplePopperPlacement.vue b/docs/components/content/examples/InputMenuExamplePopperPlacement.vue new file mode 100644 index 00000000..20e6411c --- /dev/null +++ b/docs/components/content/examples/InputMenuExamplePopperPlacement.vue @@ -0,0 +1,9 @@ + + + diff --git a/docs/components/content/examples/InputMenuExampleSearchAttributes.vue b/docs/components/content/examples/InputMenuExampleSearchAttributes.vue new file mode 100644 index 00000000..0df49da7 --- /dev/null +++ b/docs/components/content/examples/InputMenuExampleSearchAttributes.vue @@ -0,0 +1,28 @@ + + + diff --git a/docs/content/3.forms/1.input.md b/docs/content/3.forms/1.input.md index b06ba702..795591ec 100644 --- a/docs/content/3.forms/1.input.md +++ b/docs/content/3.forms/1.input.md @@ -172,13 +172,13 @@ Use the `#leading` slot to set the content of the leading icon. ::component-card --- slots: - leading: + leading: baseProps: placeholder: 'Search...' --- #leading - :u-avatar{src="https://avatars.githubusercontent.com/u/739984?v=4" size="3xs"} + :u-avatar{src="https://avatars.githubusercontent.com/u/739984?v=4" size="3xs" class="mx-0.5"} :: ### `trailing` diff --git a/docs/content/3.forms/2.input-menu.md b/docs/content/3.forms/2.input-menu.md new file mode 100644 index 00000000..cedb6fd9 --- /dev/null +++ b/docs/content/3.forms/2.input-menu.md @@ -0,0 +1,170 @@ +--- +title: InputMenu +description: Display an autocomplete input with real-time suggestions. +links: + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxt/ui/blob/dev/src/runtime/components/forms/InputMenu.vue + - label: 'Combobox' + icon: i-simple-icons-headlessui + to: 'https://headlessui.com/vue/combobox' +navigation: + badge: New +--- + +## Usage + +The `InputMenu` component renders by default an [Input](/forms/input) component and is based on the `ui.input` preset. You can use most of the `Input` props to configure the display such as [color](/forms/input#style), [variant](/forms/input#style), [size](/forms/input#size), [placeholder](/forms/input#placeholder), [icon](/forms/input#icon), [disabled](/forms/input#disabled), etc. + +You can use the `ui` prop like the `Input` component to override the default config. The `uiMenu` prop can be used to override the default menu config. + +Pass an array of strings or objects to the `options` prop to display in the menu. + +::component-example +--- +component: 'input-menu-example-basic' +componentProps: + class: 'w-full lg:w-48' +--- +:: + +::callout{icon="i-heroicons-exclamation-triangle"} +This component does not support multiple values. Use the [SelectMenu](/forms/select-menu#multiple) component instead. +:: + +### Objects + +You can pass an array of objects to `options` and either compare on the whole object or use the `by` prop to compare on a specific key. You can configure which field will be used to display the label through the `option-attribute` prop that defaults to `label`. + +::component-example +--- +component: 'input-menu-example-objects' +componentProps: + class: 'w-full lg:w-48' +--- +:: + +Use the `search-attributes` prop with an array of property names to search on each option object. Nested attributes can be accessed using `dot.notation`. When the property value is an array or object, these are cast to string so these can be searched within. + +::component-example +--- +component: 'input-menu-example-search-attributes' +componentProps: + class: 'w-full lg:w-48' +--- +:: + +If you only want to select a single object property rather than the whole object as value, you can set the `value-attribute` property. This prop defaults to `null`. + +::component-example +--- +component: 'input-menu-example-objects-value-attribute' +componentProps: + class: 'w-full lg:w-48' +--- +:: + +### Icon + +The `InputMenu` has a button on the right to toggle the menu. Use the `trailing-icon` prop to set a different icon or change it globally in `ui.inputMenu.default.trailingIcon`. Defaults to `i-heroicons-chevron-down-20-solid`. + +::component-card +--- +baseProps: + class: 'w-full lg:w-48' + placeholder: 'Select a person' + options: ['Wade Cooper', 'Arlene Mccoy', 'Devon Webb', 'Tom Cook', 'Tanya Fox', 'Hellen Schmidt', 'Caroline Schultz', 'Mason Heaney', 'Claudie Smitham', 'Emil Schaefer'] +props: + trailingIcon: 'i-heroicons-chevron-up-down-20-solid' +excludedProps: + - trailingIcon +--- +:: + +Use the `selected-icon` prop to set a different icon or change it globally in `ui.inputMenu.default.selectedIcon`. Defaults to `i-heroicons-check-20-solid`. + +::component-card +--- +baseProps: + class: 'w-full lg:w-48' + placeholder: 'Select a person' + options: ['Wade Cooper', 'Arlene Mccoy', 'Devon Webb', 'Tom Cook', 'Tanya Fox', 'Hellen Schmidt', 'Caroline Schultz', 'Mason Heaney', 'Claudie Smitham', 'Emil Schaefer'] +props: + selectedIcon: 'i-heroicons-hand-thumb-up-solid' +excludedProps: + - selectedIcon +--- +:: + +::callout{icon="i-heroicons-light-bulb"} +Learn how to customize icons from the [Input](/forms/input#icon) component. +:: + +## Popper + +Use the `popper` prop to customize the popper instance. + +### Arrow + +:component-example{component="input-menu-example-popper-arrow"} + +### Placement + +:component-example{component="input-menu-example-popper-placement"} + +### Offset + +:component-example{component="input-menu-example-popper-offset"} + +## Slots + +### `option` + +Use the `#option` slot to customize the option content. You will have access to the `option`, `active` and `selected` properties in the slot scope. + +::component-example +--- +component: 'input-menu-example-option-slot' +componentProps: + class: 'w-full lg:w-48' +--- +:: + +### `option-empty` + +Use the `#option-empty` slot to customize the content displayed when the `searchable` prop is `true` and there is no options. You will have access to the `query` property in the slot scope. + +::component-example +--- +component: 'input-menu-example-option-empty-slot' +componentProps: + class: 'w-full lg:w-48' +--- +:: + +### `empty` + +Use the `#empty` slot to customize the content displayed when there is no options. Defaults to `No options.`. + +::component-example +--- +component: 'input-menu-example-empty-slot' +componentProps: + class: 'w-full lg:w-48' +--- +:: + +## Props + +:component-props + +## Config + +::callout{icon="i-heroicons-light-bulb"} +Use the `ui` prop to override the input config and the `uiMenu` prop to override the menu config. +:: + +::tabs{:selectedIndex="1"} + :component-preset{label="Input (ui)" slug="Input"} + :component-preset{label="InputMenu (uiMenu)"} +:: diff --git a/docs/content/3.forms/3.select.md b/docs/content/3.forms/3.select.md index 9acffba3..0627a637 100644 --- a/docs/content/3.forms/3.select.md +++ b/docs/content/3.forms/3.select.md @@ -203,7 +203,7 @@ Use the `#leading` slot to set the content of the leading icon. ::component-card --- slots: - leading: + leading: baseProps: options: - 'United States' @@ -213,7 +213,7 @@ baseProps: --- #leading - :u-avatar{src="https://avatars.githubusercontent.com/u/739984?v=4" size="3xs"} + :u-avatar{src="https://avatars.githubusercontent.com/u/739984?v=4" size="3xs" class="mx-0.5"} :: ### `trailing` diff --git a/src/runtime/components/forms/InputMenu.vue b/src/runtime/components/forms/InputMenu.vue new file mode 100644 index 00000000..73a8a88c --- /dev/null +++ b/src/runtime/components/forms/InputMenu.vue @@ -0,0 +1,411 @@ + + + diff --git a/src/runtime/ui.config/forms/input.ts b/src/runtime/ui.config/forms/input.ts index bcbf5abb..64b0aaf0 100644 --- a/src/runtime/ui.config/forms/input.ts +++ b/src/runtime/ui.config/forms/input.ts @@ -76,24 +76,24 @@ export default { wrapper: 'absolute inset-y-0 start-0 flex items-center', pointer: 'pointer-events-none', padding: { - '2xs': 'ps-2', - xs: 'ps-2.5', - sm: 'ps-2.5', - md: 'ps-3', - lg: 'ps-3.5', - xl: 'ps-3.5' + '2xs': 'px-2', + xs: 'px-2.5', + sm: 'px-2.5', + md: 'px-3', + lg: 'px-3.5', + xl: 'px-3.5' } }, trailing: { wrapper: 'absolute inset-y-0 end-0 flex items-center', pointer: 'pointer-events-none', padding: { - '2xs': 'pe-2', - xs: 'pe-2.5', - sm: 'pe-2.5', - md: 'pe-3', - lg: 'pe-3.5', - xl: 'pe-3.5' + '2xs': 'px-2', + xs: 'px-2.5', + sm: 'px-2.5', + md: 'px-3', + lg: 'px-3.5', + xl: 'px-3.5' } } }, diff --git a/src/runtime/ui.config/forms/inputMenu.ts b/src/runtime/ui.config/forms/inputMenu.ts new file mode 100644 index 00000000..49b80339 --- /dev/null +++ b/src/runtime/ui.config/forms/inputMenu.ts @@ -0,0 +1,63 @@ +import { arrow } from '../popper' + +export default { + container: 'z-20 group', + trigger: 'inline-flex w-full', + width: 'w-full', + height: 'max-h-60', + base: 'relative focus:outline-none overflow-y-auto scroll-py-1', + background: 'bg-white dark:bg-gray-800', + shadow: 'shadow-lg', + rounded: 'rounded-md', + padding: 'p-1', + ring: 'ring-1 ring-gray-200 dark:ring-gray-700', + empty: 'text-sm text-gray-400 dark:text-gray-500 px-2 py-1.5', + option: { + base: 'cursor-default select-none relative flex items-center justify-between gap-1', + rounded: 'rounded-md', + padding: 'px-2 py-1.5', + size: 'text-sm', + color: 'text-gray-900 dark:text-white', + container: 'flex items-center gap-2 min-w-0', + active: 'bg-gray-100 dark:bg-gray-900', + inactive: '', + selected: 'pe-7', + disabled: 'cursor-not-allowed opacity-50', + empty: 'text-sm text-gray-400 dark:text-gray-500 px-2 py-1.5', + icon: { + base: 'flex-shrink-0 h-4 w-4', + active: 'text-gray-900 dark:text-white', + inactive: 'text-gray-400 dark:text-gray-500' + }, + selectedIcon: { + wrapper: 'absolute inset-y-0 end-0 flex items-center', + padding: 'pe-2', + base: 'h-4 w-4 text-gray-900 dark:text-white flex-shrink-0' + }, + avatar: { + base: 'flex-shrink-0', + size: '3xs' as const + }, + chip: { + base: 'flex-shrink-0 w-2 h-2 mx-1 rounded-full' + } + }, + // Syntax for `` component https://vuejs.org/guide/built-ins/transition.html#css-based-transitions + transition: { + leaveActiveClass: 'transition ease-in duration-100', + leaveFromClass: 'opacity-100', + leaveToClass: 'opacity-0' + }, + popper: { + placement: 'bottom-end' + }, + default: { + selectedIcon: 'i-heroicons-check-20-solid', + trailingIcon: 'i-heroicons-chevron-down-20-solid' + }, + arrow: { + ...arrow, + ring: 'before:ring-1 before:ring-gray-200 dark:before:ring-gray-700', + background: 'before:bg-white dark:before:bg-gray-700' + } +} diff --git a/src/runtime/ui.config/forms/selectMenu.ts b/src/runtime/ui.config/forms/selectMenu.ts index 2f33e741..9751d009 100644 --- a/src/runtime/ui.config/forms/selectMenu.ts +++ b/src/runtime/ui.config/forms/selectMenu.ts @@ -1,51 +1,15 @@ import { arrow } from '../popper' +import inputMenu from './inputMenu' export default { - container: 'z-20 group', - trigger: 'inline-flex w-full', + ...inputMenu, select: 'inline-flex items-center text-left cursor-default', - width: 'w-full', - height: 'max-h-60', - base: 'relative focus:outline-none overflow-y-auto scroll-py-1', - background: 'bg-white dark:bg-gray-800', - shadow: 'shadow-lg', - rounded: 'rounded-md', - padding: 'p-1', - ring: 'ring-1 ring-gray-200 dark:ring-gray-700', input: 'block w-[calc(100%+0.5rem)] focus:ring-transparent text-sm px-3 py-1.5 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border-0 border-b border-gray-200 dark:border-gray-700 focus:border-inherit sticky -top-1 -mt-1 mb-1 -mx-1 z-10 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none', required: 'absolute inset-0 w-px opacity-0 cursor-default', label: 'block truncate', - empty: 'text-sm text-gray-400 dark:text-gray-500 px-2 py-1.5', option: { - base: 'cursor-default select-none relative flex items-center justify-between gap-1', - rounded: 'rounded-md', - padding: 'px-2 py-1.5', - size: 'text-sm', - color: 'text-gray-900 dark:text-white', - container: 'flex items-center gap-2 min-w-0', - active: 'bg-gray-100 dark:bg-gray-900', - inactive: '', - selected: 'pe-7', - disabled: 'cursor-not-allowed opacity-50', - empty: 'text-sm text-gray-400 dark:text-gray-500 px-2 py-1.5', - create: 'block truncate', - icon: { - base: 'flex-shrink-0 h-4 w-4', - active: 'text-gray-900 dark:text-white', - inactive: 'text-gray-400 dark:text-gray-500' - }, - selectedIcon: { - wrapper: 'absolute inset-y-0 end-0 flex items-center', - padding: 'pe-2', - base: 'h-4 w-4 text-gray-900 dark:text-white flex-shrink-0' - }, - avatar: { - base: 'flex-shrink-0', - size: '3xs' as const - }, - chip: { - base: 'flex-shrink-0 w-2 h-2 mx-1 rounded-full' - } + ...inputMenu.option, + create: 'block truncate' }, // Syntax for `` component https://vuejs.org/guide/built-ins/transition.html#css-based-transitions transition: { diff --git a/src/runtime/ui.config/index.ts b/src/runtime/ui.config/index.ts index a6a4f5e8..cf4203be 100644 --- a/src/runtime/ui.config/index.ts +++ b/src/runtime/ui.config/index.ts @@ -18,6 +18,7 @@ export { default as meterGroup } from './elements/meterGroup' // Forms export { default as input } from './forms/input' +export { default as inputMenu } from './forms/inputMenu' export { default as formGroup } from './forms/formGroup' export { default as textarea } from './forms/textarea' export { default as select } from './forms/select' diff --git a/src/runtime/ui.config/popper.ts b/src/runtime/ui.config/popper.ts index 1fd4a713..4472555f 100644 --- a/src/runtime/ui.config/popper.ts +++ b/src/runtime/ui.config/popper.ts @@ -4,5 +4,6 @@ export const arrow = { rounded: 'before:rounded-sm', background: 'before:bg-gray-200 dark:before:bg-gray-800', shadow: 'before:shadow', - placement: 'group-data-[popper-placement*="right"]:-left-1 group-data-[popper-placement*="left"]:-right-1 group-data-[popper-placement*="top"]:-bottom-1 group-data-[popper-placement*="bottom"]:-top-1' + // eslint-disable-next-line quotes + placement: `group-data-[popper-placement*='right']:-left-1 group-data-[popper-placement*='left']:-right-1 group-data-[popper-placement*='top']:-bottom-1 group-data-[popper-placement*='bottom']:-top-1` }