diff --git a/.changeset/honest-dolphins-reflect.md b/.changeset/honest-dolphins-reflect.md new file mode 100644 index 0000000000..6fb41f17a7 --- /dev/null +++ b/.changeset/honest-dolphins-reflect.md @@ -0,0 +1,90 @@ +--- +"@heroui/use-intersection-observer": patch +"@heroui/use-data-scroll-overflow": patch +"@heroui/use-aria-accordion-item": patch +"@heroui/use-aria-modal-overlay": patch +"@heroui/use-safe-layout-effect": patch +"@heroui/use-aria-multiselect": patch +"@heroui/use-infinite-scroll": patch +"@heroui/use-scroll-position": patch +"@heroui/react-rsc-utils": patch +"@heroui/scroll-shadow": patch +"@heroui/use-aria-accordion": patch +"@heroui/autocomplete": patch +"@heroui/number-input": patch +"@heroui/use-update-effect": patch +"@heroui/dom-animation": patch +"@heroui/stories-utils": patch +"@heroui/breadcrumbs": patch +"@heroui/date-picker": patch +"@heroui/use-aria-overlay": patch +"@heroui/use-callback-ref": patch +"@heroui/framer-utils": patch +"@heroui/shared-icons": patch +"@heroui/shared-utils": patch +"@heroui/date-input": patch +"@heroui/pagination": patch +"@heroui/use-aria-button": patch +"@heroui/react-utils": patch +"@heroui/accordion": patch +"@heroui/input-otp": patch +"@heroui/use-disclosure": patch +"@heroui/use-is-mounted": patch +"@heroui/use-pagination": patch +"@heroui/use-real-shape": patch +"@heroui/aria-utils": patch +"@heroui/test-utils": patch +"@heroui/calendar": patch +"@heroui/checkbox": patch +"@heroui/dropdown": patch +"@heroui/progress": patch +"@heroui/skeleton": patch +"@heroui/use-aria-link": patch +"@heroui/use-clipboard": patch +"@heroui/use-draggable": patch +"@heroui/use-is-mobile": patch +"@heroui/use-ref-state": patch +"@heroui/divider": patch +"@heroui/listbox": patch +"@heroui/popover": patch +"@heroui/snippet": patch +"@heroui/spinner": patch +"@heroui/tooltip": patch +"@heroui/avatar": patch +"@heroui/button": patch +"@heroui/drawer": patch +"@heroui/navbar": patch +"@heroui/ripple": patch +"@heroui/select": patch +"@heroui/slider": patch +"@heroui/spacer": patch +"@heroui/switch": patch +"@heroui/use-measure": patch +"@heroui/alert": patch +"@heroui/badge": patch +"@heroui/image": patch +"@heroui/input": patch +"@heroui/modal": patch +"@heroui/radio": patch +"@heroui/table": patch +"@heroui/toast": patch +"@heroui/use-resize": patch +"@heroui/card": patch +"@heroui/chip": patch +"@heroui/code": patch +"@heroui/form": patch +"@heroui/link": patch +"@heroui/menu": patch +"@heroui/tabs": patch +"@heroui/user": patch +"@heroui/system-rsc": patch +"@heroui/use-image": patch +"@heroui/use-theme": patch +"@heroui/kbd": patch +"@heroui/use-ssr": patch +"@heroui/system": patch +"@heroui/react": patch +"@heroui/theme": patch +--- + +remove RA dependencies (overlays & utils) diff --git a/packages/core/system/package.json b/packages/core/system/package.json index 267ec8de0b..2747ce0607 100644 --- a/packages/core/system/package.json +++ b/packages/core/system/package.json @@ -56,8 +56,6 @@ "dependencies": { "@heroui/react-utils": "workspace:*", "@heroui/system-rsc": "workspace:*", - "@react-aria/i18n": "3.12.10", - "@react-aria/overlays": "3.27.3", - "@react-aria/utils": "3.29.1" + "@react-aria/i18n": "3.12.10" } } \ No newline at end of file diff --git a/packages/core/system/src/ext/overlay-provider.tsx b/packages/core/system/src/ext/overlay-provider.tsx new file mode 100644 index 0000000000..850d7a56e7 --- /dev/null +++ b/packages/core/system/src/ext/overlay-provider.tsx @@ -0,0 +1,114 @@ +// Partial code from react-spectrum to avoid importing the entire package +// ref: packages/@react-aria/overlays/src/useModal.tsx + +import type { + AriaAttributes, + AriaRole, + CSSProperties, + DOMAttributes as ReactDOMAttributes, + JSX, + ReactNode, +} from "react"; + +import React, {useContext, useMemo, useState} from "react"; + +interface FocusableElement extends Element, HTMLOrSVGElement {} + +interface DOMAttributes extends AriaAttributes, ReactDOMAttributes { + id?: string | undefined; + role?: AriaRole | undefined; + tabIndex?: number | undefined; + style?: CSSProperties | undefined; + className?: string | undefined; +} + +interface ModalProviderAria { + modalProviderProps: AriaAttributes; +} + +interface ModalContext { + parent: ModalContext | null; + modalCount: number; + addModal: () => void; + removeModal: () => void; +} + +export interface ModalProviderProps extends DOMAttributes { + children: ReactNode; +} + +const Context = React.createContext(null); + +/** + * Each ModalProvider tracks how many modals are open in its subtree. On mount, the modals + * trigger `addModal` to increment the count, and trigger `removeModal` on unmount to decrement it. + * This is done recursively so that all parent providers are incremented and decremented. + * If the modal count is greater than zero, we add `aria-hidden` to this provider to hide its + * subtree from screen readers. This is done using React context in order to account for things + * like portals, which can cause the React tree and the DOM tree to differ significantly in structure. + */ +function ModalProvider(props: ModalProviderProps): JSX.Element { + let {children} = props; + let parent = useContext(Context); + let [modalCount, setModalCount] = useState(0); + let context = useMemo( + () => ({ + parent, + modalCount, + addModal() { + setModalCount((count) => count + 1); + if (parent) { + parent.addModal(); + } + }, + removeModal() { + setModalCount((count) => count - 1); + if (parent) { + parent.removeModal(); + } + }, + }), + [parent, modalCount], + ); + + return {children}; +} + +/** + * Used to determine if the tree should be aria-hidden based on how many + * modals are open. + */ +function useModalProvider(): ModalProviderAria { + let context = useContext(Context); + + return { + modalProviderProps: { + "aria-hidden": context && context.modalCount > 0 ? true : undefined, + }, + }; +} + +/** + * Creates a root node that will be aria-hidden if there are other modals open. + */ +function OverlayContainerDOM(props: ModalProviderProps) { + let {modalProviderProps} = useModalProvider(); + + return
; +} + +/** + * An OverlayProvider acts as a container for the top-level application. + * Any application that uses modal dialogs or other overlays should + * be wrapped in a ``. This is used to ensure that + * the main content of the application is hidden from screen readers + * if a modal or other overlay is opened. Only the top-most modal or + * overlay should be accessible at once. + */ +export function OverlayProvider(props: ModalProviderProps): JSX.Element { + return ( + + + + ); +} diff --git a/packages/core/system/src/ext/router-provider.tsx b/packages/core/system/src/ext/router-provider.tsx new file mode 100644 index 0000000000..d86539ac0d --- /dev/null +++ b/packages/core/system/src/ext/router-provider.tsx @@ -0,0 +1,280 @@ +// Partial code from react-spectrum to avoid importing the entire package +// ref: packages/@react-aria/utils/src/openLink.tsx +// ref: packages/@react-aria/utils/src/focusWithoutScrolling.ts + +import type {Href, RouterOptions, FocusableElement} from "./shared"; +import type {JSX, ReactNode} from "react"; + +import {createContext, useMemo} from "react"; + +interface Modifiers { + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} + +interface Router { + isNative: boolean; + open: ( + target: Element, + modifiers: Modifiers, + href: Href, + routerOptions: RouterOptions | undefined, + ) => void; + useHref: (href: Href) => string; +} + +interface RouterProviderProps { + navigate: (path: Href, routerOptions: RouterOptions | undefined) => void; + useHref?: (href: Href) => string; + children: ReactNode; +} + +// This is a polyfill for element.focus({preventScroll: true}); +// Currently necessary for Safari and old Edge: +// https://caniuse.com/#feat=mdn-api_htmlelement_focus_preventscroll_option +// See https://bugs.webkit.org/show_bug.cgi?id=178583 +// + +// Original licensing for the following methods can be found in the +// NOTICE file in the root directory of this source tree. +// See https://github.com/calvellido/focus-options-polyfill +interface ScrollableElement { + element: HTMLElement; + scrollTop: number; + scrollLeft: number; +} + +const RouterContext = createContext({ + isNative: true, + open: openSyntheticLink, + useHref: (href) => href, +}); + +let supportsPreventScrollCached: boolean | null = null; + +function supportsPreventScroll() { + if (supportsPreventScrollCached == null) { + supportsPreventScrollCached = false; + try { + let focusElem = document.createElement("div"); + + focusElem.focus({ + get preventScroll() { + supportsPreventScrollCached = true; + + return true; + }, + }); + } catch { + // Ignore + } + } + + return supportsPreventScrollCached; +} + +function focusWithoutScrolling(element: FocusableElement): void { + if (supportsPreventScroll()) { + element.focus({preventScroll: true}); + } else { + let scrollableElements = getScrollableElements(element); + + element.focus(); + restoreScrollPosition(scrollableElements); + } +} + +function getScrollableElements(element: FocusableElement): ScrollableElement[] { + let parent = element.parentNode; + let scrollableElements: ScrollableElement[] = []; + let rootScrollingElement = document.scrollingElement || document.documentElement; + + while (parent instanceof HTMLElement && parent !== rootScrollingElement) { + if (parent.offsetHeight < parent.scrollHeight || parent.offsetWidth < parent.scrollWidth) { + scrollableElements.push({ + element: parent, + scrollTop: parent.scrollTop, + scrollLeft: parent.scrollLeft, + }); + } + parent = parent.parentNode; + } + + if (rootScrollingElement instanceof HTMLElement) { + scrollableElements.push({ + element: rootScrollingElement, + scrollTop: rootScrollingElement.scrollTop, + scrollLeft: rootScrollingElement.scrollLeft, + }); + } + + return scrollableElements; +} + +function restoreScrollPosition(scrollableElements: ScrollableElement[]) { + for (let {element, scrollTop, scrollLeft} of scrollableElements) { + element.scrollTop = scrollTop; + element.scrollLeft = scrollLeft; + } +} + +function cached(fn: () => boolean) { + if (process.env.NODE_ENV === "test") { + return fn; + } + + let res: boolean | null = null; + + return () => { + if (res == null) { + res = fn(); + } + + return res; + }; +} + +function testUserAgent(re: RegExp) { + if (typeof window === "undefined" || window.navigator == null) { + return false; + } + + return ( + window.navigator["userAgentData"]?.brands.some((brand: {brand: string; version: string}) => + re.test(brand.brand), + ) || re.test(window.navigator.userAgent) + ); +} + +function testPlatform(re: RegExp) { + return typeof window !== "undefined" && window.navigator != null + ? re.test(window.navigator["userAgentData"]?.platform || window.navigator.platform) + : false; +} + +const isChrome = cached(function () { + return testUserAgent(/Chrome/i); +}); + +const isWebKit = cached(function () { + return testUserAgent(/AppleWebKit/i) && !isChrome(); +}); + +const isMac = cached(function () { + return testPlatform(/^Mac/i); +}); + +const isFirefox = cached(function () { + return testUserAgent(/Firefox/i); +}); + +const isIPad = cached(function () { + return testPlatform(/^iPad/i) || (isMac() && navigator.maxTouchPoints > 1); +}); + +export function RouterProvider(props: RouterProviderProps): JSX.Element { + let {children, navigate, useHref} = props; + + let ctx = useMemo( + () => ({ + isNative: false, + open: ( + target: Element, + modifiers: Modifiers, + href: Href, + routerOptions: RouterOptions | undefined, + ) => { + getSyntheticLink(target, (link) => { + if (shouldClientNavigate(link, modifiers)) { + navigate(href, routerOptions); + } else { + openLink(link, modifiers); + } + }); + }, + useHref: useHref || ((href) => href), + }), + [navigate, useHref], + ); + + return {children}; +} + +function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers): boolean { + let target = link.getAttribute("target"); + + return ( + (!target || target === "_self") && + link.origin === location.origin && + !link.hasAttribute("download") && + !modifiers.metaKey && + !modifiers.ctrlKey && + !modifiers.altKey && + !modifiers.shiftKey + ); +} + +function openLink(target: HTMLAnchorElement, modifiers: Modifiers, setOpening = true): void { + let {metaKey, ctrlKey, altKey, shiftKey} = modifiers; + + if (isFirefox() && window.event?.type?.startsWith("key") && target.target === "_blank") { + if (isMac()) { + metaKey = true; + } else { + ctrlKey = true; + } + } + + let event = + isWebKit() && isMac() && !isIPad() && process.env.NODE_ENV !== "test" + ? // @ts-ignore + new KeyboardEvent("keydown", {keyIdentifier: "Enter", metaKey, ctrlKey, altKey, shiftKey}) + : new MouseEvent("click", { + metaKey, + ctrlKey, + altKey, + shiftKey, + bubbles: true, + cancelable: true, + }); + + (openLink as any).isOpening = setOpening; + focusWithoutScrolling(target); + target.dispatchEvent(event); + (openLink as any).isOpening = false; +} +(openLink as any).isOpening = false; + +function getSyntheticLink(target: Element, open: (link: HTMLAnchorElement) => void) { + if (target instanceof HTMLAnchorElement) { + open(target); + } else if (target.hasAttribute("data-href")) { + let link = document.createElement("a"); + + link.href = target.getAttribute("data-href")!; + if (target.hasAttribute("data-target")) { + link.target = target.getAttribute("data-target")!; + } + if (target.hasAttribute("data-rel")) { + link.rel = target.getAttribute("data-rel")!; + } + if (target.hasAttribute("data-download")) { + link.download = target.getAttribute("data-download")!; + } + if (target.hasAttribute("data-ping")) { + link.ping = target.getAttribute("data-ping")!; + } + if (target.hasAttribute("data-referrer-policy")) { + link.referrerPolicy = target.getAttribute("data-referrer-policy")!; + } + target.appendChild(link); + open(link); + target.removeChild(link); + } +} + +function openSyntheticLink(target: Element, modifiers: Modifiers) { + getSyntheticLink(target, (link) => openLink(link, modifiers)); +} diff --git a/packages/core/system/src/ext/shared.ts b/packages/core/system/src/ext/shared.ts new file mode 100644 index 0000000000..eddef43342 --- /dev/null +++ b/packages/core/system/src/ext/shared.ts @@ -0,0 +1,29 @@ +import type {HTMLAttributeAnchorTarget, HTMLAttributeReferrerPolicy} from "react"; + +export interface RouterConfig {} + +export type Href = RouterConfig extends {href: infer H} ? H : string; + +export type RouterOptions = RouterConfig extends {routerOptions: infer O} ? O : never; + +export interface LinkDOMProps { + /** A URL to link to. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href). */ + href?: Href; + /** Hints at the human language of the linked URL. See[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#hreflang). */ + hrefLang?: string; + /** The target window for the link. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target). */ + target?: HTMLAttributeAnchorTarget; + /** The relationship between the linked resource and the current page. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel). */ + rel?: string; + /** Causes the browser to download the linked URL. A string may be provided to suggest a file name. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download). */ + download?: boolean | string; + /** A space-separated list of URLs to ping when the link is followed. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#ping). */ + ping?: string; + /** How much of the referrer to send when following the link. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#referrerpolicy). */ + referrerPolicy?: HTMLAttributeReferrerPolicy; + /** Options for the configured client side router. */ + routerOptions?: RouterOptions; +} + +/** Any focusable element, including both HTML and SVG elements. */ +export interface FocusableElement extends Element, HTMLOrSVGElement {} diff --git a/packages/core/system/src/provider.tsx b/packages/core/system/src/provider.tsx index 1c9d16734e..2332cd7c3f 100644 --- a/packages/core/system/src/provider.tsx +++ b/packages/core/system/src/provider.tsx @@ -1,15 +1,15 @@ -import type {ModalProviderProps} from "@react-aria/overlays"; -import type {ProviderContextProps} from "./provider-context"; -import type {Href, RouterOptions} from "@react-types/shared"; +import type {ModalProviderProps} from "./ext/overlay-provider"; import type {I18nProviderProps} from "@react-aria/i18n"; +import type {ProviderContextProps} from "./provider-context"; +import type {Href, RouterOptions} from "./ext/shared"; import {I18nProvider} from "@react-aria/i18n"; -import {RouterProvider} from "@react-aria/utils"; -import {OverlayProvider} from "@react-aria/overlays"; import {useMemo} from "react"; import {MotionConfig, MotionGlobalConfig} from "framer-motion"; import {ProviderContext} from "./provider-context"; +import {OverlayProvider} from "./ext/overlay-provider"; +import {RouterProvider} from "./ext/router-provider"; export interface HeroUIProviderProps extends Omit, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7489e4b36..5c53474982 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3250,12 +3250,6 @@ importers: '@react-aria/i18n': specifier: 3.12.10 version: 3.12.10(react-dom@18.3.0(react@18.3.0))(react@18.3.0) - '@react-aria/overlays': - specifier: 3.27.3 - version: 3.27.3(react-dom@18.3.0(react@18.3.0))(react@18.3.0) - '@react-aria/utils': - specifier: 3.29.1 - version: 3.29.1(react-dom@18.3.0(react@18.3.0))(react@18.3.0) devDependencies: clean-package: specifier: 2.2.0