diff --git a/jest/create-jest-config.cjs b/jest/create-jest-config.cjs index 6700197..3ab2429 100644 --- a/jest/create-jest-config.cjs +++ b/jest/create-jest-config.cjs @@ -8,6 +8,9 @@ module.exports = function createJestConfig(root, options) { '^.+\\.(t|j)sx?$': '@swc/jest', ...transform, }, + globals: { + __DEV__: true, + }, }, rest ) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index a661465..c41b192 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix SSR tab hydration when using Strict Mode in development ([#2231](https://github.com/tailwindlabs/headlessui/pull/2231)) +- Don't break overflow when multiple dialogs are open at the same time ([#2215](https://github.com/tailwindlabs/headlessui/pull/2215)) ## [1.7.8] - 2023-01-27 diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index f632115..356e2a0 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -1,4 +1,4 @@ -import React, { createElement, useRef, useState, Fragment, useEffect } from 'react' +import React, { createElement, useRef, useState, Fragment, useEffect, useCallback } from 'react' import { render } from '@testing-library/react' import { Dialog } from './dialog' @@ -37,13 +37,13 @@ global.IntersectionObserver = class FakeIntersectionObserver { afterAll(() => jest.restoreAllMocks()) function nextFrame() { - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - resolve() - }) - }) - }) + return frames(1) +} + +async function frames(count: number) { + for (let n = 0; n <= count; n++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } } function TabSentinel(props: PropsOf<'button'>) { @@ -323,6 +323,94 @@ describe('Rendering', () => { expect(document.documentElement.style.overflow).toBe('hidden') }) ) + + it( + 'scroll locking should work when transitioning between dialogs', + suppressConsoleLogs(async () => { + // While we don't support multiple dialogs + // We at least want to work towards supporting it at some point + // The first step is just making sure that scroll locking works + // when there are multiple dialogs open at the same time + + function Example() { + let [dialogs, setDialogs] = useState([]) + let toggle = useCallback( + (id: string, state: 'open' | 'close') => { + if (state === 'open' && !dialogs.includes(id)) { + setDialogs([id]) + } else if (state === 'close' && dialogs.includes(id)) { + setDialogs(dialogs.filter((x) => x !== id)) + } + }, + [dialogs] + ) + + return ( + <> + + + + + ) + } + + function DialogWrapper({ + id, + dialogs, + toggle, + }: { + id: string + dialogs: string[] + toggle: (id: string, state: 'open' | 'close') => void + }) { + return ( + <> + + + toggle(id, 'close')}> + + + + + ) + } + + render() + + // No overflow yet + expect(document.documentElement.style.overflow).toBe('') + + let open1 = () => document.getElementById('open_d1') + let open2 = () => document.getElementById('open_d2') + let open3 = () => document.getElementById('open_d3') + let close3 = () => document.getElementById('close_d3') + + // Open the dialog & expect overflow + await click(open1()) + await frames(2) + expect(document.documentElement.style.overflow).toBe('hidden') + + // Open the dialog & expect overflow + await click(open2()) + await frames(2) + expect(document.documentElement.style.overflow).toBe('hidden') + + // Open the dialog & expect overflow + await click(open3()) + await frames(2) + expect(document.documentElement.style.overflow).toBe('hidden') + + // At this point only the last dialog should be open + // Close the dialog & dont expect overflow + await click(close3()) + await frames(2) + expect(document.documentElement.style.overflow).toBe('') + }) + ) }) describe('Dialog.Overlay', () => { diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 927ce0b..bb83b30 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -37,8 +37,7 @@ import { useOwnerDocument } from '../../hooks/use-owner' import { useEventListener } from '../../hooks/use-event-listener' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { useEvent } from '../../hooks/use-event' -import { disposables } from '../../utils/disposables' -import { isIOS } from '../../utils/platform' +import { useDocumentOverflowLockedEffect } from '../../hooks/document-overflow/use-document-overflow' enum DialogStates { Open, @@ -96,110 +95,9 @@ function useScrollLock( enabled: boolean, resolveAllowedContainers: () => HTMLElement[] = () => [document.body] ) { - useEffect(() => { - if (!enabled) return - if (!ownerDocument) return - - let d = disposables() - let scrollPosition = window.pageYOffset - - function style(node: HTMLElement, property: string, value: string) { - let previous = node.style.getPropertyValue(property) - Object.assign(node.style, { [property]: value }) - return d.add(() => { - Object.assign(node.style, { [property]: previous }) - }) - } - - let documentElement = ownerDocument.documentElement - let ownerWindow = ownerDocument.defaultView ?? window - - let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth - style(documentElement, 'overflow', 'hidden') - - if (scrollbarWidthBefore > 0) { - let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth - let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter - style(documentElement, 'paddingRight', `${scrollbarWidth}px`) - } - - if (isIOS()) { - style(ownerDocument.body, 'marginTop', `-${scrollPosition}px`) - window.scrollTo(0, 0) - - // Relatively hacky, but if you click a link like `` in the Dialog, and there - // exists an element on the page (outside of the Dialog) with that id, then the browser will - // scroll to that position. However, this is not the case if the element we want to scroll to - // is higher and the browser needs to scroll up, but it doesn't do that. - // - // Let's try and capture that element and store it, so that we can later scroll to it once the - // Dialog closes. - let scrollToElement: HTMLElement | null = null - d.addEventListener( - ownerDocument, - 'click', - (e) => { - if (e.target instanceof HTMLElement) { - try { - let anchor = e.target.closest('a') - if (!anchor) return - let { hash } = new URL(anchor.href) - let el = ownerDocument.querySelector(hash) - if (el && !resolveAllowedContainers().some((container) => container.contains(el))) { - scrollToElement = el as HTMLElement - } - } catch (err) {} - } - }, - true - ) - - d.addEventListener( - ownerDocument, - 'touchmove', - (e) => { - // Check if we are scrolling inside any of the allowed containers, if not let's cancel the event! - if ( - e.target instanceof HTMLElement && - !resolveAllowedContainers().some((container) => - container.contains(e.target as HTMLElement) - ) - ) { - e.preventDefault() - } - }, - { passive: false } - ) - - // Restore scroll position - d.add(() => { - // Before opening the Dialog, we capture the current pageYOffset, and offset the page with - // this value so that we can also scroll to `(0, 0)`. - // - // If we want to restore a few things can happen: - // - // 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely - // restore to the captured value earlier. - // 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a - // link was scrolled into view in the background). Ideally we want to restore to this _new_ - // position. To do this, we can take the new value into account with the captured value from - // before. - // - // (Since the value of window.pageYOffset is 0 in the first case, we should be able to - // always sum these values) - window.scrollTo(0, window.pageYOffset + scrollPosition) - - // If we captured an element that should be scrolled to, then we can try to do that if the - // element is still connected (aka, still in the DOM). - if (scrollToElement && scrollToElement.isConnected) { - scrollToElement.scrollIntoView({ block: 'nearest' }) - scrollToElement = null - } - }) - } - - return d.dispose - }, [ownerDocument, enabled]) + useDocumentOverflowLockedEffect(ownerDocument, enabled, (meta) => ({ + containers: [...(meta.containers ?? []), resolveAllowedContainers], + })) } function stateReducer(state: StateDefinition, action: Actions) { diff --git a/packages/@headlessui-react/src/hooks/document-overflow/adjust-scrollbar-padding.ts b/packages/@headlessui-react/src/hooks/document-overflow/adjust-scrollbar-padding.ts new file mode 100644 index 0000000..8abd827 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/document-overflow/adjust-scrollbar-padding.ts @@ -0,0 +1,25 @@ +import { ScrollLockStep } from './overflow-store' + +export function adjustScrollbarPadding(): ScrollLockStep { + let scrollbarWidthBefore: number + + return { + before({ doc }) { + let documentElement = doc.documentElement + let ownerWindow = doc.defaultView ?? window + + scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth + }, + + after({ doc, d }) { + let documentElement = doc.documentElement + + // Account for the change in scrollbar width + // NOTE: This is a bit of a hack, but it's the only way to do this + let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth + let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter + + d.style(documentElement, 'paddingRight', `${scrollbarWidth}px`) + }, + } +} diff --git a/packages/@headlessui-react/src/hooks/document-overflow/handle-ios-locking.ts b/packages/@headlessui-react/src/hooks/document-overflow/handle-ios-locking.ts new file mode 100644 index 0000000..71fb049 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/document-overflow/handle-ios-locking.ts @@ -0,0 +1,98 @@ +import { isIOS } from '../../utils/platform' +import { ScrollLockStep } from './overflow-store' + +interface ContainerMetadata { + containers: (() => HTMLElement[])[] +} + +export function handleIOSLocking(): ScrollLockStep { + if (!isIOS()) { + return {} + } + + let scrollPosition: number + + return { + before() { + scrollPosition = window.pageYOffset + }, + + after({ doc, d, meta }) { + function inAllowedContainer(el: HTMLElement) { + return meta.containers + .flatMap((resolve) => resolve()) + .some((container) => container.contains(el)) + } + + d.style(doc.body, 'marginTop', `-${scrollPosition}px`) + window.scrollTo(0, 0) + + // Relatively hacky, but if you click a link like `` in the Dialog, and there + // exists an element on the page (outside of the Dialog) with that id, then the browser will + // scroll to that position. However, this is not the case if the element we want to scroll to + // is higher and the browser needs to scroll up, but it doesn't do that. + // + // Let's try and capture that element and store it, so that we can later scroll to it once the + // Dialog closes. + let scrollToElement: HTMLElement | null = null + d.addEventListener( + doc, + 'click', + (e) => { + if (!(e.target instanceof HTMLElement)) { + return + } + + try { + let anchor = e.target.closest('a') + if (!anchor) return + let { hash } = new URL(anchor.href) + let el = doc.querySelector(hash) + if (el && !inAllowedContainer(el as HTMLElement)) { + scrollToElement = el as HTMLElement + } + } catch (err) {} + }, + true + ) + + d.addEventListener( + doc, + 'touchmove', + (e) => { + // Check if we are scrolling inside any of the allowed containers, if not let's cancel the event! + if (e.target instanceof HTMLElement && !inAllowedContainer(e.target as HTMLElement)) { + e.preventDefault() + } + }, + { passive: false } + ) + + // Restore scroll position + d.add(() => { + // Before opening the Dialog, we capture the current pageYOffset, and offset the page with + // this value so that we can also scroll to `(0, 0)`. + // + // If we want to restore a few things can happen: + // + // 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely + // restore to the captured value earlier. + // 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a + // link was scrolled into view in the background). Ideally we want to restore to this _new_ + // position. To do this, we can take the new value into account with the captured value from + // before. + // + // (Since the value of window.pageYOffset is 0 in the first case, we should be able to + // always sum these values) + window.scrollTo(0, window.pageYOffset + scrollPosition) + + // If we captured an element that should be scrolled to, then we can try to do that if the + // element is still connected (aka, still in the DOM). + if (scrollToElement && scrollToElement.isConnected) { + scrollToElement.scrollIntoView({ block: 'nearest' }) + scrollToElement = null + } + }) + }, + } +} diff --git a/packages/@headlessui-react/src/hooks/document-overflow/overflow-store.ts b/packages/@headlessui-react/src/hooks/document-overflow/overflow-store.ts new file mode 100644 index 0000000..70604f6 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/document-overflow/overflow-store.ts @@ -0,0 +1,118 @@ +import { disposables, Disposables } from '../../utils/disposables' +import { createStore } from '../../utils/store' +import { adjustScrollbarPadding } from './adjust-scrollbar-padding' +import { handleIOSLocking } from './handle-ios-locking' +import { preventScroll } from './prevent-scroll' + +interface DocEntry { + doc: Document + count: number + d: Disposables + meta: Set +} + +function buildMeta(fns: Iterable) { + let tmp = {} + for (let fn of fns) { + Object.assign(tmp, fn(tmp)) + } + return tmp +} + +export type MetaFn = (meta: Record) => Record + +export interface Context = any> { + doc: Document + d: Disposables + meta: MetaType +} + +export interface ScrollLockStep = any> { + before?(ctx: Context): void + after?(ctx: Context): void +} + +export let overflows = createStore(() => new Map(), { + PUSH(doc: Document, meta: MetaFn) { + let entry = this.get(doc) ?? { + doc, + count: 0, + d: disposables(), + meta: new Set(), + } + + entry.count++ + entry.meta.add(meta) + this.set(doc, entry) + + return this + }, + + POP(doc: Document, meta: MetaFn) { + let entry = this.get(doc) + if (entry) { + entry.count-- + entry.meta.delete(meta) + } + + return this + }, + + SCROLL_PREVENT({ doc, d, meta }: DocEntry) { + let ctx = { + doc, + d, + meta: buildMeta(meta), + } + + let steps: ScrollLockStep[] = [ + handleIOSLocking(), + adjustScrollbarPadding(), + preventScroll(), + ] + + // Run all `before` actions together + steps.forEach(({ before }) => before?.(ctx)) + + // Run all `after` actions together + steps.forEach(({ after }) => after?.(ctx)) + }, + + SCROLL_ALLOW({ d }: DocEntry) { + d.dispose() + }, + + TEARDOWN({ doc }: DocEntry) { + this.delete(doc) + }, +}) + +// Update the document overflow state when the store changes +// This MUST happen outside of react for this to work properly. +overflows.subscribe(() => { + let docs = overflows.getSnapshot() + + let styles = new Map() + + // Read data from all the documents + for (let [doc] of docs) { + styles.set(doc, doc.documentElement.style.overflow) + } + + // Write data to all the documents + for (let entry of docs.values()) { + let isHidden = styles.get(entry.doc) === 'hidden' + let isLocked = entry.count !== 0 + let willChange = (isLocked && !isHidden) || (!isLocked && isHidden) + + if (willChange) { + overflows.dispatch(entry.count > 0 ? 'SCROLL_PREVENT' : 'SCROLL_ALLOW', entry) + } + + // We have to clean up after ourselves so we don't leak memory + // Using a WeakMap would be ideal, but it's not iterable + if (entry.count === 0) { + overflows.dispatch('TEARDOWN', entry) + } + } +}) diff --git a/packages/@headlessui-react/src/hooks/document-overflow/prevent-scroll.ts b/packages/@headlessui-react/src/hooks/document-overflow/prevent-scroll.ts new file mode 100644 index 0000000..09355a6 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/document-overflow/prevent-scroll.ts @@ -0,0 +1,9 @@ +import { ScrollLockStep } from './overflow-store' + +export function preventScroll(): ScrollLockStep { + return { + before({ doc, d }) { + d.style(doc.documentElement, 'overflow', 'hidden') + }, + } +} diff --git a/packages/@headlessui-react/src/hooks/document-overflow/use-document-overflow.ts b/packages/@headlessui-react/src/hooks/document-overflow/use-document-overflow.ts new file mode 100644 index 0000000..849a635 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/document-overflow/use-document-overflow.ts @@ -0,0 +1,27 @@ +import { useIsoMorphicEffect } from '../use-iso-morphic-effect' +import { useStore } from '../../hooks/use-store' +import { overflows } from './overflow-store' + +export function useDocumentOverflowLockedEffect( + doc: Document | null, + shouldBeLocked: boolean, + meta: (meta: Record) => Record +) { + let store = useStore(overflows) + let entry = doc ? store.get(doc) : undefined + let locked = entry ? entry.count > 0 : false + + useIsoMorphicEffect(() => { + if (!doc || !shouldBeLocked) { + return + } + + // Prevent the document from scrolling + overflows.dispatch('PUSH', doc, meta) + + // Allow document to scroll + return () => overflows.dispatch('POP', doc, meta) + }, [shouldBeLocked, doc]) + + return locked +} diff --git a/packages/@headlessui-react/src/hooks/use-store.ts b/packages/@headlessui-react/src/hooks/use-store.ts new file mode 100644 index 0000000..92ab46f --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-store.ts @@ -0,0 +1,6 @@ +import { useSyncExternalStore } from '../use-sync-external-store-shim/index' +import { Store } from '../utils/store' + +export function useStore(store: Store) { + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) +} diff --git a/packages/@headlessui-react/src/use-sync-external-store-shim/index.ts b/packages/@headlessui-react/src/use-sync-external-store-shim/index.ts new file mode 100644 index 0000000..28a6609 --- /dev/null +++ b/packages/@headlessui-react/src/use-sync-external-store-shim/index.ts @@ -0,0 +1,38 @@ +// This was taken from the ESM / CJS compatible version found in Remix Router: +// https://github.com/remix-run/react-router/tree/43cc1aacd8b132507618a4a1dd7de3674cd7bcf4/packages/react-router/lib/use-sync-external-store-shim + +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react' +import { useSyncExternalStore as client } from './useSyncExternalStoreShimClient' +import { useSyncExternalStore as server } from './useSyncExternalStoreShimServer' + +const canUseDOM: boolean = !!( + typeof window !== 'undefined' && + typeof window.document !== 'undefined' && + typeof window.document.createElement !== 'undefined' +) + +const isServerEnvironment = !canUseDOM +const shim = isServerEnvironment ? server : client + +type UseSyncExternalStoreFn = ( + subscribe: (fn: () => void) => () => void, + getSnapshot: () => T, + // Note: The shim does not use getServerSnapshot, because pre-18 versions of + // React do not expose a way to check if we're hydrating. So users of the shim + // will need to track that themselves and return the correct value + // from `getSnapshot`. + getServerSnapshot?: () => T +) => T + +// @ts-ignore +export const useSyncExternalStore: UseSyncExternalStoreFn = + 'useSyncExternalStore' in React ? ((r) => r.useSyncExternalStore)(React) : shim diff --git a/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimClient.ts b/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimClient.ts new file mode 100644 index 0000000..58f967c --- /dev/null +++ b/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimClient.ts @@ -0,0 +1,152 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react' + +// Make typescript happy +declare var __DEV__: boolean + +/** + * inlined Object.is polyfill to avoid requiring consumers ship their own + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is + */ +function isPolyfill(x: any, y: any) { + return ( + (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare + ) +} + +const is: (x: any, y: any) => boolean = typeof Object.is === 'function' ? Object.is : isPolyfill + +// Intentionally not using named imports because Rollup uses dynamic +// dispatch for CommonJS interop named imports. +const { useState, useEffect, useLayoutEffect, useDebugValue } = React + +let didWarnOld18Alpha = false +let didWarnUncachedGetSnapshot = false + +// Disclaimer: This shim breaks many of the rules of React, and only works +// because of a very particular set of implementation details and assumptions +// -- change any one of them and it will break. The most important assumption +// is that updates are always synchronous, because concurrent rendering is +// only available in versions of React that also have a built-in +// useSyncExternalStore API. And we only use this shim when the built-in API +// does not exist. +// +// Do not assume that the clever hacks used by this hook also work in general. +// The point of this shim is to replace the need for hacks by other libraries. +export function useSyncExternalStore( + subscribe: (fn: () => void) => () => void, + getSnapshot: () => T, + // Note: The shim does not use getServerSnapshot, because pre-18 versions of + // React do not expose a way to check if we're hydrating. So users of the shim + // will need to track that themselves and return the correct value + // from `getSnapshot`. + getServerSnapshot?: () => T +): T { + if (__DEV__) { + if (!didWarnOld18Alpha) { + if ('startTransition' in React) { + didWarnOld18Alpha = true + console.error( + 'You are using an outdated, pre-release alpha of React 18 that ' + + 'does not support useSyncExternalStore. The ' + + 'use-sync-external-store shim will not work correctly. Upgrade ' + + 'to a newer pre-release.' + ) + } + } + } + + // Read the current snapshot from the store on every render. Again, this + // breaks the rules of React, and only works here because of specific + // implementation details, most importantly that updates are + // always synchronous. + const value = getSnapshot() + if (__DEV__) { + if (!didWarnUncachedGetSnapshot) { + const cachedValue = getSnapshot() + if (!is(value, cachedValue)) { + console.error('The result of getSnapshot should be cached to avoid an infinite loop') + didWarnUncachedGetSnapshot = true + } + } + } + + // Because updates are synchronous, we don't queue them. Instead we force a + // re-render whenever the subscribed state changes by updating an some + // arbitrary useState hook. Then, during render, we call getSnapshot to read + // the current value. + // + // Because we don't actually use the state returned by the useState hook, we + // can save a bit of memory by storing other stuff in that slot. + // + // To implement the early bailout, we need to track some things on a mutable + // object. Usually, we would put that in a useRef hook, but we can stash it in + // our useState hook instead. + // + // To force a re-render, we call forceUpdate({inst}). That works because the + // new object always fails an equality check. + const [{ inst }, forceUpdate] = useState({ inst: { value, getSnapshot } }) + + // Track the latest getSnapshot function with a ref. This needs to be updated + // in the layout phase so we can access it during the tearing check that + // happens on subscribe. + useLayoutEffect(() => { + inst.value = value + inst.getSnapshot = getSnapshot + + // Whenever getSnapshot or subscribe changes, we need to check in the + // commit phase if there was an interleaved mutation. In concurrent mode + // this can happen all the time, but even in synchronous mode, an earlier + // effect may have mutated the store. + if (checkIfSnapshotChanged(inst)) { + // Force a re-render. + forceUpdate({ inst }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscribe, value, getSnapshot]) + + useEffect(() => { + // Check for changes right before subscribing. Subsequent changes will be + // detected in the subscription handler. + if (checkIfSnapshotChanged(inst)) { + // Force a re-render. + forceUpdate({ inst }) + } + const handleStoreChange = () => { + // TODO: Because there is no cross-renderer API for batching updates, it's + // up to the consumer of this library to wrap their subscription event + // with unstable_batchedUpdates. Should we try to detect when this isn't + // the case and print a warning in development? + + // The store changed. Check if the snapshot changed since the last time we + // read from the store. + if (checkIfSnapshotChanged(inst)) { + // Force a re-render. + forceUpdate({ inst }) + } + } + // Subscribe to the store and return a clean-up function. + return subscribe(handleStoreChange) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscribe]) + + useDebugValue(value) + return value +} + +function checkIfSnapshotChanged(inst: any) { + const latestGetSnapshot = inst.getSnapshot + const prevValue = inst.value + try { + const nextValue = latestGetSnapshot() + return !is(prevValue, nextValue) + } catch (error) { + return true + } +} diff --git a/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimServer.ts b/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimServer.ts new file mode 100644 index 0000000..96b8dbc --- /dev/null +++ b/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimServer.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export function useSyncExternalStore( + subscribe: (fn: () => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T +): T { + // Note: The shim does not use getServerSnapshot, because pre-18 versions of + // React do not expose a way to check if we're hydrating. So users of the shim + // will need to track that themselves and return the correct value + // from `getSnapshot`. + return getSnapshot() +} diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts index ef505c4..fb06193 100644 --- a/packages/@headlessui-react/src/utils/disposables.ts +++ b/packages/@headlessui-react/src/utils/disposables.ts @@ -1,5 +1,7 @@ import { microTask } from './micro-task' +export type Disposables = ReturnType + export function disposables() { let disposables: Function[] = [] let queue: Function[] = [] @@ -69,6 +71,14 @@ export function disposables() { await handle() } }, + + style(node: HTMLElement, property: string, value: string) { + let previous = node.style.getPropertyValue(property) + Object.assign(node.style, { [property]: value }) + return this.add(() => { + Object.assign(node.style, { [property]: previous }) + }) + }, } return api diff --git a/packages/@headlessui-react/src/utils/store.ts b/packages/@headlessui-react/src/utils/store.ts new file mode 100644 index 0000000..1521bf7 --- /dev/null +++ b/packages/@headlessui-react/src/utils/store.ts @@ -0,0 +1,39 @@ +type ChangeFn = () => void +type UnsubscribeFn = () => void +type ActionFn = (this: T, ...args: any[]) => T | void +type StoreActions = Record> + +export interface Store { + getSnapshot(): T + subscribe(onChange: ChangeFn): UnsubscribeFn + dispatch(action: ActionKey, ...args: any[]): void +} + +export function createStore( + initial: () => T, + actions: StoreActions +): Store { + let state: T = initial() + + let listeners = new Set() + + return { + getSnapshot() { + return state + }, + + subscribe(onChange) { + listeners.add(onChange) + + return () => listeners.delete(onChange) + }, + + dispatch(key: ActionKey, ...args: any[]) { + let newState = actions[key].call(state, ...args) + if (newState) { + state = newState + listeners.forEach((listener) => listener()) + } + }, + } +} diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index e8cf6a1..819ebae 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Don't break overflow when multiple dialogs are open at the same time ([#2215](https://github.com/tailwindlabs/headlessui/pull/2215)) ## [1.7.8] - 2023-01-27 diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 3a9f600..e6f1766 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -1,5 +1,14 @@ -import { defineComponent, ref, nextTick, h, ConcreteComponent, onMounted } from 'vue' -import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' +import { + defineComponent, + ref, + nextTick, + h, + ConcreteComponent, + onMounted, + PropType, + computed, +} from 'vue' +import { createRenderTemplate, render, screen } from '../../test-utils/vue-testing-library' import { Dialog, @@ -42,13 +51,13 @@ global.IntersectionObserver = class FakeIntersectionObserver { afterAll(() => jest.restoreAllMocks()) function nextFrame() { - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - resolve() - }) - }) - }) + return frames(1) +} + +async function frames(count: number) { + for (let n = 0; n <= count; n++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } } let TabSentinel = defineComponent({ @@ -441,6 +450,101 @@ describe('Rendering', () => { expect(document.documentElement.style.overflow).toBe('hidden') }) ) + + it( + 'scroll locking should work when transitioning between dialogs', + suppressConsoleLogs(async () => { + // While we don't support multiple dialogs + // We at least want to work towards supporting it at some point + // The first step is just making sure that scroll locking works + // when there are multiple dialogs open at the same time + let DialogWrapper = defineComponent({ + components: { + TransitionRoot, + Dialog, + }, + props: { + id: String, + dialogs: Array as PropType, + toggle: Function as PropType<(id: string, state: string) => void>, + }, + template: ` + + + + + + + `, + + setup(props) { + return { + id_open: computed(() => `open_${props.id}`), + id_close: computed(() => `close_${props.id}`), + } + }, + }) + + let Example = defineComponent({ + components: { DialogWrapper }, + template: ` + + + + `, + + setup() { + let dialogs = ref([]) + return { + dialogs, + toggle(id: string, state: 'open' | 'close') { + if (state === 'open' && !dialogs.value.includes(id)) { + dialogs.value = [id] + } else if (state === 'close' && dialogs.value.includes(id)) { + dialogs.value = dialogs.value.filter((x) => x !== id) + } + }, + } + }, + }) + + renderTemplate(Example) + + // No overflow yet + expect(document.documentElement.style.overflow).toBe('') + + let open1 = () => document.getElementById('open_d1') + let open2 = () => document.getElementById('open_d2') + let open3 = () => document.getElementById('open_d3') + let close3 = () => document.getElementById('close_d3') + + // Open the dialog & expect overflow + await click(open1()) + await frames(2) + expect(document.documentElement.style.overflow).toBe('hidden') + + // Open the dialog & expect overflow + await click(open2()) + await frames(2) + // expect(document.documentElement.style.overflow).toBe('hidden') + + // Open the dialog & expect overflow + await click(open3()) + await frames(2) + expect(document.documentElement.style.overflow).toBe('hidden') + + // At this point only the last dialog should be open + // Close the dialog & dont expect overflow + await click(close3()) + await frames(2) + + expect(document.documentElement.style.overflow).toBe('') + }) + ) }) describe('DialogOverlay', () => { diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 902756c..bf15e00 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -33,8 +33,8 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { getOwnerDocument } from '../../utils/owner' import { useEventListener } from '../../hooks/use-event-listener' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' -import { disposables } from '../../utils/disposables' -import { isIOS } from '../../utils/platform' +import { useDocumentOverflowLockedEffect } from '../../hooks/document-overflow/use-document-overflow' +import { handleIOSLocking } from '../../hooks/document-overflow/handle-ios-locking' enum DialogStates { Open, @@ -221,114 +221,9 @@ export let Dialog = defineComponent({ }) // Scroll lock - watchEffect((onInvalidate) => { - if (dialogState.value !== DialogStates.Open) return - if (hasParentDialog) return - - let owner = ownerDocument.value - if (!owner) return - - let d = disposables() - let scrollPosition = window.pageYOffset - - function style(node: HTMLElement, property: string, value: string) { - let previous = node.style.getPropertyValue(property) - Object.assign(node.style, { [property]: value }) - return d.add(() => { - Object.assign(node.style, { [property]: previous }) - }) - } - - let documentElement = owner?.documentElement - let ownerWindow = owner.defaultView ?? window - - let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth - style(documentElement, 'overflow', 'hidden') - - if (scrollbarWidthBefore > 0) { - let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth - let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter - style(documentElement, 'paddingRight', `${scrollbarWidth}px`) - } - - if (isIOS()) { - style(owner.body, 'marginTop', `-${scrollPosition}px`) - window.scrollTo(0, 0) - - // Relatively hacky, but if you click a link like `` in the Dialog, and there - // exists an element on the page (outside of the Dialog) with that id, then the browser will - // scroll to that position. However, this is not the case if the element we want to scroll to - // is higher and the browser needs to scroll up, but it doesn't do that. - // - // Let's try and capture that element and store it, so that we can later scroll to it once the - // Dialog closes. - let scrollToElement: HTMLElement | null = null - d.addEventListener( - owner, - 'click', - (e) => { - if (e.target instanceof HTMLElement) { - try { - let anchor = e.target.closest('a') - if (!anchor) return - let { hash } = new URL(anchor.href) - let el = owner!.querySelector(hash) - if (el && !resolveAllowedContainers().some((container) => container.contains(el))) { - scrollToElement = el as HTMLElement - } - } catch (err) {} - } - }, - true - ) - - d.addEventListener( - owner, - 'touchmove', - (e) => { - // Check if we are scrolling inside any of the allowed containers, if not let's cancel - // the event! - if ( - e.target instanceof HTMLElement && - !resolveAllowedContainers().some((container) => - container.contains(e.target as HTMLElement) - ) - ) { - e.preventDefault() - } - }, - { passive: false } - ) - - // Restore scroll position - d.add(() => { - // Before opening the Dialog, we capture the current pageYOffset, and offset the page with - // this value so that we can also scroll to `(0, 0)`. - // - // If we want to restore a few things can happen: - // - // 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely - // restore to the captured value earlier. - // 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a - // link was scrolled into view in the background). Ideally we want to restore to this _new_ - // position. To do this, we can take the new value into account with the captured value from - // before. - // - // (Since the value of window.pageYOffset is 0 in the first case, we should be able to - // always sum these values) - window.scrollTo(0, window.pageYOffset + scrollPosition) - - // If we captured an element that should be scrolled to, then we can try to do that if the - // element is still connected (aka, still in the DOM). - if (scrollToElement && scrollToElement.isConnected) { - scrollToElement.scrollIntoView({ block: 'nearest' }) - scrollToElement = null - } - }) - } - - onInvalidate(d.dispose) - }) + useDocumentOverflowLockedEffect(ownerDocument, enabled, (meta) => ({ + containers: [...(meta.containers ?? []), resolveAllowedContainers], + })) // Trigger close when the FocusTrap gets hidden watchEffect((onInvalidate) => { diff --git a/packages/@headlessui-vue/src/hooks/document-overflow/adjust-scrollbar-padding.ts b/packages/@headlessui-vue/src/hooks/document-overflow/adjust-scrollbar-padding.ts new file mode 100644 index 0000000..8abd827 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/document-overflow/adjust-scrollbar-padding.ts @@ -0,0 +1,25 @@ +import { ScrollLockStep } from './overflow-store' + +export function adjustScrollbarPadding(): ScrollLockStep { + let scrollbarWidthBefore: number + + return { + before({ doc }) { + let documentElement = doc.documentElement + let ownerWindow = doc.defaultView ?? window + + scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth + }, + + after({ doc, d }) { + let documentElement = doc.documentElement + + // Account for the change in scrollbar width + // NOTE: This is a bit of a hack, but it's the only way to do this + let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth + let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter + + d.style(documentElement, 'paddingRight', `${scrollbarWidth}px`) + }, + } +} diff --git a/packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts b/packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts new file mode 100644 index 0000000..71fb049 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/document-overflow/handle-ios-locking.ts @@ -0,0 +1,98 @@ +import { isIOS } from '../../utils/platform' +import { ScrollLockStep } from './overflow-store' + +interface ContainerMetadata { + containers: (() => HTMLElement[])[] +} + +export function handleIOSLocking(): ScrollLockStep { + if (!isIOS()) { + return {} + } + + let scrollPosition: number + + return { + before() { + scrollPosition = window.pageYOffset + }, + + after({ doc, d, meta }) { + function inAllowedContainer(el: HTMLElement) { + return meta.containers + .flatMap((resolve) => resolve()) + .some((container) => container.contains(el)) + } + + d.style(doc.body, 'marginTop', `-${scrollPosition}px`) + window.scrollTo(0, 0) + + // Relatively hacky, but if you click a link like `` in the Dialog, and there + // exists an element on the page (outside of the Dialog) with that id, then the browser will + // scroll to that position. However, this is not the case if the element we want to scroll to + // is higher and the browser needs to scroll up, but it doesn't do that. + // + // Let's try and capture that element and store it, so that we can later scroll to it once the + // Dialog closes. + let scrollToElement: HTMLElement | null = null + d.addEventListener( + doc, + 'click', + (e) => { + if (!(e.target instanceof HTMLElement)) { + return + } + + try { + let anchor = e.target.closest('a') + if (!anchor) return + let { hash } = new URL(anchor.href) + let el = doc.querySelector(hash) + if (el && !inAllowedContainer(el as HTMLElement)) { + scrollToElement = el as HTMLElement + } + } catch (err) {} + }, + true + ) + + d.addEventListener( + doc, + 'touchmove', + (e) => { + // Check if we are scrolling inside any of the allowed containers, if not let's cancel the event! + if (e.target instanceof HTMLElement && !inAllowedContainer(e.target as HTMLElement)) { + e.preventDefault() + } + }, + { passive: false } + ) + + // Restore scroll position + d.add(() => { + // Before opening the Dialog, we capture the current pageYOffset, and offset the page with + // this value so that we can also scroll to `(0, 0)`. + // + // If we want to restore a few things can happen: + // + // 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely + // restore to the captured value earlier. + // 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a + // link was scrolled into view in the background). Ideally we want to restore to this _new_ + // position. To do this, we can take the new value into account with the captured value from + // before. + // + // (Since the value of window.pageYOffset is 0 in the first case, we should be able to + // always sum these values) + window.scrollTo(0, window.pageYOffset + scrollPosition) + + // If we captured an element that should be scrolled to, then we can try to do that if the + // element is still connected (aka, still in the DOM). + if (scrollToElement && scrollToElement.isConnected) { + scrollToElement.scrollIntoView({ block: 'nearest' }) + scrollToElement = null + } + }) + }, + } +} diff --git a/packages/@headlessui-vue/src/hooks/document-overflow/overflow-store.ts b/packages/@headlessui-vue/src/hooks/document-overflow/overflow-store.ts new file mode 100644 index 0000000..70604f6 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/document-overflow/overflow-store.ts @@ -0,0 +1,118 @@ +import { disposables, Disposables } from '../../utils/disposables' +import { createStore } from '../../utils/store' +import { adjustScrollbarPadding } from './adjust-scrollbar-padding' +import { handleIOSLocking } from './handle-ios-locking' +import { preventScroll } from './prevent-scroll' + +interface DocEntry { + doc: Document + count: number + d: Disposables + meta: Set +} + +function buildMeta(fns: Iterable) { + let tmp = {} + for (let fn of fns) { + Object.assign(tmp, fn(tmp)) + } + return tmp +} + +export type MetaFn = (meta: Record) => Record + +export interface Context = any> { + doc: Document + d: Disposables + meta: MetaType +} + +export interface ScrollLockStep = any> { + before?(ctx: Context): void + after?(ctx: Context): void +} + +export let overflows = createStore(() => new Map(), { + PUSH(doc: Document, meta: MetaFn) { + let entry = this.get(doc) ?? { + doc, + count: 0, + d: disposables(), + meta: new Set(), + } + + entry.count++ + entry.meta.add(meta) + this.set(doc, entry) + + return this + }, + + POP(doc: Document, meta: MetaFn) { + let entry = this.get(doc) + if (entry) { + entry.count-- + entry.meta.delete(meta) + } + + return this + }, + + SCROLL_PREVENT({ doc, d, meta }: DocEntry) { + let ctx = { + doc, + d, + meta: buildMeta(meta), + } + + let steps: ScrollLockStep[] = [ + handleIOSLocking(), + adjustScrollbarPadding(), + preventScroll(), + ] + + // Run all `before` actions together + steps.forEach(({ before }) => before?.(ctx)) + + // Run all `after` actions together + steps.forEach(({ after }) => after?.(ctx)) + }, + + SCROLL_ALLOW({ d }: DocEntry) { + d.dispose() + }, + + TEARDOWN({ doc }: DocEntry) { + this.delete(doc) + }, +}) + +// Update the document overflow state when the store changes +// This MUST happen outside of react for this to work properly. +overflows.subscribe(() => { + let docs = overflows.getSnapshot() + + let styles = new Map() + + // Read data from all the documents + for (let [doc] of docs) { + styles.set(doc, doc.documentElement.style.overflow) + } + + // Write data to all the documents + for (let entry of docs.values()) { + let isHidden = styles.get(entry.doc) === 'hidden' + let isLocked = entry.count !== 0 + let willChange = (isLocked && !isHidden) || (!isLocked && isHidden) + + if (willChange) { + overflows.dispatch(entry.count > 0 ? 'SCROLL_PREVENT' : 'SCROLL_ALLOW', entry) + } + + // We have to clean up after ourselves so we don't leak memory + // Using a WeakMap would be ideal, but it's not iterable + if (entry.count === 0) { + overflows.dispatch('TEARDOWN', entry) + } + } +}) diff --git a/packages/@headlessui-vue/src/hooks/document-overflow/prevent-scroll.ts b/packages/@headlessui-vue/src/hooks/document-overflow/prevent-scroll.ts new file mode 100644 index 0000000..09355a6 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/document-overflow/prevent-scroll.ts @@ -0,0 +1,9 @@ +import { ScrollLockStep } from './overflow-store' + +export function preventScroll(): ScrollLockStep { + return { + before({ doc, d }) { + d.style(doc.documentElement, 'overflow', 'hidden') + }, + } +} diff --git a/packages/@headlessui-vue/src/hooks/document-overflow/use-document-overflow.ts b/packages/@headlessui-vue/src/hooks/document-overflow/use-document-overflow.ts new file mode 100644 index 0000000..f0408d5 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/document-overflow/use-document-overflow.ts @@ -0,0 +1,43 @@ +import { useStore } from '../../hooks/use-store' +import { overflows, MetaFn } from './overflow-store' +import { computed, Ref, watch } from 'vue' + +export function useDocumentOverflowLockedEffect( + doc: Ref, + shouldBeLocked: Ref, + meta: MetaFn +) { + let store = useStore(overflows) + let locked = computed(() => { + let entry = doc.value ? store.value.get(doc.value) : undefined + return entry ? entry.count > 0 : false + }) + + watch( + [doc, shouldBeLocked], + ([doc, shouldBeLocked], [oldDoc], onInvalidate) => { + if (!doc || !shouldBeLocked) { + return + } + + // Prevent the document from scrolling + overflows.dispatch('PUSH', doc, meta) + + // Allow document to scroll + let didRunCleanup = false + onInvalidate(() => { + if (didRunCleanup) return + overflows.dispatch('POP', oldDoc ?? doc, meta) + + // This shouldn't be necessary, but it is. + // Seems like a Vue bug. + didRunCleanup = true + }) + }, + { + immediate: true, + } + ) + + return locked +} diff --git a/packages/@headlessui-vue/src/hooks/use-store.ts b/packages/@headlessui-vue/src/hooks/use-store.ts new file mode 100644 index 0000000..ae2bc29 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-store.ts @@ -0,0 +1,14 @@ +import { onUnmounted, shallowRef } from 'vue' +import { Store } from '../utils/store' + +export function useStore(store: Store) { + let state = shallowRef(store.getSnapshot()) + + onUnmounted( + store.subscribe(() => { + state.value = store.getSnapshot() + }) + ) + + return state +} diff --git a/packages/@headlessui-vue/src/utils/disposables.ts b/packages/@headlessui-vue/src/utils/disposables.ts index 70667ce..4fbc5ae 100644 --- a/packages/@headlessui-vue/src/utils/disposables.ts +++ b/packages/@headlessui-vue/src/utils/disposables.ts @@ -1,3 +1,5 @@ +export type Disposables = ReturnType + export function disposables() { let disposables: Function[] = [] let queue: Function[] = [] @@ -37,6 +39,14 @@ export function disposables() { disposables.push(cb) }, + style(node: HTMLElement, property: string, value: string) { + let previous = node.style.getPropertyValue(property) + Object.assign(node.style, { [property]: value }) + return this.add(() => { + Object.assign(node.style, { [property]: previous }) + }) + }, + dispose() { for (let dispose of disposables.splice(0)) { dispose() diff --git a/packages/@headlessui-vue/src/utils/pipeline.ts b/packages/@headlessui-vue/src/utils/pipeline.ts new file mode 100644 index 0000000..ab56592 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/pipeline.ts @@ -0,0 +1,20 @@ +export interface Middleware { + (request: ReqType, next: (req: ReqType) => void): void +} + +export function pipeline(handlers: Middleware[]) { + return (request: ReqType, andThen?: (req: ReqType) => void) => { + let index = 0 + + function next() { + let handler = handlers[index++] + if (handler) { + handler(request, next) + } else if (andThen) { + andThen(request) + } + } + + next() + } +} diff --git a/packages/@headlessui-vue/src/utils/store.ts b/packages/@headlessui-vue/src/utils/store.ts new file mode 100644 index 0000000..1521bf7 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/store.ts @@ -0,0 +1,39 @@ +type ChangeFn = () => void +type UnsubscribeFn = () => void +type ActionFn = (this: T, ...args: any[]) => T | void +type StoreActions = Record> + +export interface Store { + getSnapshot(): T + subscribe(onChange: ChangeFn): UnsubscribeFn + dispatch(action: ActionKey, ...args: any[]): void +} + +export function createStore( + initial: () => T, + actions: StoreActions +): Store { + let state: T = initial() + + let listeners = new Set() + + return { + getSnapshot() { + return state + }, + + subscribe(onChange) { + listeners.add(onChange) + + return () => listeners.delete(onChange) + }, + + dispatch(key: ActionKey, ...args: any[]) { + let newState = actions[key].call(state, ...args) + if (newState) { + state = newState + listeners.forEach((listener) => listener()) + } + }, + } +} diff --git a/packages/playground-react/pages/dialog/dialog-scroll-issue.tsx b/packages/playground-react/pages/dialog/dialog-scroll-issue.tsx new file mode 100644 index 0000000..b789857 --- /dev/null +++ b/packages/playground-react/pages/dialog/dialog-scroll-issue.tsx @@ -0,0 +1,74 @@ +import { Fragment, useState } from 'react' +import { Dialog, Transition } from '@headlessui/react' + +function MyDialog({ open, close }) { + return ( + <> + + + + + + + + + ) +} + +export default function App() { + let [isOpen, setIsOpen] = useState(false) + + return ( +
+ + setIsOpen(false)} /> +
+ +
+ +
+ +
+ + Hello from Foo! + +
+ +
+ +
+ +
+
+ ) +} diff --git a/packages/playground-react/pages/dialog/dialog-with-shadow-children.tsx b/packages/playground-react/pages/dialog/dialog-with-shadow-children.tsx new file mode 100644 index 0000000..61261c5 --- /dev/null +++ b/packages/playground-react/pages/dialog/dialog-with-shadow-children.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'react' +import { useState } from 'react' +import { Dialog } from '@headlessui/react' + +if (typeof document !== 'undefined') { + class MyCustomElement extends HTMLElement { + shadow: ShadowRoot + + constructor() { + super() + this.shadow = this.attachShadow({ mode: 'closed' }) + } + + connectedCallback() { + let button = document.createElement('button') + button.textContent = 'Inside shadow root (closed)' + this.shadow.appendChild(button) + } + } + + customElements.define('my-custom-element', MyCustomElement) +} + +function ShadowChildren({ id }: { id: string }) { + let container = useRef(null) + + useEffect(() => { + if (!container.current || container.current.shadowRoot) { + return + } + + let shadowRoot = container.current.attachShadow({ mode: 'open' }) + let button = document.createElement('button') + button.id = id + button.style.display = 'block' + button.textContent = 'Inside shadow root (open)' + + let mce = document.createElement('my-custom-element') + + shadowRoot.appendChild(button) + shadowRoot.appendChild(mce) + }, []) + + return
+} + +export default function App() { + const [open, setOpen] = useState(false) + + return ( +
+ + setOpen(false)}> +
+
+ + +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/scripts/build.sh b/scripts/build.sh index 7e20bc8..ca13445 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -28,12 +28,12 @@ resolverOptions+=('/**/*.{ts,tsx}') resolverOptions+=('--ignore=.test.,__mocks__') INPUT_FILES=$($resolver ${resolverOptions[@]}) -NODE_ENV=production $esbuild $INPUT_FILES --format=esm --outdir=$DST --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} & -NODE_ENV=production $esbuild $input --format=esm --outfile=$DST/$name.esm.js --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} & +NODE_ENV=production $esbuild $INPUT_FILES --format=esm --outdir=$DST --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} & +NODE_ENV=production $esbuild $input --format=esm --outfile=$DST/$name.esm.js --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} & # Common JS -NODE_ENV=production $esbuild $input --format=cjs --outfile=$DST/$name.prod.cjs --minify --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} $@ & -NODE_ENV=development $esbuild $input --format=cjs --outfile=$DST/$name.dev.cjs --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} $@ & +NODE_ENV=production $esbuild $input --format=cjs --outfile=$DST/$name.prod.cjs --minify --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} $@ & +NODE_ENV=development $esbuild $input --format=cjs --outfile=$DST/$name.dev.cjs --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="true" ${sharedOptions[@]} $@ & # Generate types tsc --emitDeclarationOnly --outDir $DST &