feat(InputMenu): new component (#1095)

This commit is contained in:
Benjamin Canac
2023-12-12 18:45:04 +01:00
committed by GitHub
parent 66a80c7486
commit 6d8d82a265
20 changed files with 852 additions and 57 deletions

View File

@@ -0,0 +1,411 @@
<template>
<HCombobox
v-slot="{ open }"
:by="by"
:name="name"
:model-value="modelValue"
:disabled="disabled || loading"
as="div"
:class="ui.wrapper"
@update:model-value="onUpdate"
>
<div :class="uiMenu.trigger">
<HComboboxInput
:id="inputId"
ref="input"
:name="name"
:required="required"
:placeholder="placeholder"
:disabled="disabled || loading"
:class="inputClass"
autocomplete="off"
v-bind="attrs"
:display-value="() => ['string', 'number'].includes(typeof modelValue) ? modelValue : modelValue[optionAttribute]"
@change="query = $event.target.value"
/>
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="leadingWrapperIconClass">
<slot name="leading" :disabled="disabled" :loading="loading">
<UIcon :name="leadingIconName" :class="leadingIconClass" />
</slot>
</span>
<HComboboxButton v-if="(isTrailing && trailingIconName) || $slots.trailing" ref="trigger" :class="trailingWrapperIconClass">
<slot name="trailing" :disabled="disabled" :loading="loading">
<UIcon :name="trailingIconName" :class="trailingIconClass" />
</slot>
</HComboboxButton>
</div>
<div v-if="open" ref="container" :class="[uiMenu.container, uiMenu.width]">
<Transition appear v-bind="uiMenu.transition">
<div>
<div v-if="popper.arrow" data-popper-arrow :class="Object.values(uiMenu.arrow)" />
<HComboboxOptions static :class="[uiMenu.base, uiMenu.ring, uiMenu.rounded, uiMenu.shadow, uiMenu.background, uiMenu.padding, uiMenu.height]">
<HComboboxOption
v-for="(option, index) in filteredOptions"
v-slot="{ active, selected, disabled: optionDisabled }"
:key="index"
as="template"
:value="valueAttribute ? option[valueAttribute] : option"
:disabled="option.disabled"
>
<li :class="[uiMenu.option.base, uiMenu.option.rounded, uiMenu.option.padding, uiMenu.option.size, uiMenu.option.color, active ? uiMenu.option.active : uiMenu.option.inactive, selected && uiMenu.option.selected, optionDisabled && uiMenu.option.disabled]">
<div :class="uiMenu.option.container">
<slot name="option" :option="option" :active="active" :selected="selected">
<UIcon v-if="option.icon" :name="option.icon" :class="[uiMenu.option.icon.base, active ? uiMenu.option.icon.active : uiMenu.option.icon.inactive, option.iconClass]" aria-hidden="true" />
<UAvatar
v-else-if="option.avatar"
v-bind="{ size: uiMenu.option.avatar.size, ...option.avatar }"
:class="uiMenu.option.avatar.base"
aria-hidden="true"
/>
<span v-else-if="option.chip" :class="uiMenu.option.chip.base" :style="{ background: `#${option.chip}` }" />
<span class="truncate">{{ ['string', 'number'].includes(typeof option) ? option : option[optionAttribute] }}</span>
</slot>
</div>
<span v-if="selected" :class="[uiMenu.option.selectedIcon.wrapper, uiMenu.option.selectedIcon.padding]">
<UIcon :name="selectedIcon" :class="uiMenu.option.selectedIcon.base" aria-hidden="true" />
</span>
</li>
</HComboboxOption>
<p v-if="query && !filteredOptions.length" :class="uiMenu.option.empty">
<slot name="option-empty" :query="query">
No results for "{{ query }}".
</slot>
</p>
<p v-else-if="!filteredOptions.length" :class="uiMenu.empty">
<slot name="empty" :query="query">
No options.
</slot>
</p>
</HComboboxOptions>
</div>
</Transition>
</div>
</HCombobox>
</template>
<script lang="ts">
import { ref, computed, toRef, watch, defineComponent } from 'vue'
import type { PropType } from 'vue'
import {
Combobox as HCombobox,
ComboboxButton as HComboboxButton,
ComboboxOptions as HComboboxOptions,
ComboboxOption as HComboboxOption,
ComboboxInput as HComboboxInput
} from '@headlessui/vue'
import { computedAsync } from '@vueuse/core'
import { defu } from 'defu'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import { useUI } from '../../composables/useUI'
import { usePopper } from '../../composables/usePopper'
import { useFormGroup } from '../../composables/useFormGroup'
import { get, mergeConfig } from '../../utils'
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
import type { InputSize, InputColor, InputVariant, PopperOptions, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { input, inputMenu } from '#ui/ui.config'
const config = mergeConfig<typeof input>(appConfig.ui.strategy, appConfig.ui.input, input)
const configMenu = mergeConfig<typeof inputMenu>(appConfig.ui.strategy, appConfig.ui.inputMenu, inputMenu)
export default defineComponent({
components: {
HCombobox,
HComboboxButton,
HComboboxOptions,
HComboboxOption,
HComboboxInput,
UIcon,
UAvatar
},
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number, Object, Array],
default: ''
},
by: {
type: String,
default: undefined
},
options: {
type: Array as PropType<{ [key: string]: any, disabled?: boolean }[] | string[]>,
default: () => []
},
id: {
type: String,
default: null
},
name: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => config.default.loadingIcon
},
leadingIcon: {
type: String,
default: null
},
trailingIcon: {
type: String,
default: () => configMenu.default.trailingIcon
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
selectedIcon: {
type: String,
default: () => configMenu.default.selectedIcon
},
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: null
},
padded: {
type: Boolean,
default: true
},
size: {
type: String as PropType<InputSize>,
default: null,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<InputColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<InputVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
optionAttribute: {
type: String,
default: 'label'
},
valueAttribute: {
type: String,
default: null
},
searchAttributes: {
type: Array,
default: null
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
inputClass: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
},
uiMenu: {
type: Object as PropType<Partial<typeof configMenu> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'open', 'close', 'change'],
setup (props, { emit, slots }) {
const { ui, attrs } = useUI('input', toRef(props, 'ui'), config, toRef(props, 'class'))
const { ui: uiMenu } = useUI('inputMenu', toRef(props, 'uiMenu'), configMenu)
const popper = computed<PopperOptions>(() => defu({}, props.popper, uiMenu.value.popper as PopperOptions))
const [trigger, container] = usePopper(popper.value)
const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
const { emitFormBlur, emitFormChange, inputId, color, size: sizeFormGroup, name } = useFormGroup(props, config)
const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
const query = ref('')
const inputClass = computed(() => {
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return twMerge(twJoin(
ui.value.base,
ui.value.form,
rounded.value,
ui.value.placeholder,
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value]
), props.inputClass)
})
const isLeading = computed(() => {
return (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing) || props.leadingIcon
})
const isTrailing = computed(() => {
return (props.icon && props.trailing) || (props.loading && props.trailing) || props.trailingIcon
})
const leadingIconName = computed(() => {
if (props.loading) {
return props.loadingIcon
}
return props.leadingIcon || props.icon
})
const trailingIconName = computed(() => {
if (props.loading && !isLeading.value) {
return props.loadingIcon
}
return props.trailingIcon || props.icon
})
const leadingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.pointer,
ui.value.icon.leading.padding[size.value]
)
})
const leadingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && ui.value.icon.loading
)
})
const trailingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.padding[size.value]
)
})
const trailingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && !isLeading.value && ui.value.icon.loading
)
})
const filteredOptions = computedAsync(async () => {
if (query.value === '') {
return props.options
}
return (props.options as any[]).filter((option: any) => {
return (props.searchAttributes?.length ? props.searchAttributes : [props.optionAttribute]).some((searchAttribute: any) => {
if (['string', 'number'].includes(typeof option)) {
return String(option).search(new RegExp(query.value, 'i')) !== -1
}
const child = get(option, searchAttribute)
return child !== null && child !== undefined && String(child).search(new RegExp(query.value, 'i')) !== -1
})
})
})
watch(container, (value) => {
if (value) {
emit('open')
} else {
emit('close')
emitFormBlur()
}
})
function onUpdate (event: any) {
emit('update:modelValue', event)
emit('change', event)
emitFormChange()
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
// eslint-disable-next-line vue/no-dupe-keys
uiMenu,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
name,
inputId,
// eslint-disable-next-line vue/no-dupe-keys
popper,
trigger,
container,
isLeading,
isTrailing,
// eslint-disable-next-line vue/no-dupe-keys
inputClass,
leadingIconName,
leadingIconClass,
leadingWrapperIconClass,
trailingIconName,
trailingIconClass,
trailingWrapperIconClass,
filteredOptions,
query,
onUpdate
}
}
})
</script>

View File

@@ -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'
}
}
},

View File

@@ -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 `<Transition>` 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'
}
}

View File

@@ -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 `<Transition>` component https://vuejs.org/guide/built-ins/transition.html#css-based-transitions
transition: {

View File

@@ -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'

View File

@@ -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`
}