From f4be95dcf5a07c964ae9f2555070d437e0388c13 Mon Sep 17 00:00:00 2001 From: Conner Blanton Date: Mon, 20 Nov 2023 12:38:05 -0600 Subject: [PATCH] fix(ButtonGroup): handle components with children (#999) Co-authored-by: Benjamin Canac --- src/runtime/components/elements/Badge.vue | 7 +- src/runtime/components/elements/Button.vue | 15 ++-- .../components/elements/ButtonGroup.ts | 60 ++++++--------- src/runtime/components/forms/Input.vue | 9 ++- src/runtime/components/forms/Select.vue | 9 ++- src/runtime/components/forms/SelectMenu.vue | 9 ++- src/runtime/composables/useButtonGroup.ts | 74 +++++++++++++++++++ 7 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 src/runtime/composables/useButtonGroup.ts diff --git a/src/runtime/components/elements/Badge.vue b/src/runtime/components/elements/Badge.vue index eb7f8cd78d..d90640e082 100644 --- a/src/runtime/components/elements/Badge.vue +++ b/src/runtime/components/elements/Badge.vue @@ -10,6 +10,7 @@ import type { PropType } from 'vue' import { twMerge, twJoin } from 'tailwind-merge' import { useUI } from '../../composables/useUI' import { mergeConfig } from '../../utils' +import { useInjectButtonGroup } from '../../composables/useButtonGroup' import type { BadgeColor, BadgeSize, BadgeVariant, Strategy } from '../../types' // @ts-expect-error import appConfig from '#build/app.config' @@ -60,14 +61,16 @@ export default defineComponent({ setup (props) { const { ui, attrs } = useUI('badge', toRef(props, 'ui'), config) + const { size, rounded } = useInjectButtonGroup({ ui, props }) + const badgeClass = computed(() => { const variant = ui.value.color?.[props.color as string]?.[props.variant as string] || ui.value.variant[props.variant] return twMerge(twJoin( ui.value.base, ui.value.font, - ui.value.rounded, - ui.value.size[props.size], + rounded.value, + ui.value.size[size.value], variant?.replaceAll('{color}', props.color) ), props.class) }) diff --git a/src/runtime/components/elements/Button.vue b/src/runtime/components/elements/Button.vue index ff6bc0d6e8..ff0083012c 100644 --- a/src/runtime/components/elements/Button.vue +++ b/src/runtime/components/elements/Button.vue @@ -24,6 +24,7 @@ import UIcon from '../elements/Icon.vue' import ULink from '../elements/Link.vue' import { useUI } from '../../composables/useUI' import { mergeConfig } from '../../utils' +import { useInjectButtonGroup } from '../../composables/useButtonGroup' import type { ButtonColor, ButtonSize, ButtonVariant, Strategy } from '../../types' // @ts-expect-error import appConfig from '#build/app.config' @@ -130,6 +131,8 @@ export default defineComponent({ setup (props, { slots }) { const { ui, attrs } = useUI('button', toRef(props, 'ui'), config) + const { size, rounded } = useInjectButtonGroup({ ui, props }) + const isLeading = computed(() => { return (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing) || props.leadingIcon }) @@ -146,10 +149,10 @@ export default defineComponent({ return twMerge(twJoin( ui.value.base, ui.value.font, - ui.value.rounded, - ui.value.size[props.size], - ui.value.gap[props.size], - props.padded && ui.value[isSquare.value ? 'square' : 'padding'][props.size], + rounded.value, + ui.value.size[size.value], + ui.value.gap[size.value], + props.padded && ui.value[isSquare.value ? 'square' : 'padding'][size.value], variant?.replaceAll('{color}', props.color), props.block ? 'w-full flex justify-center items-center' : 'inline-flex items-center' ), props.class) @@ -174,7 +177,7 @@ export default defineComponent({ const leadingIconClass = computed(() => { return twJoin( ui.value.icon.base, - ui.value.icon.size[props.size], + ui.value.icon.size[size.value], props.loading && 'animate-spin' ) }) @@ -182,7 +185,7 @@ export default defineComponent({ const trailingIconClass = computed(() => { return twJoin( ui.value.icon.base, - ui.value.icon.size[props.size], + ui.value.icon.size[size.value], props.loading && !isLeading.value && 'animate-spin' ) }) diff --git a/src/runtime/components/elements/ButtonGroup.ts b/src/runtime/components/elements/ButtonGroup.ts index b715507f08..f5ed723cb5 100644 --- a/src/runtime/components/elements/ButtonGroup.ts +++ b/src/runtime/components/elements/ButtonGroup.ts @@ -1,8 +1,9 @@ -import { h, cloneVNode, computed, toRef, defineComponent } from 'vue' +import { h, computed, toRef, defineComponent } from 'vue' import type { PropType } from 'vue' import { twMerge, twJoin } from 'tailwind-merge' import { useUI } from '../../composables/useUI' import { mergeConfig, getSlotsChildren } from '../../utils' +import { useProvideButtonGroup } from '../../composables/useButtonGroup' import type { ButtonSize, Strategy } from '../../types' // @ts-expect-error import appConfig from '#build/app.config' @@ -12,6 +13,7 @@ const buttonConfig = mergeConfig(appConfig.ui.strategy, appConfig const buttonGroupConfig = mergeConfig(appConfig.ui.strategy, appConfig.ui.buttonGroup, buttonGroup) export default defineComponent({ + name: 'ButtonGroup', inheritAttrs: false, props: { size: { @@ -42,43 +44,6 @@ export default defineComponent({ const children = computed(() => getSlotsChildren(slots)) - const rounded = computed(() => { - const roundedMap = { - 'rounded-none': { horizontal: { left: 'rounded-s-none', right: 'rounded-e-none' }, vertical: { top: 'rounded-t-none', bottom: 'rounded-b-none' } }, - 'rounded-sm': { horizontal: { left: 'rounded-s-sm', right: 'rounded-e-sm' }, vertical: { top: 'rounded-t-sm', bottom: 'rounded-b-sm' } }, - rounded: { horizontal: { left: 'rounded-s', right: 'rounded-e' }, vertical: { top: 'rounded-t', bottom: 'rounded-b' } }, - 'rounded-md': { horizontal: { left: 'rounded-s-md', right: 'rounded-e-md' }, vertical: { top: 'rounded-t-md', bottom: 'rounded-b-md' } }, - 'rounded-lg': { horizontal: { left: 'rounded-s-lg', right: 'rounded-e-lg' }, vertical: { top: 'rounded-t-lg', bottom: 'rounded-b-lg' } }, - 'rounded-xl': { horizontal: { left: 'rounded-s-xl', right: 'rounded-e-xl' }, vertical: { top: 'rounded-t-xl', bottom: 'rounded-b-xl' } }, - 'rounded-2xl': { horizontal: { left: 'rounded-s-2xl', right: 'rounded-e-2xl' }, vertical: { top: 'rounded-t-2xl', bottom: 'rounded-b-2xl' } }, - 'rounded-3xl': { horizontal: { left: 'rounded-s-3xl', right: 'rounded-e-3xl' }, vertical: { top: 'rounded-t-3xl', bottom: 'rounded-b-3xl' } }, - 'rounded-full': { horizontal: { left: 'rounded-s-full', right: 'rounded-e-full' }, vertical: { top: 'rounded-t-full', bottom: 'rounded-b-full' } } - } - return roundedMap[ui.value.rounded][props.orientation] - }) - - const clones = computed(() => children.value.map((node, index) => { - const vProps: any = {} - - if (props.size) { - vProps.size = props.size - } - - vProps.ui = node.props?.ui || {} - vProps.ui.rounded = 'rounded-none' - vProps.ui.base = '!shadow-none' - - if (index === 0) { - vProps.ui.rounded += ` ${rounded.value.left || rounded.value.top}` - } - - if (index === children.value.length - 1) { - vProps.ui.rounded += ` ${rounded.value.right || rounded.value.bottom}` - } - - return cloneVNode(node, vProps) - })) - const wrapperClass = computed(() => { return twMerge(twJoin( ui.value.wrapper[props.orientation], @@ -87,6 +52,23 @@ export default defineComponent({ ), props.class) }) - return () => h('div', { class: wrapperClass.value, ...attrs.value }, clones.value) + const rounded = computed(() => { + const roundedMap = { + 'rounded-none': { horizontal: { start: 'rounded-s-none', end: 'rounded-e-none' }, vertical: { start: 'rounded-t-none', end: 'rounded-b-none' } }, + 'rounded-sm': { horizontal: { start: 'rounded-s-sm', end: 'rounded-e-sm' }, vertical: { start: 'rounded-t-sm', end: 'rounded-b-sm' } }, + rounded: { horizontal: { start: 'rounded-s', end: 'rounded-e' }, vertical: { start: 'rounded-t', end: 'rounded-b' } }, + 'rounded-md': { horizontal: { start: 'rounded-s-md', end: 'rounded-e-md' }, vertical: { start: 'rounded-t-md', end: 'rounded-b-md' } }, + 'rounded-lg': { horizontal: { start: 'rounded-s-lg', end: 'rounded-e-lg' }, vertical: { start: 'rounded-t-lg', end: 'rounded-b-lg' } }, + 'rounded-xl': { horizontal: { start: 'rounded-s-xl', end: 'rounded-e-xl' }, vertical: { start: 'rounded-t-xl', end: 'rounded-b-xl' } }, + 'rounded-2xl': { horizontal: { start: 'rounded-s-2xl', end: 'rounded-e-2xl' }, vertical: { start: 'rounded-t-2xl', end: 'rounded-b-2xl' } }, + 'rounded-3xl': { horizontal: { start: 'rounded-s-3xl', end: 'rounded-e-3xl' }, vertical: { start: 'rounded-t-3xl', end: 'rounded-b-3xl' } }, + 'rounded-full': { horizontal: { start: 'rounded-s-full', end: 'rounded-e-full' }, vertical: { start: 'rounded-t-full', end: 'rounded-b-full' } } + } + return roundedMap[ui.value.rounded][props.orientation] + }) + + useProvideButtonGroup({ orientation: toRef(props, 'orientation'), size: toRef(props, 'size'), ui, rounded }) + + return () => h('div', { class: wrapperClass.value, ...attrs.value }, children.value) } }) diff --git a/src/runtime/components/forms/Input.vue b/src/runtime/components/forms/Input.vue index 2da9bcb19d..840843ebcd 100644 --- a/src/runtime/components/forms/Input.vue +++ b/src/runtime/components/forms/Input.vue @@ -41,6 +41,7 @@ import { defu } from 'defu' import { useUI } from '../../composables/useUI' import { useFormGroup } from '../../composables/useFormGroup' import { mergeConfig, looseToNumber } from '../../utils' +import { useInjectButtonGroup } from '../../composables/useButtonGroup' import type { InputSize, InputColor, InputVariant, Strategy } from '../../types' // @ts-expect-error import appConfig from '#build/app.config' @@ -167,7 +168,11 @@ export default defineComponent({ setup (props, { emit, slots }) { const { ui, attrs } = useUI('input', toRef(props, 'ui'), config, toRef(props, 'class')) - const { emitFormBlur, emitFormInput, size, color, inputId, name } = useFormGroup(props, config) + const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props }) + + const { emitFormBlur, emitFormInput, size: sizeFormGroup, color, inputId, name } = useFormGroup(props, config) + + const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value) const modelModifiers = ref(defu({}, props.modelModifiers, { trim: false, lazy: false, number: false })) @@ -229,7 +234,7 @@ export default defineComponent({ return twMerge(twJoin( ui.value.base, - ui.value.rounded, + rounded.value, ui.value.placeholder, ui.value.size[size.value], props.padded ? ui.value.padding[size.value] : 'p-0', diff --git a/src/runtime/components/forms/Select.vue b/src/runtime/components/forms/Select.vue index 89d84d18f5..9dbe4f1922 100644 --- a/src/runtime/components/forms/Select.vue +++ b/src/runtime/components/forms/Select.vue @@ -61,6 +61,7 @@ import UIcon from '../elements/Icon.vue' import { useUI } from '../../composables/useUI' import { useFormGroup } from '../../composables/useFormGroup' import { mergeConfig, get } from '../../utils' +import { useInjectButtonGroup } from '../../composables/useButtonGroup' import type { SelectSize, SelectColor, SelectVariant, Strategy } from '../../types' // @ts-expect-error import appConfig from '#build/app.config' @@ -183,7 +184,11 @@ export default defineComponent({ setup (props, { emit, slots }) { const { ui, attrs } = useUI('select', toRef(props, 'ui'), config, toRef(props, 'class')) - const { emitFormChange, inputId, color, size, name } = useFormGroup(props, config) + const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props }) + + const { emitFormChange, inputId, color, size: sizeFormGroup, name } = useFormGroup(props, config) + + const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value) const onInput = (event: InputEvent) => { emit('update:modelValue', (event.target as HTMLInputElement).value) @@ -251,7 +256,7 @@ export default defineComponent({ return twMerge(twJoin( ui.value.base, - ui.value.rounded, + rounded.value, ui.value.size[size.value], props.padded ? ui.value.padding[size.value] : 'p-0', variant?.replaceAll('{color}', color.value), diff --git a/src/runtime/components/forms/SelectMenu.vue b/src/runtime/components/forms/SelectMenu.vue index 21fbfa5b6b..f927fa2eee 100644 --- a/src/runtime/components/forms/SelectMenu.vue +++ b/src/runtime/components/forms/SelectMenu.vue @@ -141,6 +141,7 @@ import { useUI } from '../../composables/useUI' import { usePopper } from '../../composables/usePopper' import { useFormGroup } from '../../composables/useFormGroup' import { mergeConfig } from '../../utils' +import { useInjectButtonGroup } from '../../composables/useButtonGroup' import type { SelectSize, SelectColor, SelectVariant, PopperOptions, Strategy } from '../../types' // @ts-expect-error import appConfig from '#build/app.config' @@ -320,7 +321,11 @@ export default defineComponent({ const popper = computed(() => defu({}, props.popper, uiMenu.value.popper as PopperOptions)) const [trigger, container] = usePopper(popper.value) - const { emitFormBlur, emitFormChange, inputId, color, size, name } = useFormGroup(props, config) + + 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 searchInput = ref>() @@ -330,7 +335,7 @@ export default defineComponent({ return twMerge(twJoin( ui.value.base, - ui.value.rounded, + rounded.value, 'text-left cursor-default', ui.value.size[size.value], ui.value.gap[size.value], diff --git a/src/runtime/composables/useButtonGroup.ts b/src/runtime/composables/useButtonGroup.ts new file mode 100644 index 0000000000..b567d22530 --- /dev/null +++ b/src/runtime/composables/useButtonGroup.ts @@ -0,0 +1,74 @@ +import { computed, ref, provide, inject, onMounted, onUnmounted, getCurrentInstance } from 'vue' +import type { Ref, ComponentInternalInstance } from 'vue' +import { buttonGroup } from '#ui/ui.config' + +type ButtonGroupProps = { + orientation?: Ref<'horizontal' | 'vertical'> + size?: Ref + ui?: Ref> + rounded?: Ref<{ start: string, end: string }> +} + +// make a ButtonGroupContext type for injection. Should include ButtonGroupProps +type ButtonGroupContext = { + children: ComponentInternalInstance[] + register(child: ComponentInternalInstance): void + unregister(child: ComponentInternalInstance): void + orientation: 'horizontal' | 'vertical' + size: string + ui: Partial + rounded: { start: string, end: string } +} + +export function useProvideButtonGroup (buttonGroupProps: ButtonGroupProps) { + const instance = getCurrentInstance() + const groupKey = `group-${instance.uid}` + const state = ref({ + children: [], + register (child) { + this.children.push(child) + }, + unregister (child) { + const index = this.children.indexOf(child) + if (index > -1) { + this.children.splice(index, 1) + } + }, + ...buttonGroupProps + }) + provide(groupKey, state as Ref) +} + +export function useInjectButtonGroup ({ ui, props }: { ui: any, props: any }) { + const instance = getCurrentInstance() + + let parent = instance.parent + let groupContext: Ref | undefined + + // Traverse up the parent chain to find the nearest ButtonGroup + while (parent && !groupContext) { + if (parent.type.name === 'ButtonGroup') { + groupContext = inject(`group-${parent.uid}`) + break + } + parent = parent.parent + } + + const positionInGroup = computed(() => groupContext?.value.children.indexOf(instance)) + onMounted(() => { + groupContext?.value.register(instance) + }) + onUnmounted(() => { + groupContext?.value.unregister(instance) + }) + return { + size: computed(() => groupContext?.value.size || props.size), + rounded: computed(() => { + if (!groupContext || positionInGroup.value === -1) return ui.value.rounded + if (groupContext.value.children.length === 1) return groupContext.value.ui.rounded + if (positionInGroup.value === 0) return groupContext.value.rounded.start + if (positionInGroup.value === groupContext.value.children.length - 1) return groupContext.value.rounded.end + return 'rounded-none' + }) + } +}