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
19 changes: 16 additions & 3 deletions playgrounds/nuxt/app/pages/components/field-group.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,22 @@ const orientation = ref('horizontal' as keyof typeof theme.variants.orientation)
<UButton color="neutral" variant="outline">
Button
</UButton>
<UButton color="neutral" variant="subtle">
Button
</UButton>
<UModal>
<UButton color="neutral" variant="subtle">
Open
</UButton>

<template #footer="{ close }">
<UFieldGroup clear>
Copy link
Member

@romhml romhml Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't look intuitive since this FieldGroup's purpose is to cancel the previous one and not be one. We should do that automatically on all components with nested content that could be used in a FieldGroup.

We could make a new component to wrap the Modal, Slideover, Popover contents which voids the Button Group injection internally:

<NestedContent> <!-- provide(fieldGroupInjectionKey, null) -->
  <slot name="content"> ... </slot>
</NestedContent>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed it might be better that manually having to wrap buttons every time. Could we just call useFieldGroup() in those components? πŸ€”

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need a separate component, the trick is that the trigger should inherit from the FieldGroup injection, while the content shouldn't

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you can't just override the injection in the modal component for example

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I mentioned a potential solution to this #4242 (comment)

<UButton @click="close">
Submit
</UButton>
<UButton color="neutral" variant="outline" @click="close">
Close
</UButton>
</UFieldGroup>
</template>
</UModal>
<UButton color="neutral" variant="outline">
Button
</UButton>
Expand Down
26 changes: 21 additions & 5 deletions src/runtime/components/FieldGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export interface FieldGroupProps {
* @defaultValue 'horizontal'
*/
orientation?: FieldGroup['variants']['orientation']
/**
* When true, clears the field group context so nested components don't inherit styling.
* @defaultValue false
*/
clear?: boolean
class?: any
ui?: FieldGroup['slots']
}
Expand All @@ -46,14 +51,25 @@ const appConfig = useAppConfig() as FieldGroup['AppConfig']
// eslint-disable-next-line vue/no-dupe-keys
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fieldGroup || {}) }))

provide(fieldGroupInjectionKey, computed(() => ({
orientation: props.orientation,
size: props.size
})))
provide(fieldGroupInjectionKey, computed(() => {
if (props.clear) {
return {
orientation: undefined,
size: undefined
}
}
return {
orientation: props.orientation,
size: props.size
}
}))
</script>

<template>
<Primitive :as="as" :data-orientation="orientation" :class="ui({ orientation, class: props.class })">
<template v-if="clear">
<slot />
</template>
<Primitive v-else :as="as" :data-orientation="orientation" :class="ui({ orientation, class: props.class })">
<slot />
</Primitive>
</template>
4 changes: 2 additions & 2 deletions src/runtime/components/Input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ const modelValue = useVModel<InputProps<T>, 'modelValue', 'update:modelValue'>(p

const appConfig = useAppConfig() as Input['AppConfig']

const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<InputProps<T>>(props, { deferInputValidation: true })
const { emitFormBlur, emitFormInput, emitFormChange, size: formFieldSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<InputProps<T>>(props, { deferInputValidation: true })
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps<T>>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

const inputSize = computed(() => fieldGroupSize.value || formGroupSize.value)
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.input || {}) })({
type: props.type as Input['variants']['type'],
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/InputDate.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const slots = defineSlots<InputDateSlots>()
const appConfig = useAppConfig() as InputDate['AppConfig']

const rootProps = useForwardPropsEmits(reactiveOmit(props, 'id', 'name', 'range', 'modelValue', 'defaultValue', 'color', 'variant', 'size', 'highlight', 'disabled', 'autofocus', 'autofocusDelay', 'icon', 'avatar', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'separatorIcon', 'class', 'ui'), emits)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputDateProps<R>>(props)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputDateProps<R>>(props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputDateProps<R>>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

Expand All @@ -100,7 +100,7 @@ const [DefineSegmentsTemplate, ReuseSegmentsTemplate] = createReusableTemplate<{
type?: 'start' | 'end'
}>()

const inputSize = computed(() => fieldGroupSize.value || formGroupSize.value)
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputDate || {}) })({
color: color.value,
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/InputMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,11 @@ const virtualizerProps = toRef(() => {
})
})

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))

const inputSize = computed(() => fieldGroupSize.value || formGroupSize.value)
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)

