Repository: tailwindlabs/headlessui
Pull request #3035 (2024-03-14 15:29:12)

Prefer incoming `data-*` attributes, over the ones set by Headless UI
Typically when Headless UI components set certain props on a component, then they are there to make sure that the component behaves as expected. E.g.: `aria-*` attributes, `role` attributes, `tabIndex`, etc.

However, we also provide helpful `data-focus` or `data-hover` attributes that make styling components easier. These attributes are not necessary to make the component behave as expected.

This brings us to the problem that this PR solves. Sometimes when you use one of Headless UI's components, and you provide your own component that already contains the same `data-*` attributes, then your attributes will be overridden.

This PR solves that and prefers the existing `data-*` attributes over the ones we set in Headless UI.

This is currently only implemented when you can't really control the props when using `as={Fragment}`.

```ts
<MenuButton as={Fragment}>
  <button data-hover={hovered}>My menu button</button>
</MenuButton>
```

In this scenario the `data-hover` based on the `hovered` variable that can come from anywhere will be preferred compared to what `MenuButton` provides.

vercel[bot]: [vc]: #sWEFdXxeJaen7kKbUFBLvRhvNKoSnnqT4XyKnfT8Bsk=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXJlYWN0Iiwicm9vdERpcmVjdG9yeSI6InBsYXlncm91bmRzL3JlYWN0IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RhaWx3aW5kbGFicy9oZWFkbGVzc3VpLXJlYWN0LzdxcmRjRHplM2pRNmdRcWtneFBvcTdSdmFOdjMiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS1yZWFjdC1naXQtZml4LWtlZXAtdXNlci1kYXRhLWF0dHJpYnV0ZXMtdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJoZWFkbGVzc3VpLXJlYWN0LWdpdC1maXgta2VlcC11c2VyLWRhdGEtYXR0cmlidXRlcy10YWlsd2luZGxhYnMudmVyY2VsLmFwcCJ9fV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-react** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-react/7qrdcDze3jQ6gQqkgxPoq7RvaNv3)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-react-git-fix-keep-user-data-attributes-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-react-git-fix-keep-user-data-attributes-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Mar 14, 2024 3:29pm |



Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 1035c32..8ce1ce1 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -17,11 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Attempt form submission when pressing `Enter` on `Checkbox` component ([#2962](https://github.com/tailwindlabs/headlessui/pull/2962))
 - Allow setting custom `tabIndex` on the `<Switch />` component ([#2966](https://github.com/tailwindlabs/headlessui/pull/2966))
 - Forward `disabled` state to hidden inputs in form-like components ([#3004](https://github.com/tailwindlabs/headlessui/pull/3004))
+- Prefer incoming `data-*` attributes, over the ones set by Headless UI ([#3035](https://github.com/tailwindlabs/headlessui/pull/3035))
 
 ### Changed
 
 - Attempt form submission when pressing `Enter` on the `<Listbox.Button />` component ([#2972](https://github.com/tailwindlabs/headlessui/pull/2972))
 
+### Added
+
+- Accept optional `strategy` for the `anchor` prop ([#3034](https://github.com/tailwindlabs/headlessui/pull/3034))
+
 ## [2.0.0-alpha.4] - 2024-01-03
 
 ### Fixed
diff --git a/packages/@headlessui-react/src/internal/floating.tsx b/packages/@headlessui-react/src/internal/floating.tsx
index b14a638..2aeea1b 100644
--- a/packages/@headlessui-react/src/internal/floating.tsx
+++ b/packages/@headlessui-react/src/internal/floating.tsx
@@ -21,6 +21,11 @@ type Align = 'start' | 'end'
 type Placement = 'top' | 'right' | 'bottom' | 'left'
 
 type BaseAnchorProps = {
+  /**
+   * The strategy to use when positioning the panel. Defaults to `fixed`.
+   */
+  strategy: 'absolute' | 'fixed'
+
   /**
    * The `gap` is the space between the trigger and the panel.
    */
@@ -166,6 +171,7 @@ export function FloatingProvider({
     offset = 0,
     padding = 0,
     inner,
+    strategy = 'fixed',
   } = useResolvedConfig(config, floatingEl)
   let [to, align = 'center'] = placement.split(' ') as [Placement | 'selection', Align | 'center']
 
@@ -189,7 +195,7 @@ export function FloatingProvider({
 
     // This component will be used in combination with a `Portal`, which means the floating
     // element will be rendered outside of the current DOM tree.
-    strategy: 'fixed',
+    strategy,
 
     // We use the panel in a `Dialog` which is making the page inert, therefore no re-positioning is
     // needed when scrolling changes.
diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts
index 56895f6..85357b8 100644
--- a/packages/@headlessui-react/src/utils/render.ts
+++ b/packages/@headlessui-react/src/utils/render.ts
@@ -213,12 +213,35 @@ function _render<TTag extends ElementType, TSlot>(
 
         let classNameProps = newClassName ? { className: newClassName } : {}
 
+        // Merge props from the existing element with the incoming props
+        let mergedProps = mergePropsAdvanced(
+          resolvedChildren.props as any,
+          // Filter out undefined values so that they don't override the existing values
+          compact(omit(rest, ['ref']))
+        )
+
+        // Make sure that `data-*` that already exist in the `mergedProps` are
+        // skipped.
+        //
+        // Typically we want to keep the props we set in each component because
+        // they are required to make the component work correctly. However, in
+        // case of `data-*` attributes, these are attributes that help the end
+        // user.
+        //
+        // This means that since the props are not required for the component to
+        // work, that we can safely prefer the `data-*` attributes from the
+        // component that the end user provided.
+        for (let key in dataAttributes) {
+          if (key in mergedProps) {
+            delete dataAttributes[key]
+          }
+        }
+
         return cloneElement(
           resolvedChildren,
           Object.assign(
             {},
-            // Filter out undefined values so that they don't override the existing values
-            mergePropsAdvanced(resolvedChildren.props as any, compact(omit(rest, ['ref']))),
+            mergedProps,
             dataAttributes,
             refRelatedProps,
             { ref: mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref) },


Test patch:
diff --git a/packages/@headlessui-react/src/utils/__snapshots__/render.test.tsx.snap b/packages/@headlessui-react/src/utils/__snapshots__/render.test.tsx.snap
index ea55f73..fc97ee6 100644
--- a/packages/@headlessui-react/src/utils/__snapshots__/render.test.tsx.snap
+++ b/packages/@headlessui-react/src/utils/__snapshots__/render.test.tsx.snap
@@ -109,6 +109,22 @@ exports[`Default functionality should forward all the props to the first child w
 </div>"
 `;
 
+exports[`Default functionality should forward boolean values from \`slot\` as data attributes 1`] = `
+"<div
+  data-testid=\\"wrapper\\"
+>
+  <div
+    data-a=\\"\\"
+    data-c=\\"\\"
+    data-headlessui-state=\\"a c\\"
+  >
+    <span>
+      Contents
+    </span>
+  </div>
+</div>"
+`;
+
 exports[`Default functionality should not error when we are rendering a Fragment with multiple children when we don't passthrough additional props 1`] = `
 "<div
   data-testid=\\"wrapper\\"
@@ -122,6 +138,19 @@ exports[`Default functionality should not error when we are rendering a Fragment
 </div>"
 `;
 
+exports[`Default functionality should prefer user provided data attributes over the ones we set automatically 1`] = `
+"<div
+  data-testid=\\"wrapper\\"
+>
+  <span
+    data-accept=\\"always\\"
+    data-headlessui-state=\\"accept\\"
+  >
+    Contents
+  </span>
+</div>"
+`;
+
 exports[`Features.RenderStrategy Hidden render strategy should be possible to render an \`unmount={false}\` dummy component (show = false) 1`] = `
 "<div
   data-testid=\\"wrapper\\"
diff --git a/packages/@headlessui-react/src/utils/render.test.tsx b/packages/@headlessui-react/src/utils/render.test.tsx
index 48509cd..f6844c5 100644
--- a/packages/@headlessui-react/src/utils/render.test.tsx
+++ b/packages/@headlessui-react/src/utils/render.test.tsx
@@ -153,6 +153,50 @@ describe('Default functionality', () => {
     expect(contents()).toMatchSnapshot()
   })
 
+  it('should forward boolean values from `slot` as data attributes', () => {
+    function Dummy<TTag extends ElementType = 'div'>(
+      props: Props<TTag> & Partial<{ a: any; b: any; c: any }>
+    ) {
+      return (
+        <div data-testid="wrapper">
+          {render({
+            ourProps: {},
+            theirProps: props,
+            slot: { a: true, b: false, c: true },
+            defaultTag: 'div',
+            name: 'Dummy',
+          })}
+        </div>
+      )
+    }
+
+    testRender(<Dummy>{() => <span>Contents</span>}</Dummy>)
+
+    expect(contents()).toMatchSnapshot()
+  })
+
+  it('should prefer user provided data attributes over the ones we set automatically', () => {
+    function Dummy<TTag extends ElementType = 'div'>(
+      props: Props<TTag> & Partial<{ a: any; b: any; c: any }>
+    ) {
+      return (
+        <div data-testid="wrapper">
+          {render({
+            ourProps: {},
+            theirProps: props,
+            slot: { accept: true },
+            defaultTag: 'div',
+            name: 'Dummy',
+          })}
+        </div>
+      )
+    }
+
+    testRender(<Dummy as={Fragment}>{() => <span data-accept="always">Contents</span>}</Dummy>)
+
+    expect(contents()).toMatchSnapshot()
+  })
+
   it(
     'should error when we are rendering a Fragment with multiple children',
     suppressConsoleLogs(() => {