From 70333a9ccd758cc340619737b1643d84e7c70739 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 3 Jun 2022 09:58:11 -0400 Subject: [PATCH] Support `` children when using `as="template"` (#1548) * Extract renderTemplate logic * Flatten Fragments when rendering * Update changelog --- packages/@headlessui-vue/CHANGELOG.md | 4 ++ .../src/components/combobox/combobox.test.ts | 18 +----- .../src/components/dialog/dialog.test.ts | 34 ++++------- .../components/disclosure/disclosure.test.ts | 22 ++----- .../components/focus-trap/focus-trap.test.ts | 20 ++----- .../src/components/listbox/listbox.test.tsx | 24 +++----- .../src/components/menu/menu.test.tsx | 18 +----- .../src/components/popover/popover.test.ts | 20 ++----- .../src/components/portal/portal.test.ts | 18 +----- .../radio-group/radio-group.test.ts | 23 +++----- .../src/components/switch/switch.test.tsx | 18 +----- .../src/components/tabs/tabs.test.ts | 18 +----- .../components/transitions/transition.test.ts | 18 +----- .../src/test-utils/vue-testing-library.ts | 19 ++++++ .../@headlessui-vue/src/utils/render.test.ts | 58 ++++++++++++++----- packages/@headlessui-vue/src/utils/render.ts | 31 +++++++++- 16 files changed, 147 insertions(+), 216 deletions(-) diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index bbf5e56ce0..4c6b3d9634 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` ([#1482](https://github.com/tailwindlabs/headlessui/pull/1482)) - Add `@headlessui/tailwindcss` plugin ([#1487](https://github.com/tailwindlabs/headlessui/pull/1487)) +### Fixed + +- Support `` children when using `as="template"` ([#1548](https://github.com/tailwindlabs/headlessui/pull/1548)) + ## [1.6.4] - 2022-05-29 ### Fixed diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index ca10160e43..e2da3e3d93 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -9,7 +9,7 @@ import { computed, PropType, } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { Combobox, ComboboxInput, @@ -89,21 +89,7 @@ function getDefaultComponents() { } } -function renderTemplate(input: string | Partial) { - let defaultComponents = getDefaultComponents() - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate(getDefaultComponents()) describe('safeguards', () => { it.each([ diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 0ba781c473..d74188c9a8 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -7,7 +7,7 @@ import { ConcreteComponent, onMounted, } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { Dialog, @@ -58,29 +58,15 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { - Dialog, - DialogOverlay, - DialogBackdrop, - DialogPanel, - DialogTitle, - DialogDescription, - TabSentinel, - } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ + Dialog, + DialogOverlay, + DialogBackdrop, + DialogPanel, + DialogTitle, + DialogDescription, + TabSentinel, +}) describe('Safe guards', () => { it.each([ diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts index 36cb854405..b2420fab7b 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts @@ -1,5 +1,5 @@ import { defineComponent, nextTick, ref, watch, h, ComponentOptionsWithoutProps } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { Disclosure, DisclosureButton, DisclosurePanel } from './disclosure' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { @@ -19,21 +19,11 @@ jest.mock('../../hooks/use-id') afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { Disclosure, DisclosureButton, DisclosurePanel } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ + Disclosure, + DisclosureButton, + DisclosurePanel, +}) describe('Safe guards', () => { it.each([ diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts index 1b24930ce7..8f67694d0b 100644 --- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts +++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts @@ -3,7 +3,7 @@ import { defineComponent, ref, nextTick, onMounted, ComponentOptionsWithoutProps import { FocusTrap } from './focus-trap' import { assertActiveElement, getByText } from '../../test-utils/accessibility-assertions' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { click, press, shift, Keys } from '../../test-utils/interactions' import { html } from '../../test-utils/html' @@ -16,21 +16,9 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { FocusTrap } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ + FocusTrap, +}) it('should focus the first focusable element inside the FocusTrap', async () => { renderTemplate( diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index 7fa2814158..4877b8aed7 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -7,7 +7,7 @@ import { reactive, ComponentOptionsWithoutProps, } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } from './listbox' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { @@ -66,21 +66,13 @@ function nextFrame() { }) } -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ + Listbox, + ListboxLabel, + ListboxButton, + ListboxOptions, + ListboxOption, +}) describe('safeguards', () => { it.each([ diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx index fbd6a915e4..4f0714060b 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -7,7 +7,7 @@ import { ref, watch, } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { Menu, MenuButton, MenuItems, MenuItem } from './menu' import { TransitionChild } from '../transitions/transition' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' @@ -61,21 +61,7 @@ function nextFrame() { }) } -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { Menu, MenuButton, MenuItems, MenuItem } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ Menu, MenuButton, MenuItems, MenuItem }) describe('Safe guards', () => { it.each([ diff --git a/packages/@headlessui-vue/src/components/popover/popover.test.ts b/packages/@headlessui-vue/src/components/popover/popover.test.ts index a5fcfaa356..54a730fe89 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.test.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.test.ts @@ -1,5 +1,5 @@ import { defineComponent, nextTick, ref, watch, h, ComponentOptionsWithoutProps } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { Popover, PopoverGroup, PopoverButton, PopoverPanel, PopoverOverlay } from './popover' import { Portal } from '../portal/portal' @@ -28,8 +28,8 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { +function getDefaultComponents() { + return { Popover, PopoverGroup, PopoverButton, @@ -37,20 +37,10 @@ function renderTemplate(input: string | ComponentOptionsWithoutProps) { PopoverOverlay, Portal, } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) } +const renderTemplate = createRenderTemplate(getDefaultComponents()) + describe('Safe guards', () => { it.each([ ['PopoverButton', PopoverButton], diff --git a/packages/@headlessui-vue/src/components/portal/portal.test.ts b/packages/@headlessui-vue/src/components/portal/portal.test.ts index e0baae06d0..ff8f2c942a 100644 --- a/packages/@headlessui-vue/src/components/portal/portal.test.ts +++ b/packages/@headlessui-vue/src/components/portal/portal.test.ts @@ -1,6 +1,6 @@ import { h, defineComponent, ref, nextTick, ComponentOptionsWithoutProps, createSSRApp } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { renderToString } from 'vue/server-renderer' import { Portal, PortalGroup } from './portal' import { click } from '../../test-utils/interactions' @@ -23,21 +23,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { Portal, PortalGroup } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ Portal, PortalGroup }) async function ssrRenderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Portal, PortalGroup } diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts index e6aca27595..148c87273c 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts @@ -1,5 +1,5 @@ import { defineComponent, nextTick, ref, watch, reactive, ComponentOptionsWithoutProps } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { RadioGroup, RadioGroupOption, RadioGroupLabel, RadioGroupDescription } from './radio-group' @@ -34,21 +34,12 @@ function nextFrame() { }) } -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { RadioGroup, RadioGroupOption, RadioGroupLabel, RadioGroupDescription } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ + RadioGroup, + RadioGroupOption, + RadioGroupLabel, + RadioGroupDescription, +}) describe('Safe guards', () => { it.each([['RadioGroupOption', RadioGroupOption]])( diff --git a/packages/@headlessui-vue/src/components/switch/switch.test.tsx b/packages/@headlessui-vue/src/components/switch/switch.test.tsx index 67e83416de..bf65a3e66e 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.test.tsx +++ b/packages/@headlessui-vue/src/components/switch/switch.test.tsx @@ -1,5 +1,5 @@ import { defineComponent, ref, watch, h, ComponentOptionsWithoutProps } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { Switch, SwitchLabel, SwitchDescription, SwitchGroup } from './switch' import { @@ -16,21 +16,7 @@ import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' jest.mock('../../hooks/use-id') -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { Switch, SwitchLabel, SwitchDescription, SwitchGroup } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ Switch, SwitchLabel, SwitchDescription, SwitchGroup }) describe('Safe guards', () => { it('should be possible to render a Switch without crashing', () => { diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts index 2ec3f3027a..75709043f3 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts @@ -1,5 +1,5 @@ import { ComponentOptionsWithoutProps, defineComponent, nextTick, ref } from 'vue' -import { render } from '../../test-utils/vue-testing-library' +import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' import { TabGroup, TabList, Tab, TabPanels, TabPanel } from './tabs' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { @@ -20,21 +20,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { TabGroup, TabList, Tab, TabPanels, TabPanel } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ TabGroup, TabList, Tab, TabPanels, TabPanel }) describe('safeguards', () => { it.each([ diff --git a/packages/@headlessui-vue/src/components/transitions/transition.test.ts b/packages/@headlessui-vue/src/components/transitions/transition.test.ts index ea01ade06b..01eae6fd3c 100644 --- a/packages/@headlessui-vue/src/components/transitions/transition.test.ts +++ b/packages/@headlessui-vue/src/components/transitions/transition.test.ts @@ -1,5 +1,5 @@ import { defineComponent, ref, onMounted, ComponentOptionsWithoutProps } from 'vue' -import { render, fireEvent } from '../../test-utils/vue-testing-library' +import { render, fireEvent, createRenderTemplate } from '../../test-utils/vue-testing-library' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { TransitionRoot, TransitionChild } from './transition' @@ -11,21 +11,7 @@ jest.mock('../../hooks/use-id') afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { TransitionRoot, TransitionChild } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ TransitionRoot, TransitionChild }) function getByTestId(id: string) { return document.querySelector(`[data-testid="${id}"]`)! as HTMLElement diff --git a/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts b/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts index 81eb7bbb66..a2d64a2205 100644 --- a/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts +++ b/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils' import { logDOM, fireEvent, screen } from '@testing-library/dom' +import { DefineComponent, ComponentOptionsWithoutProps, defineComponent } from 'vue' let mountedWrappers = new Set() @@ -13,6 +14,24 @@ function resolveContainer(): HTMLElement { return attachTo } +// It's not the most elegant type +// but Props and Emits need to be typed as any and not `{}` +type AnyComponent = DefineComponent + +export function createRenderTemplate(defaultComponents: Record) { + return (input: string | ComponentOptionsWithoutProps) => { + if (typeof input === 'string') { + input = { template: input } + } + + let component: ComponentOptionsWithoutProps = Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) + + return render(defineComponent(component)) + } +} + export function render(TestComponent: any, options?: Parameters[1] | undefined) { let wrapper = mount(TestComponent, { ...options, diff --git a/packages/@headlessui-vue/src/utils/render.test.ts b/packages/@headlessui-vue/src/utils/render.test.ts index 287ce0ff30..47408315ea 100644 --- a/packages/@headlessui-vue/src/utils/render.test.ts +++ b/packages/@headlessui-vue/src/utils/render.test.ts @@ -1,5 +1,5 @@ import { defineComponent, ComponentOptionsWithoutProps } from 'vue' -import { render as testRender } from '../test-utils/vue-testing-library' +import { createRenderTemplate, render as testRender } from '../test-utils/vue-testing-library' import { render } from './render' import { html } from '../test-utils/html' @@ -13,21 +13,7 @@ let Dummy = defineComponent({ }, }) -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = { Dummy } - - if (typeof input === 'string') { - return testRender(defineComponent({ template: input, components: defaultComponents })) - } - - return testRender( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} +const renderTemplate = createRenderTemplate({ Dummy }) describe('Validation', () => { it('should error when using an as="template" with additional props', () => { @@ -93,4 +79,44 @@ describe('Validation', () => { expect(document.getElementById('result')).toHaveClass('abc') }) + + it('should allow use of as children', () => { + renderTemplate({ + template: html` + +
Some Content
+
+ `, + + components: { + ExampleOuter: defineComponent({ + template: html` + + + + `, + + components: { + ExampleInner: defineComponent({ + components: { Dummy }, + + template: html` + + + + + + `, + }), + }, + }), + }, + }) + + expect(document.getElementById('result')).toHaveClass('foo') + expect(document.getElementById('result')).toHaveClass('bar') + + // TODO: Is this the expected behavior? Should it actually be `345`? + expect(document.getElementById('result')).toHaveAttribute('data-test', '123') + }) }) diff --git a/packages/@headlessui-vue/src/utils/render.ts b/packages/@headlessui-vue/src/utils/render.ts index 10c742655b..172bd0d8ba 100644 --- a/packages/@headlessui-vue/src/utils/render.ts +++ b/packages/@headlessui-vue/src/utils/render.ts @@ -1,4 +1,4 @@ -import { h, cloneVNode, Slots } from 'vue' +import { h, cloneVNode, Slots, Fragment, VNode } from 'vue' import { match } from './match' export enum Features { @@ -102,6 +102,8 @@ function _render({ } if (as === 'template') { + children = flattenFragments(children as VNode[]) + if (Object.keys(incomingProps).length > 0 || Object.keys(attrs).length > 0) { let [firstChild, ...other] = children ?? [] @@ -144,6 +146,33 @@ function _render({ return h(as, Object.assign({}, incomingProps, dataAttributes), children) } +/** + * When passed a structure like this: + * something + * + * And Example is defined as: + * + * + * We need to turn the fragment that represents into the slot. + * Luckily by this point it's already rendered into an array of VNodes + * for us so we can just flatten it directly. + * + * We have to do this recursively because there could be multiple + * levels of Component nesting all with elements interspersed + * + * @param children + * @returns + */ +function flattenFragments(children: VNode[]): VNode[] { + return children.flatMap((child) => { + if (child.type === Fragment) { + return flattenFragments(child.children as VNode[]) + } + + return [child] + }) +} + export function compact>(object: T) { let clone = Object.assign({}, object) for (let key in clone) {