const [DefineCreateItemTemplate, ReuseCreateItemTemplate] = createReusableTemplate()
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: InputMenuItem, index: number }>({
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/InputNumber.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ const appConfig = useAppConfig() as InputNumber['AppConfig']

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultValue', 'min', 'max', 'step', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange', 'readonly'), emits)

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formGroupSize, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps<T>>(props)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formFieldSize, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps<T>>(props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputNumberProps<T>>(props)

const inputSize = computed(() => fieldGroupSize.value || formGroupSize.value)
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputNumber || {}) })({
color: color.value,
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/InputTags.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ const appConfig = useAppConfig() as InputTags['AppConfig']

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'addOnPaste', 'addOnTab', 'addOnBlur', 'duplicate', 'delimiter', 'max', 'convertValue', 'displayValue', 'required'), emits)

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputTagsProps>(props)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputTagsProps>(props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputTagsProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

const inputSize = computed(() => fieldGroupSize.value || formGroupSize.value)
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputTags || {}) })({
color: color.value,
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/InputTime.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ const appConfig = useAppConfig() as InputTime['AppConfig']

const rootProps = useForwardPropsEmits(reactiveOmit(props, 'id', 'name', 'color', 'variant', 'size', 'highlight', 'disabled', 'autofocus', 'autofocusDelay', 'icon', 'avatar', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'class', 'ui'), emits)

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formGroupSize, name, highlight, disabled, ariaAttrs } = useFormField<InputTimeProps>(props)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formFieldSize, name, highlight, disabled, ariaAttrs } = useFormField<InputTimeProps>(props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputTimeProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)

const inputSize = computed(() => fieldGroupSize.value || formGroupSize.value)
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputTime || {}) })({
color: color.value,
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,11 @@ const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as SelectContentProps)
const arrowProps = toRef(() => props.arrow as SelectArrowProps)

const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))

const selectSize = computed(() => fieldGroupSize.value || formGroupSize.value)
const selectSize = computed(() => fieldGroupSize.value || formFieldSize.value)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.select || {}) })({
color: color.value,
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,11 @@ const virtualizerProps = toRef(() => {
})
const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: t('selectMenu.search'), variant: 'none' }) as InputProps)

const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))

const selectSize = computed(() => fieldGroupSize.value || formGroupSize.value)
const selectSize = computed(() => fieldGroupSize.value || formFieldSize.value)

const [DefineCreateItemTemplate, ReuseCreateItemTemplate] = createReusableTemplate()
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: SelectMenuItem, index: number }>({
Expand Down
1 change: 1 addition & 0 deletions test/components/FieldGroup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('FieldGroup', () => {
// Props
['with as', { props: { as: 'section' } }],
['with class', { props: { class: 'absolute' } }],
['with clear', { props: { clear: true } }],
// Slots
['with default slot', {
slots: {
Expand Down
2 changes: 2 additions & 0 deletions test/components/__snapshots__/FieldGroup-vue.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ exports[`FieldGroup > renders with as correctly 1`] = `"<section data-orientatio

exports[`FieldGroup > renders with class correctly 1`] = `"<div data-orientation="horizontal" class="inline-flex -space-x-px absolute"></div>"`;

exports[`FieldGroup > renders with clear correctly 1`] = `""`;

exports[`FieldGroup > renders with default slot correctly 1`] = `
"<div data-orientation="horizontal" class="relative inline-flex -space-x-px">
<div data-slot="root" class="relative inline-flex items-center group has-focus-visible:z-[1]"><input type="text" data-slot="base" class="w-full rounded-md border-0 appearance-none placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors group-not-only:group-first:rounded-e-none group-not-only:group-last:rounded-s-none group-not-last:group-not-first:rounded-none px-2.5 py-1.5 text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" autocomplete="off">
Expand Down
2 changes: 2 additions & 0 deletions test/components/__snapshots__/FieldGroup.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ exports[`FieldGroup > renders with as correctly 1`] = `"<section data-orientatio

exports[`FieldGroup > renders with class correctly 1`] = `"<div data-orientation="horizontal" class="inline-flex -space-x-px absolute"></div>"`;

exports[`FieldGroup > renders with clear correctly 1`] = `""`;

exports[`FieldGroup > renders with default slot correctly 1`] = `
"<div data-orientation="horizontal" class="relative inline-flex -space-x-px">
<div data-slot="root" class="relative inline-flex items-center group has-focus-visible:z-[1]"><input type="text" data-slot="base" class="w-full rounded-md border-0 appearance-none placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors group-not-only:group-first:rounded-e-none group-not-only:group-last:rounded-s-none group-not-last:group-not-first:rounded-none px-2.5 py-1.5 text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" autocomplete="off">
Expand Down
Loading