Don't break overflow when multiple dialogs are open at the same time (#2215)
* Fix overflow when swapping dialogs that use transition * Refactor * refactor * wip * wip * wip * wip * wip * wip * wip * wip * Inline shim for ESM support Until the official package adds an ESM version with a wildcard import we can’t use it. This version was copied from Remix Router * Add dialog shadow root examples * Fix SSR error * Add repro for iOS scrolling issue * Try to fix vercel build idk what’s wrong here * Update repro A transition is required to delay closing enough to demonstrate the bug * Port global dialog state to Vue * Add dialog test to Vue * wip * wip * Workaround bug This shouldn’t happen at all and we need to find the source of the bug but this should “fix” things for the time being * wip * Rebuild overflow locking with simpler API * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update deps * wip * simplify * Port to Vue * wip * wip * Tweak tests * Update changelog * Ensure meta callbacks are cleaned up * cleanup * wip
This commit is contained in:
@@ -8,6 +8,9 @@ module.exports = function createJestConfig(root, options) {
|
||||
'^.+\\.(t|j)sx?$': '@swc/jest',
|
||||
...transform,
|
||||
},
|
||||
globals: {
|
||||
__DEV__: true,
|
||||
},
|
||||
},
|
||||
rest
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
return frames(1)
|
||||
}
|
||||
|
||||
async function frames(count: number) {
|
||||
for (let n = 0; n <= count; n++) {
|
||||
await new Promise<void>((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<string[]>([])
|
||||
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 (
|
||||
<>
|
||||
<DialogWrapper id="d1" dialogs={dialogs} toggle={toggle} />
|
||||
<DialogWrapper id="d2" dialogs={dialogs} toggle={toggle} />
|
||||
<DialogWrapper id="d3" dialogs={dialogs} toggle={toggle} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogWrapper({
|
||||
id,
|
||||
dialogs,
|
||||
toggle,
|
||||
}: {
|
||||
id: string
|
||||
dialogs: string[]
|
||||
toggle: (id: string, state: 'open' | 'close') => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<button id={`open_${id}`} onClick={() => toggle(id, 'open')}>
|
||||
Open {id}
|
||||
</button>
|
||||
<Transition as={Fragment} show={dialogs.includes(id)}>
|
||||
<Dialog onClose={() => toggle(id, 'close')}>
|
||||
<button id={`close_${id}`} onClick={() => toggle(id, 'close')}>
|
||||
Close {id}
|
||||
</button>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<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('Dialog.Overlay', () => {
|
||||
|
||||
@@ -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 `<a href="#foo">` 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) {
|
||||
|
||||
@@ -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`)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { isIOS } from '../../utils/platform'
|
||||
import { ScrollLockStep } from './overflow-store'
|
||||
|
||||
interface ContainerMetadata {
|
||||
containers: (() => HTMLElement[])[]
|
||||
}
|
||||
|
||||
export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
|
||||
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 `<a href="#foo">` 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
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<MetaFn>
|
||||
}
|
||||
|
||||
function buildMeta(fns: Iterable<MetaFn>) {
|
||||
let tmp = {}
|
||||
for (let fn of fns) {
|
||||
Object.assign(tmp, fn(tmp))
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
export type MetaFn = (meta: Record<string, any>) => Record<string, any>
|
||||
|
||||
export interface Context<MetaType extends Record<string, any> = any> {
|
||||
doc: Document
|
||||
d: Disposables
|
||||
meta: MetaType
|
||||
}
|
||||
|
||||
export interface ScrollLockStep<MetaType extends Record<string, any> = any> {
|
||||
before?(ctx: Context<MetaType>): void
|
||||
after?(ctx: Context<MetaType>): void
|
||||
}
|
||||
|
||||
export let overflows = createStore(() => new Map<Document, DocEntry>(), {
|
||||
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<any>[] = [
|
||||
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<Document, string | undefined>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ScrollLockStep } from './overflow-store'
|
||||
|
||||
export function preventScroll(): ScrollLockStep {
|
||||
return {
|
||||
before({ doc, d }) {
|
||||
d.style(doc.documentElement, 'overflow', 'hidden')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>) => Record<string, any>
|
||||
) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useSyncExternalStore } from '../use-sync-external-store-shim/index'
|
||||
import { Store } from '../utils/store'
|
||||
|
||||
export function useStore<T>(store: Store<T, any>) {
|
||||
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
||||
}
|
||||
@@ -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 = <T>(
|
||||
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
|
||||
+152
@@ -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<T>(
|
||||
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
|
||||
}
|
||||
}
|
||||
+20
@@ -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<T>(
|
||||
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()
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { microTask } from './micro-task'
|
||||
|
||||
export type Disposables = ReturnType<typeof disposables>
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
type ChangeFn = () => void
|
||||
type UnsubscribeFn = () => void
|
||||
type ActionFn<T> = (this: T, ...args: any[]) => T | void
|
||||
type StoreActions<Key extends string, T> = Record<Key, ActionFn<T>>
|
||||
|
||||
export interface Store<T, ActionKey extends string> {
|
||||
getSnapshot(): T
|
||||
subscribe(onChange: ChangeFn): UnsubscribeFn
|
||||
dispatch(action: ActionKey, ...args: any[]): void
|
||||
}
|
||||
|
||||
export function createStore<T, ActionKey extends string>(
|
||||
initial: () => T,
|
||||
actions: StoreActions<ActionKey, T>
|
||||
): Store<T, ActionKey> {
|
||||
let state: T = initial()
|
||||
|
||||
let listeners = new Set<ChangeFn>()
|
||||
|
||||
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())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
return frames(1)
|
||||
}
|
||||
|
||||
async function frames(count: number) {
|
||||
for (let n = 0; n <= count; n++) {
|
||||
await new Promise<void>((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<string[]>,
|
||||
toggle: Function as PropType<(id: string, state: string) => void>,
|
||||
},
|
||||
template: `
|
||||
<button :id="id_open" @click="toggle(id, 'open')">
|
||||
Open {{ id }}
|
||||
</button>
|
||||
<TransitionRoot as="template" :show="dialogs.includes(id)">
|
||||
<Dialog @close="toggle(id, 'close')" :data-debug="id">
|
||||
<button :id="id_close" @click="toggle(id, 'close')">
|
||||
Close {{ id }}
|
||||
</button>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
`,
|
||||
|
||||
setup(props) {
|
||||
return {
|
||||
id_open: computed(() => `open_${props.id}`),
|
||||
id_close: computed(() => `close_${props.id}`),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let Example = defineComponent({
|
||||
components: { DialogWrapper },
|
||||
template: `
|
||||
<DialogWrapper id="d1" :dialogs="dialogs" :toggle="toggle" />
|
||||
<DialogWrapper id="d2" :dialogs="dialogs" :toggle="toggle" />
|
||||
<DialogWrapper id="d3" :dialogs="dialogs" :toggle="toggle" />
|
||||
`,
|
||||
|
||||
setup() {
|
||||
let dialogs = ref<string[]>([])
|
||||
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', () => {
|
||||
|
||||
@@ -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 `<a href="#foo">` 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) => {
|
||||
|
||||
@@ -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`)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { isIOS } from '../../utils/platform'
|
||||
import { ScrollLockStep } from './overflow-store'
|
||||
|
||||
interface ContainerMetadata {
|
||||
containers: (() => HTMLElement[])[]
|
||||
}
|
||||
|
||||
export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
|
||||
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 `<a href="#foo">` 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
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<MetaFn>
|
||||
}
|
||||
|
||||
function buildMeta(fns: Iterable<MetaFn>) {
|
||||
let tmp = {}
|
||||
for (let fn of fns) {
|
||||
Object.assign(tmp, fn(tmp))
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
export type MetaFn = (meta: Record<string, any>) => Record<string, any>
|
||||
|
||||
export interface Context<MetaType extends Record<string, any> = any> {
|
||||
doc: Document
|
||||
d: Disposables
|
||||
meta: MetaType
|
||||
}
|
||||
|
||||
export interface ScrollLockStep<MetaType extends Record<string, any> = any> {
|
||||
before?(ctx: Context<MetaType>): void
|
||||
after?(ctx: Context<MetaType>): void
|
||||
}
|
||||
|
||||
export let overflows = createStore(() => new Map<Document, DocEntry>(), {
|
||||
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<any>[] = [
|
||||
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<Document, string | undefined>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ScrollLockStep } from './overflow-store'
|
||||
|
||||
export function preventScroll(): ScrollLockStep {
|
||||
return {
|
||||
before({ doc, d }) {
|
||||
d.style(doc.documentElement, 'overflow', 'hidden')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<Document | null>,
|
||||
shouldBeLocked: Ref<boolean>,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { onUnmounted, shallowRef } from 'vue'
|
||||
import { Store } from '../utils/store'
|
||||
|
||||
export function useStore<T>(store: Store<T, any>) {
|
||||
let state = shallowRef(store.getSnapshot())
|
||||
|
||||
onUnmounted(
|
||||
store.subscribe(() => {
|
||||
state.value = store.getSnapshot()
|
||||
})
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export type Disposables = ReturnType<typeof disposables>
|
||||
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface Middleware<ReqType> {
|
||||
(request: ReqType, next: (req: ReqType) => void): void
|
||||
}
|
||||
|
||||
export function pipeline<ReqType>(handlers: Middleware<ReqType>[]) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
type ChangeFn = () => void
|
||||
type UnsubscribeFn = () => void
|
||||
type ActionFn<T> = (this: T, ...args: any[]) => T | void
|
||||
type StoreActions<Key extends string, T> = Record<Key, ActionFn<T>>
|
||||
|
||||
export interface Store<T, ActionKey extends string> {
|
||||
getSnapshot(): T
|
||||
subscribe(onChange: ChangeFn): UnsubscribeFn
|
||||
dispatch(action: ActionKey, ...args: any[]): void
|
||||
}
|
||||
|
||||
export function createStore<T, ActionKey extends string>(
|
||||
initial: () => T,
|
||||
actions: StoreActions<ActionKey, T>
|
||||
): Store<T, ActionKey> {
|
||||
let state: T = initial()
|
||||
|
||||
let listeners = new Set<ChangeFn>()
|
||||
|
||||
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())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
|
||||
function MyDialog({ open, close }) {
|
||||
return (
|
||||
<>
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog onClose={close} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition duration-500 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition duration-500 ease-out"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed left-0 top-0 bottom-0 flex items-center justify-center bg-red-500 p-4">
|
||||
<Dialog.Panel className="mx-auto w-48 rounded bg-white p-4">
|
||||
<p className="my-2">Gray area should be scrollable</p>
|
||||
|
||||
<p className="h-32 overflow-y-scroll border bg-gray-100">
|
||||
Are you sure you want to deactivate your account? All of your data will be
|
||||
permanently removed. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<p>Colored area on the right should not be scrollable</p>
|
||||
|
||||
<a
|
||||
href="#foo"
|
||||
onClick={() => {
|
||||
setTimeout(() => {
|
||||
close()
|
||||
}, 2000)
|
||||
}}
|
||||
>
|
||||
Click me to close dialog and scroll to Foo
|
||||
</a>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
let [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
|
||||
<MyDialog open={isOpen} close={() => setIsOpen(false)} />
|
||||
<div className="h-[50vh]" />
|
||||
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
|
||||
<div className="h-[50vh]" />
|
||||
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
|
||||
<div className="h-[50vh]" />
|
||||
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
|
||||
<div className="h-[50vh]" />
|
||||
<a id="foo" className="block w-full bg-pink-500 p-12">
|
||||
Hello from Foo!
|
||||
</a>
|
||||
<div className="h-[50vh]" />
|
||||
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
|
||||
<div className="h-[50vh]" />
|
||||
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
|
||||
<div className="h-[50vh]" />
|
||||
<button onClick={() => setIsOpen((v) => !v)}>Toggle dialog</button>
|
||||
<div className="h-[50vh]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>(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 <div ref={container}></div>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="m-4 rounded border-0 bg-gray-500 px-3 py-1 font-medium text-white hover:bg-gray-600"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
open
|
||||
</button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<div className="fixed inset-0 z-50 bg-gray-900/75 backdrop-blur-lg">
|
||||
<div>
|
||||
<button
|
||||
className="m-4 rounded border-0 bg-gray-500 px-3 py-1 font-medium text-white hover:bg-gray-600"
|
||||
id="btn_outside_light"
|
||||
>
|
||||
Outside shadow root
|
||||
</button>
|
||||
<ShadowChildren id="btn_outside_shadow" />
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Panel className="fixed top-16 left-16 z-50 h-64 w-64 rounded-lg border border-black/10 bg-white bg-clip-padding p-12 shadow-lg">
|
||||
<div>
|
||||
<button
|
||||
className="m-4 rounded border-0 bg-gray-500 px-3 py-1 font-medium text-white hover:bg-gray-600"
|
||||
id="btn_inside_light"
|
||||
>
|
||||
Outside shadow root
|
||||
</button>
|
||||
<ShadowChildren id="btn_inside_shadow" />
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -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 &
|
||||
|
||||
Reference in New Issue
Block a user