rebuild hrm

This commit is contained in:
2026-04-13 09:30:59 +08:00
parent 44a98495ab
commit cd31c74da8
12034 changed files with 548007 additions and 451275 deletions

View File

@@ -14,10 +14,10 @@ import {flushSync} from 'react-dom';
import {RefObject, useCallback, useState} from 'react';
import {useLayoutEffect} from './useLayoutEffect';
export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: boolean = true) {
export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: boolean = true): boolean {
let [isEntering, setEntering] = useState(true);
let isAnimationReady = isEntering && isReady;
// There are two cases for entry animations:
// 1. CSS @keyframes. The `animation` property is set during the isEntering state, and it is removed after the animation finishes.
// 2. CSS transitions. The initial styles are applied during the isEntering state, and removed immediately, causing the transition to occur.
@@ -38,7 +38,7 @@ export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: b
return isAnimationReady;
}
export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boolean) {
export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boolean): boolean {
let [exitState, setExitState] = useState<'closed' | 'open' | 'exiting'>(isOpen ? 'open' : 'closed');
switch (exitState) {
@@ -71,7 +71,7 @@ export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boo
return isExiting;
}
function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onEnd: () => void) {
function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onEnd: () => void): void {
useLayoutEffect(() => {
if (isActive && ref.current) {
if (!('getAnimations' in ref.current)) {
@@ -79,7 +79,7 @@ function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onE
onEnd();
return;
}
let animations = ref.current.getAnimations();
if (animations.length === 0) {
onEnd();
@@ -94,7 +94,7 @@ function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onE
});
}
}).catch(() => {});
return () => {
canceled = true;
};

View File

@@ -13,4 +13,3 @@
// Custom event names for updating the autocomplete's aria-activedecendant.
export const CLEAR_FOCUS_EVENT = 'react-aria-clear-focus';
export const FOCUS_EVENT = 'react-aria-focus';
export const UPDATE_ACTIVEDESCENDANT = 'react-aria-update-activedescendant';

View File

@@ -3,8 +3,8 @@ export const getOwnerDocument = (el: Element | null | undefined): Document => {
};
export const getOwnerWindow = (
el: (Window & typeof global) | Element | null | undefined
): Window & typeof global => {
el: (Window & typeof globalThis) | Element | null | undefined
): Window & typeof globalThis => {
if (el && 'window' in el && el.window === el) {
return el;
}
@@ -12,3 +12,22 @@ export const getOwnerWindow = (
const doc = getOwnerDocument(el as Element | null | undefined);
return doc.defaultView || window;
};
/**
* Type guard that checks if a value is a Node. Verifies the presence and type of the nodeType property.
*/
function isNode(value: unknown): value is Node {
return value !== null &&
typeof value === 'object' &&
'nodeType' in value &&
typeof (value as Node).nodeType === 'number';
}
/**
* Type guard that checks if a node is a ShadowRoot. Uses nodeType and host property checks to
* distinguish ShadowRoot from other DocumentFragments.
*/
export function isShadowRoot(node: Node | null): node is ShadowRoot {
return isNode(node) &&
node.nodeType === Node.DOCUMENT_FRAGMENT_NODE &&
'host' in node;
}

View File

@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
import {AriaLabelingProps, DOMProps, LinkDOMProps} from '@react-types/shared';
import {AriaLabelingProps, DOMProps, GlobalDOMAttributes, LinkDOMProps} from '@react-types/shared';
const DOMPropNames = new Set([
'id'
@@ -34,6 +34,51 @@ const linkPropNames = new Set([
'referrerPolicy'
]);
const globalAttrs = new Set([
'dir',
'lang',
'hidden',
'inert',
'translate'
]);
const globalEvents = new Set([
'onClick',
'onAuxClick',
'onContextMenu',
'onDoubleClick',
'onMouseDown',
'onMouseEnter',
'onMouseLeave',
'onMouseMove',
'onMouseOut',
'onMouseOver',
'onMouseUp',
'onTouchCancel',
'onTouchEnd',
'onTouchMove',
'onTouchStart',
'onPointerDown',
'onPointerMove',
'onPointerUp',
'onPointerCancel',
'onPointerEnter',
'onPointerLeave',
'onPointerOver',
'onPointerOut',
'onGotPointerCapture',
'onLostPointerCapture',
'onScroll',
'onWheel',
'onAnimationStart',
'onAnimationEnd',
'onAnimationIteration',
'onTransitionCancel',
'onTransitionEnd',
'onTransitionRun',
'onTransitionStart'
]);
interface Options {
/**
* If labelling associated aria properties should be included in the filter.
@@ -41,6 +86,10 @@ interface Options {
labelable?: boolean,
/** Whether the element is a link and should include DOM props for <a> elements. */
isLink?: boolean,
/** Whether to include global DOM attributes. */
global?: boolean,
/** Whether to include DOM events. */
events?: boolean,
/**
* A Set of other property names that should be included in the filter.
*/
@@ -54,8 +103,8 @@ const propRe = /^(data-.*)$/;
* @param props - The component props to be filtered.
* @param opts - Props to override.
*/
export function filterDOMProps(props: DOMProps & AriaLabelingProps & LinkDOMProps, opts: Options = {}): DOMProps & AriaLabelingProps {
let {labelable, isLink, propNames} = opts;
export function filterDOMProps(props: DOMProps & AriaLabelingProps & LinkDOMProps & GlobalDOMAttributes, opts: Options = {}): DOMProps & AriaLabelingProps & GlobalDOMAttributes {
let {labelable, isLink, global, events = global, propNames} = opts;
let filteredProps = {};
for (const prop in props) {
@@ -64,6 +113,8 @@ export function filterDOMProps(props: DOMProps & AriaLabelingProps & LinkDOMProp
DOMPropNames.has(prop) ||
(labelable && labelablePropNames.has(prop)) ||
(isLink && linkPropNames.has(prop)) ||
(global && globalAttrs.has(prop)) ||
(events && (globalEvents.has(prop) || (prop.endsWith('Capture') && globalEvents.has(prop.slice(0, -7))))) ||
propNames?.has(prop) ||
propRe.test(prop)
)

View File

@@ -28,7 +28,7 @@ interface ScrollableElement {
scrollLeft: number
}
export function focusWithoutScrolling(element: FocusableElement) {
export function focusWithoutScrolling(element: FocusableElement): void {
if (supportsPreventScroll()) {
element.focus({preventScroll: true});
} else {

View File

@@ -10,7 +10,9 @@
* governing permissions and limitations under the License.
*/
export function getOffset(element, reverse, orientation = 'horizontal') {
import {Orientation} from '@react-types/shared';
export function getOffset(element: HTMLElement, reverse?: boolean, orientation: Orientation = 'horizontal'): number {
let rect = element.getBoundingClientRect();
if (reverse) {
return orientation === 'horizontal' ? rect.right : rect.bottom;

View File

@@ -11,13 +11,15 @@
*/
export {useId, mergeIds, useSlotId} from './useId';
export {chain} from './chain';
export {getOwnerDocument, getOwnerWindow} from './domHelpers';
export {createShadowTreeWalker, ShadowTreeWalker} from './shadowdom/ShadowTreeWalker';
export {getActiveElement, getEventTarget, nodeContains} from './shadowdom/DOMFunctions';
export {getOwnerDocument, getOwnerWindow, isShadowRoot} from './domHelpers';
export {mergeProps} from './mergeProps';
export {mergeRefs} from './mergeRefs';
export {filterDOMProps} from './filterDOMProps';
export {focusWithoutScrolling} from './focusWithoutScrolling';
export {getOffset} from './getOffset';
export {openLink, getSyntheticLinkProps, useSyntheticLinkProps, RouterProvider, shouldClientNavigate, useRouter, useLinkProps} from './openLink';
export {openLink, getSyntheticLinkProps, useSyntheticLinkProps, RouterProvider, shouldClientNavigate, useRouter, useLinkProps, handleLinkClick} from './openLink';
export {runAfterTransition} from './runAfterTransition';
export {useDrag1D} from './useDrag1D';
export {useGlobalListeners} from './useGlobalListeners';
@@ -43,6 +45,11 @@ export {useEffectEvent} from './useEffectEvent';
export {useDeepMemo} from './useDeepMemo';
export {useFormReset} from './useFormReset';
export {useLoadMore} from './useLoadMore';
export {CLEAR_FOCUS_EVENT, FOCUS_EVENT, UPDATE_ACTIVEDESCENDANT} from './constants';
export {isCtrlKeyPressed} from './keyboard';
export {useLoadMoreSentinel, useLoadMoreSentinel as UNSTABLE_useLoadMoreSentinel} from './useLoadMoreSentinel';
export {inertValue} from './inertValue';
export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants';
export {isCtrlKeyPressed, willOpenKeyboard} from './keyboard';
export {useEnterAnimation, useExitAnimation} from './animation';
export {isFocusable, isTabbable} from './isFocusable';
export type {LoadMoreSentinelProps} from './useLoadMoreSentinel';

11
node_modules/@react-aria/utils/src/inertValue.ts generated vendored Normal file
View File

@@ -0,0 +1,11 @@
import {version} from 'react';
export function inertValue(value?: boolean): string | boolean | undefined {
const pieces = version.split('.');
const major = parseInt(pieces[0], 10);
if (major >= 19) {
return value;
}
// compatibility with React < 19
return value ? 'true' : undefined;
}

75
node_modules/@react-aria/utils/src/isElementVisible.ts generated vendored Normal file
View File

@@ -0,0 +1,75 @@
/*
* Copyright 2021 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {getOwnerWindow} from './domHelpers';
const supportsCheckVisibility = typeof Element !== 'undefined' && 'checkVisibility' in Element.prototype;
function isStyleVisible(element: Element) {
const windowObject = getOwnerWindow(element);
if (!(element instanceof windowObject.HTMLElement) && !(element instanceof windowObject.SVGElement)) {
return false;
}
let {display, visibility} = element.style;
let isVisible = (
display !== 'none' &&
visibility !== 'hidden' &&
visibility !== 'collapse'
);
if (isVisible) {
const {getComputedStyle} = element.ownerDocument.defaultView as unknown as Window;
let {display: computedDisplay, visibility: computedVisibility} = getComputedStyle(element);
isVisible = (
computedDisplay !== 'none' &&
computedVisibility !== 'hidden' &&
computedVisibility !== 'collapse'
);
}
return isVisible;
}
function isAttributeVisible(element: Element, childElement?: Element) {
return (
!element.hasAttribute('hidden') &&
// Ignore HiddenSelect when tree walking.
!element.hasAttribute('data-react-aria-prevent-focus') &&
(element.nodeName === 'DETAILS' &&
childElement &&
childElement.nodeName !== 'SUMMARY'
? element.hasAttribute('open')
: true)
);
}
/**
* Adapted from https://github.com/testing-library/jest-dom and
* https://github.com/vuejs/vue-test-utils-next/.
* Licensed under the MIT License.
* @param element - Element to evaluate for display or visibility.
*/
export function isElementVisible(element: Element, childElement?: Element): boolean {
if (supportsCheckVisibility) {
return element.checkVisibility({visibilityProperty: true}) && !element.closest('[data-react-aria-prevent-focus]');
}
return (
element.nodeName !== '#comment' &&
isStyleVisible(element) &&
isAttributeVisible(element, childElement) &&
(!element.parentElement || isElementVisible(element.parentElement, element))
);
}

56
node_modules/@react-aria/utils/src/isFocusable.ts generated vendored Normal file
View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {isElementVisible} from './isElementVisible';
const focusableElements = [
'input:not([disabled]):not([type=hidden])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'a[href]',
'area[href]',
'summary',
'iframe',
'object',
'embed',
'audio[controls]',
'video[controls]',
'[contenteditable]:not([contenteditable^="false"])',
'permission'
];
const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ',[tabindex]:not([disabled]):not([hidden])';
focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])');
const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),');
export function isFocusable(element: Element): boolean {
return element.matches(FOCUSABLE_ELEMENT_SELECTOR) && isElementVisible(element) && !isInert(element);
}
export function isTabbable(element: Element): boolean {
return element.matches(TABBABLE_ELEMENT_SELECTOR) && isElementVisible(element) && !isInert(element);
}
function isInert(element: Element): boolean {
let node: Element | null = element;
while (node != null) {
if (node instanceof node.ownerDocument.defaultView!.HTMLElement && node.inert) {
return true;
}
node = node.parentElement;
}
return false;
}

View File

@@ -25,7 +25,7 @@ import {isAndroid} from './platform';
export function isVirtualClick(event: MouseEvent | PointerEvent): boolean {
// JAWS/NVDA with Firefox.
if ((event as any).mozInputSource === 0 && event.isTrusted) {
if ((event as PointerEvent).pointerType === '' && event.isTrusted) {
return true;
}
@@ -39,7 +39,7 @@ export function isVirtualClick(event: MouseEvent | PointerEvent): boolean {
return event.detail === 0 && !(event as PointerEvent).pointerType;
}
export function isVirtualPointerEvent(event: PointerEvent) {
export function isVirtualPointerEvent(event: PointerEvent): boolean {
// If the pointer size is zero, then we assume it's from a screen reader.
// Android TalkBack double tap will sometimes return a event with width and height of 1
// and pointerType === 'mouse' so we need to check for a specific combination of event attributes.

View File

@@ -18,10 +18,31 @@ interface Event {
metaKey: boolean
}
export function isCtrlKeyPressed(e: Event) {
export function isCtrlKeyPressed(e: Event): boolean {
if (isMac()) {
return e.metaKey;
}
return e.ctrlKey;
}
// HTML input types that do not cause the software keyboard to appear.
const nonTextInputTypes = new Set([
'checkbox',
'radio',
'range',
'color',
'file',
'image',
'button',
'submit',
'reset'
]);
export function willOpenKeyboard(target: Element) {
return (
(target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type)) ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
}

View File

@@ -13,6 +13,7 @@
import {chain} from './chain';
import clsx from 'clsx';
import {mergeIds} from './useId';
import {mergeRefs} from './mergeRefs';
interface Props {
[key: string]: any
@@ -23,13 +24,12 @@ type PropsArg = Props | null | undefined;
// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379
type TupleTypes<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V } ? NullToObject<V> : never;
type NullToObject<T> = T extends (null | undefined) ? {} : T;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
/**
* Merges multiple props objects together. Event handlers are chained,
* classNames are combined, and ids are deduplicated - different ids
* will trigger a side-effect and re-render components hooked up with `useId`.
* classNames are combined, ids are deduplicated, and refs are merged.
* For all other props, the last prop object overrides all previous ones.
* @param args - Multiple sets of props to merge together.
*/
@@ -64,6 +64,8 @@ export function mergeProps<T extends PropsArg[]>(...args: T): UnionToIntersectio
result[key] = clsx(a, b);
} else if (key === 'id' && a && b) {
result.id = mergeIds(a, b);
} else if (key === 'ref' && a && b) {
result.ref = mergeRefs(a, b);
// Override others
} else {
result[key] = b !== undefined ? b : a;

View File

@@ -10,23 +10,43 @@
* governing permissions and limitations under the License.
*/
import {ForwardedRef, MutableRefObject} from 'react';
import {MutableRefObject, Ref} from 'react';
/**
* Merges multiple refs into one. Works with either callback or object refs.
*/
export function mergeRefs<T>(...refs: Array<ForwardedRef<T> | MutableRefObject<T> | null | undefined>): ForwardedRef<T> {
export function mergeRefs<T>(...refs: Array<Ref<T> | MutableRefObject<T> | null | undefined>): Ref<T> {
if (refs.length === 1 && refs[0]) {
return refs[0];
}
return (value: T | null) => {
for (let ref of refs) {
if (typeof ref === 'function') {
ref(value);
} else if (ref != null) {
ref.current = value;
}
let hasCleanup = false;
const cleanups = refs.map(ref => {
const cleanup = setRef(ref, value);
hasCleanup ||= typeof cleanup == 'function';
return cleanup;
});
if (hasCleanup) {
return () => {
cleanups.forEach((cleanup, i) => {
if (typeof cleanup === 'function') {
cleanup();
} else {
setRef(refs[i], null);
}
});
};
}
};
}
function setRef<T>(ref: Ref<T> | MutableRefObject<T> | null | undefined, value: T) {
if (typeof ref === 'function') {
return ref(value);
} else if (ref != null) {
ref.current = value;
}
}

View File

@@ -13,7 +13,7 @@
import {focusWithoutScrolling, isMac, isWebKit} from './index';
import {Href, LinkDOMProps, RouterOptions} from '@react-types/shared';
import {isFirefox, isIPad} from './platform';
import React, {createContext, ReactNode, useContext, useMemo} from 'react';
import React, {createContext, DOMAttributes, JSX, MouseEvent as ReactMouseEvent, ReactNode, useContext, useMemo} from 'react';
interface Router {
isNative: boolean,
@@ -37,7 +37,7 @@ interface RouterProviderProps {
* A RouterProvider accepts a `navigate` function from a framework or client side router,
* and provides it to all nested React Aria links to enable client side navigation.
*/
export function RouterProvider(props: RouterProviderProps) {
export function RouterProvider(props: RouterProviderProps): JSX.Element {
let {children, navigate, useHref} = props;
let ctx = useMemo(() => ({
@@ -72,7 +72,7 @@ interface Modifiers {
shiftKey?: boolean
}
export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers) {
export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers): boolean {
// Use getAttribute here instead of link.target. Firefox will default link.target to "_parent" when inside an iframe.
let target = link.getAttribute('target');
return (
@@ -86,7 +86,7 @@ export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifie
);
}
export function openLink(target: HTMLAnchorElement, modifiers: Modifiers, setOpening = true) {
export function openLink(target: HTMLAnchorElement, modifiers: Modifiers, setOpening = true): void {
let {metaKey, ctrlKey, altKey, shiftKey} = modifiers;
// Firefox does not recognize keyboard events as a user action by default, and the popup blocker
@@ -106,7 +106,7 @@ export function openLink(target: HTMLAnchorElement, modifiers: Modifiers, setOpe
let event = isWebKit() && isMac() && !isIPad() && process.env.NODE_ENV !== 'test'
// @ts-ignore - keyIdentifier is a non-standard property, but it's what webkit expects
? new KeyboardEvent('keydown', {keyIdentifier: 'Enter', metaKey, ctrlKey, altKey, shiftKey})
: new MouseEvent('click', {metaKey, ctrlKey, altKey, shiftKey, bubbles: true, cancelable: true});
: new MouseEvent('click', {metaKey, ctrlKey, altKey, shiftKey, detail: 1, bubbles: true, cancelable: true});
(openLink as any).isOpening = setOpening;
focusWithoutScrolling(target);
target.dispatchEvent(event);
@@ -146,7 +146,7 @@ function openSyntheticLink(target: Element, modifiers: Modifiers) {
getSyntheticLink(target, link => openLink(link, modifiers));
}
export function useSyntheticLinkProps(props: LinkDOMProps) {
export function useSyntheticLinkProps(props: LinkDOMProps): DOMAttributes<HTMLElement> {
let router = useRouter();
const href = router.useHref(props.href ?? '');
return {
@@ -156,11 +156,11 @@ export function useSyntheticLinkProps(props: LinkDOMProps) {
'data-download': props.download,
'data-ping': props.ping,
'data-referrer-policy': props.referrerPolicy
};
} as DOMAttributes<HTMLElement>;
}
/** @deprecated - For backward compatibility. */
export function getSyntheticLinkProps(props: LinkDOMProps) {
export function getSyntheticLinkProps(props: LinkDOMProps): DOMAttributes<HTMLElement> {
return {
'data-href': props.href,
'data-target': props.target,
@@ -168,10 +168,10 @@ export function getSyntheticLinkProps(props: LinkDOMProps) {
'data-download': props.download,
'data-ping': props.ping,
'data-referrer-policy': props.referrerPolicy
};
} as DOMAttributes<HTMLElement>;
}
export function useLinkProps(props?: LinkDOMProps) {
export function useLinkProps(props?: LinkDOMProps): LinkDOMProps {
let router = useRouter();
const href = router.useHref(props?.href ?? '');
return {
@@ -183,3 +183,19 @@ export function useLinkProps(props?: LinkDOMProps) {
referrerPolicy: props?.referrerPolicy
};
}
export function handleLinkClick(e: ReactMouseEvent, router: Router, href: Href | undefined, routerOptions: RouterOptions | undefined): void {
// If a custom router is provided, prevent default and forward if this link should client navigate.
if (
!router.isNative &&
e.currentTarget instanceof HTMLAnchorElement &&
e.currentTarget.href &&
// If props are applied to a router Link component, it may have already prevented default.
!e.isDefaultPrevented() &&
shouldClientNavigate(e.currentTarget, e) &&
href
) {
e.preventDefault();
router.open(e.currentTarget, e, href, routerOptions);
}
}

View File

@@ -14,10 +14,9 @@ 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);
let brands = window.navigator['userAgentData']?.brands;
return Array.isArray(brands) && brands.some((brand: {brand: string, version: string}) => re.test(brand.brand)) ||
re.test(window.navigator.userAgent);
}
function testPlatform(re: RegExp) {
@@ -30,7 +29,7 @@ function cached(fn: () => boolean) {
if (process.env.NODE_ENV === 'test') {
return fn;
}
let res: boolean | null = null;
return () => {
if (res == null) {
@@ -40,40 +39,40 @@ function cached(fn: () => boolean) {
};
}
export const isMac = cached(function () {
export const isMac: () => boolean = cached(function () {
return testPlatform(/^Mac/i);
});
export const isIPhone = cached(function () {
export const isIPhone: () => boolean = cached(function () {
return testPlatform(/^iPhone/i);
});
export const isIPad = cached(function () {
export const isIPad: () => boolean = cached(function () {
return testPlatform(/^iPad/i) ||
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
(isMac() && navigator.maxTouchPoints > 1);
});
export const isIOS = cached(function () {
export const isIOS: () => boolean = cached(function () {
return isIPhone() || isIPad();
});
export const isAppleDevice = cached(function () {
export const isAppleDevice: () => boolean = cached(function () {
return isMac() || isIOS();
});
export const isWebKit = cached(function () {
export const isWebKit: () => boolean = cached(function () {
return testUserAgent(/AppleWebKit/i) && !isChrome();
});
export const isChrome = cached(function () {
export const isChrome: () => boolean = cached(function () {
return testUserAgent(/Chrome/i);
});
export const isAndroid = cached(function () {
export const isAndroid: () => boolean = cached(function () {
return testUserAgent(/Android/i);
});
export const isFirefox = cached(function () {
export const isFirefox: () => boolean = cached(function () {
return testUserAgent(/Firefox/i);
});

View File

@@ -91,9 +91,25 @@ if (typeof document !== 'undefined') {
}
}
export function runAfterTransition(fn: () => void) {
/**
* Cleans up any elements that are no longer in the document.
* This is necessary because we can't rely on transitionend events to fire
* for elements that are removed from the document while transitioning.
*/
function cleanupDetachedElements() {
for (const [eventTarget] of transitionsByElement) {
// Similar to `eventTarget instanceof Element && !eventTarget.isConnected`, but avoids
// the explicit instanceof check, since it may be different in different contexts.
if ('isConnected' in eventTarget && !eventTarget.isConnected) {
transitionsByElement.delete(eventTarget);
}
}
}
export function runAfterTransition(fn: () => void): void {
// Wait one frame to see if an animation starts, e.g. a transition on mount.
requestAnimationFrame(() => {
cleanupDetachedElements();
// If no transitions are running, call the function immediately.
// Otherwise, add it to a list of callbacks to run at the end of the animation.
if (transitionsByElement.size === 0) {

View File

@@ -11,6 +11,7 @@
*/
import {getScrollParents} from './getScrollParents';
import {nodeContains} from './shadowdom/DOMFunctions';
interface ScrollIntoViewportOpts {
/** The optional containing element of the target to be centered in the viewport. */
@@ -22,7 +23,7 @@ interface ScrollIntoViewportOpts {
* Similar to `element.scrollIntoView({block: 'nearest'})` (not supported in Edge),
* but doesn't affect parents above `scrollView`.
*/
export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement) {
export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): void {
let offsetX = relativeOffset(scrollView, element, 'left');
let offsetY = relativeOffset(scrollView, element, 'top');
let width = element.offsetWidth;
@@ -80,7 +81,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|
if (child.offsetParent === ancestor) {
// Stop once we have found the ancestor we are interested in.
break;
} else if (child.offsetParent.contains(ancestor)) {
} else if (nodeContains(child.offsetParent, ancestor)) {
// If the ancestor is not `position:relative`, then we stop at
// _its_ offset parent, and we subtract off _its_ offset, so that
// we end up with the proper offset from child to ancestor.
@@ -97,8 +98,8 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|
* that will be centered in the viewport prior to scrolling the targetElement into view. If scrolling is prevented on
* the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself.
*/
export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts) {
if (targetElement && document.contains(targetElement)) {
export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts): void {
if (targetElement && nodeContains(document, targetElement)) {
let root = document.scrollingElement || document.documentElement;
let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden';
// If scrolling is not currently prevented then we arent in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
@@ -117,6 +118,9 @@ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollI
} else {
let scrollParents = getScrollParents(targetElement);
// If scrolling is prevented, we don't want to scroll the body since it might move the overlay partially offscreen and the user can't scroll it back into view.
if (!isScrollPrevented) {
scrollParents.push(root);
}
for (let scrollParent of scrollParents) {
scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement);
}

View File

@@ -0,0 +1,71 @@
// Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16
/* eslint-disable rsp-rules/no-non-shadow-contains */
import {isShadowRoot} from '../domHelpers';
import {shadowDOM} from '@react-stately/flags';
/**
* ShadowDOM safe version of Node.contains.
*/
export function nodeContains(
node: Node | null | undefined,
otherNode: Node | null | undefined
): boolean {
if (!shadowDOM()) {
return otherNode && node ? node.contains(otherNode) : false;
}
if (!node || !otherNode) {
return false;
}
let currentNode: HTMLElement | Node | null | undefined = otherNode;
while (currentNode !== null) {
if (currentNode === node) {
return true;
}
if ((currentNode as HTMLSlotElement).tagName === 'SLOT' &&
(currentNode as HTMLSlotElement).assignedSlot) {
// Element is slotted
currentNode = (currentNode as HTMLSlotElement).assignedSlot!.parentNode;
} else if (isShadowRoot(currentNode)) {
// Element is in shadow root
currentNode = currentNode.host;
} else {
currentNode = currentNode.parentNode;
}
}
return false;
}
/**
* ShadowDOM safe version of document.activeElement.
*/
export const getActiveElement = (doc: Document = document): Element | null => {
if (!shadowDOM()) {
return doc.activeElement;
}
let activeElement: Element | null = doc.activeElement;
while (activeElement && 'shadowRoot' in activeElement &&
activeElement.shadowRoot?.activeElement) {
activeElement = activeElement.shadowRoot.activeElement;
}
return activeElement;
};
/**
* ShadowDOM safe version of event.target.
*/
export function getEventTarget<T extends Event>(event: T): Element {
if (shadowDOM() && (event.target as HTMLElement).shadowRoot) {
if (event.composedPath) {
return event.composedPath()[0] as Element;
}
}
return event.target as Element;
}

View File

@@ -0,0 +1,319 @@
// https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/ShadowTreeWalker.ts
import {nodeContains} from './DOMFunctions';
import {shadowDOM} from '@react-stately/flags';
export class ShadowTreeWalker implements TreeWalker {
public readonly filter: NodeFilter | null;
public readonly root: Node;
public readonly whatToShow: number;
private _doc: Document;
private _walkerStack: Array<TreeWalker> = [];
private _currentNode: Node;
private _currentSetFor: Set<TreeWalker> = new Set();
constructor(
doc: Document,
root: Node,
whatToShow?: number,
filter?: NodeFilter | null
) {
this._doc = doc;
this.root = root;
this.filter = filter ?? null;
this.whatToShow = whatToShow ?? NodeFilter.SHOW_ALL;
this._currentNode = root;
this._walkerStack.unshift(
doc.createTreeWalker(root, whatToShow, this._acceptNode)
);
const shadowRoot = (root as Element).shadowRoot;
if (shadowRoot) {
const walker = this._doc.createTreeWalker(
shadowRoot,
this.whatToShow,
{acceptNode: this._acceptNode}
);
this._walkerStack.unshift(walker);
}
}
private _acceptNode = (node: Node): number => {
if (node.nodeType === Node.ELEMENT_NODE) {
const shadowRoot = (node as Element).shadowRoot;
if (shadowRoot) {
const walker = this._doc.createTreeWalker(
shadowRoot,
this.whatToShow,
{acceptNode: this._acceptNode}
);
this._walkerStack.unshift(walker);
return NodeFilter.FILTER_ACCEPT;
} else {
if (typeof this.filter === 'function') {
return this.filter(node);
} else if (this.filter?.acceptNode) {
return this.filter.acceptNode(node);
} else if (this.filter === null) {
return NodeFilter.FILTER_ACCEPT;
}
}
}
return NodeFilter.FILTER_SKIP;
};
public get currentNode(): Node {
return this._currentNode;
}
public set currentNode(node: Node) {
if (!nodeContains(this.root, node)) {
throw new Error(
'Cannot set currentNode to a node that is not contained by the root node.'
);
}
const walkers: TreeWalker[] = [];
let curNode: Node | null | undefined = node;
let currentWalkerCurrentNode = node;
this._currentNode = node;
while (curNode && curNode !== this.root) {
if (curNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
const shadowRoot = curNode as ShadowRoot;
const walker = this._doc.createTreeWalker(
shadowRoot,
this.whatToShow,
{acceptNode: this._acceptNode}
);
walkers.push(walker);
walker.currentNode = currentWalkerCurrentNode;
this._currentSetFor.add(walker);
curNode = currentWalkerCurrentNode = shadowRoot.host;
} else {
curNode = curNode.parentNode;
}
}
const walker = this._doc.createTreeWalker(
this.root,
this.whatToShow,
{acceptNode: this._acceptNode}
);
walkers.push(walker);
walker.currentNode = currentWalkerCurrentNode;
this._currentSetFor.add(walker);
this._walkerStack = walkers;
}
public get doc(): Document {
return this._doc;
}
public firstChild(): Node | null {
let currentNode = this.currentNode;
let newNode = this.nextNode();
if (!nodeContains(currentNode, newNode)) {
this.currentNode = currentNode;
return null;
}
if (newNode) {
this.currentNode = newNode;
}
return newNode;
}
public lastChild(): Node | null {
let walker = this._walkerStack[0];
let newNode = walker.lastChild();
if (newNode) {
this.currentNode = newNode;
}
return newNode;
}
public nextNode(): Node | null {
const nextNode = this._walkerStack[0].nextNode();
if (nextNode) {
const shadowRoot = (nextNode as Element).shadowRoot;
if (shadowRoot) {
let nodeResult: number | undefined;
if (typeof this.filter === 'function') {
nodeResult = this.filter(nextNode);
} else if (this.filter?.acceptNode) {
nodeResult = this.filter.acceptNode(nextNode);
}
if (nodeResult === NodeFilter.FILTER_ACCEPT) {
this.currentNode = nextNode;
return nextNode;
}
// _acceptNode should have added new walker for this shadow,
// go in recursively.
let newNode = this.nextNode();
if (newNode) {
this.currentNode = newNode;
}
return newNode;
}
if (nextNode) {
this.currentNode = nextNode;
}
return nextNode;
} else {
if (this._walkerStack.length > 1) {
this._walkerStack.shift();
let newNode = this.nextNode();
if (newNode) {
this.currentNode = newNode;
}
return newNode;
} else {
return null;
}
}
}
public previousNode(): Node | null {
const currentWalker = this._walkerStack[0];
if (currentWalker.currentNode === currentWalker.root) {
if (this._currentSetFor.has(currentWalker)) {
this._currentSetFor.delete(currentWalker);
if (this._walkerStack.length > 1) {
this._walkerStack.shift();
let newNode = this.previousNode();
if (newNode) {
this.currentNode = newNode;
}
return newNode;
} else {
return null;
}
}
return null;
}
const previousNode = currentWalker.previousNode();
if (previousNode) {
const shadowRoot = (previousNode as Element).shadowRoot;
if (shadowRoot) {
let nodeResult: number | undefined;
if (typeof this.filter === 'function') {
nodeResult = this.filter(previousNode);
} else if (this.filter?.acceptNode) {
nodeResult = this.filter.acceptNode(previousNode);
}
if (nodeResult === NodeFilter.FILTER_ACCEPT) {
if (previousNode) {
this.currentNode = previousNode;
}
return previousNode;
}
// _acceptNode should have added new walker for this shadow,
// go in recursively.
let newNode = this.lastChild();
if (newNode) {
this.currentNode = newNode;
}
return newNode;
}
if (previousNode) {
this.currentNode = previousNode;
}
return previousNode;
} else {
if (this._walkerStack.length > 1) {
this._walkerStack.shift();
let newNode = this.previousNode();
if (newNode) {
this.currentNode = newNode;
}
return newNode;
} else {
return null;
}
}
}
/**
* @deprecated
*/
public nextSibling(): Node | null {
// if (__DEV__) {
// throw new Error("Method not implemented.");
// }
return null;
}
/**
* @deprecated
*/
public previousSibling(): Node | null {
// if (__DEV__) {
// throw new Error("Method not implemented.");
// }
return null;
}
/**
* @deprecated
*/
public parentNode(): Node | null {
// if (__DEV__) {
// throw new Error("Method not implemented.");
// }
return null;
}
}
/**
* ShadowDOM safe version of document.createTreeWalker.
*/
export function createShadowTreeWalker(
doc: Document,
root: Node,
whatToShow?: number,
filter?: NodeFilter | null
): TreeWalker {
if (shadowDOM()) {
return new ShadowTreeWalker(doc, root, whatToShow, filter);
}
return doc.createTreeWalker(root, whatToShow, filter);
}

View File

@@ -13,6 +13,7 @@
/* eslint-disable rulesdir/pure-render */
import {getOffset} from './getOffset';
import {nodeContains} from './shadowdom/DOMFunctions';
import {Orientation} from '@react-types/shared';
import React, {HTMLAttributes, MutableRefObject, useRef} from 'react';
@@ -99,7 +100,7 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes<HTMLElement> {
const target = e.currentTarget;
// If we're already handling dragging on a descendant with useDrag1D, then
// we don't want to handle the drag motion on this target as well.
if (draggingElements.some(elt => target.contains(elt))) {
if (draggingElements.some(elt => nodeContains(target, elt))) {
return;
}
draggingElements.push(target);

View File

@@ -10,12 +10,16 @@
* governing permissions and limitations under the License.
*/
import {useCallback, useRef} from 'react';
import React, {useCallback, useRef} from 'react';
import {useLayoutEffect} from './useLayoutEffect';
// Use the earliest effect type possible. useInsertionEffect runs during the mutation phase,
// before all layout effects, but is available only in React 18 and later.
const useEarlyEffect = React['useInsertionEffect'] ?? useLayoutEffect;
export function useEffectEvent<T extends Function>(fn?: T): T {
const ref = useRef<T | null | undefined>(null);
useLayoutEffect(() => {
useEarlyEffect(() => {
ref.current = fn;
}, [fn]);
// @ts-ignore

View File

@@ -19,7 +19,7 @@ export function useEvent<K extends keyof GlobalEventHandlersEventMap>(
event: K | (string & {}),
handler?: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
) {
): void {
let handleEvent = useEffectEvent(handler);
let isDisabled = handler == null;
@@ -33,5 +33,5 @@ export function useEvent<K extends keyof GlobalEventHandlersEventMap>(
return () => {
element.removeEventListener(event, handleEvent as EventListener, options);
};
}, [ref, event, options, isDisabled, handleEvent]);
}, [ref, event, options, isDisabled]);
}

View File

@@ -11,26 +11,26 @@
*/
import {RefObject} from '@react-types/shared';
import {useEffect, useRef} from 'react';
import {useEffect} from 'react';
import {useEffectEvent} from './useEffectEvent';
export function useFormReset<T>(
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null> | undefined,
initialValue: T,
onReset: (value: T) => void
) {
let resetValue = useRef(initialValue);
): void {
let handleReset = useEffectEvent(() => {
if (onReset) {
onReset(resetValue.current);
onReset(initialValue);
}
});
useEffect(() => {
let form = ref?.current?.form;
form?.addEventListener('reset', handleReset);
return () => {
form?.removeEventListener('reset', handleReset);
};
}, [ref, handleReset]);
}, [ref]);
}

View File

@@ -13,6 +13,7 @@
import {useCallback, useEffect, useRef} from 'react';
interface GlobalListeners {
addGlobalListener<K extends keyof WindowEventMap>(el: Window, type: K, listener: (this: Document, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void,
addGlobalListener<K extends keyof DocumentEventMap>(el: EventTarget, type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void,
addGlobalListener(el: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void,
removeGlobalListener<K extends keyof DocumentEventMap>(el: EventTarget, type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void,

View File

@@ -22,7 +22,16 @@ let canUseDOM = Boolean(
window.document.createElement
);
let idsUpdaterMap: Map<string, Array<(v: string) => void>> = new Map();
export let idsUpdaterMap: Map<string, { current: string | null }[]> = new Map();
// This allows us to clean up the idsUpdaterMap when the id is no longer used.
// Map is a strong reference, so unused ids wouldn't be cleaned up otherwise.
// This can happen in suspended components where mount/unmount is not called.
let registry;
if (typeof FinalizationRegistry !== 'undefined') {
registry = new FinalizationRegistry<string>((heldValue) => {
idsUpdaterMap.delete(heldValue);
});
}
/**
* If a default is not provided, generate an id.
@@ -33,35 +42,42 @@ export function useId(defaultId?: string): string {
let nextId = useRef(null);
let res = useSSRSafeId(value);
let cleanupRef = useRef(null);
let updateValue = useCallback((val) => {
nextId.current = val;
}, []);
if (registry) {
registry.register(cleanupRef, res);
}
if (canUseDOM) {
// TS not smart enough to know that `has` means the value exists
if (idsUpdaterMap.has(res) && !idsUpdaterMap.get(res)!.includes(updateValue)) {
idsUpdaterMap.set(res, [...idsUpdaterMap.get(res)!, updateValue]);
const cacheIdRef = idsUpdaterMap.get(res);
if (cacheIdRef && !cacheIdRef.includes(nextId)) {
cacheIdRef.push(nextId);
} else {
idsUpdaterMap.set(res, [updateValue]);
idsUpdaterMap.set(res, [nextId]);
}
}
useLayoutEffect(() => {
let r = res;
return () => {
// In Suspense, the cleanup function may be not called
// when it is though, also remove it from the finalization registry.
if (registry) {
registry.unregister(cleanupRef);
}
idsUpdaterMap.delete(r);
};
}, [res]);
// This cannot cause an infinite loop because the ref is updated first.
// This cannot cause an infinite loop because the ref is always cleaned up.
// eslint-disable-next-line
useEffect(() => {
let newId = nextId.current;
if (newId) {
nextId.current = null;
setValue(newId);
}
if (newId) { setValue(newId); }
return () => {
if (newId) { nextId.current = null; }
};
});
return res;
@@ -78,13 +94,13 @@ export function mergeIds(idA: string, idB: string): string {
let setIdsA = idsUpdaterMap.get(idA);
if (setIdsA) {
setIdsA.forEach(fn => fn(idB));
setIdsA.forEach(ref => (ref.current = idB));
return idB;
}
let setIdsB = idsUpdaterMap.get(idB);
if (setIdsB) {
setIdsB.forEach(fn => fn(idA));
setIdsB.forEach((ref) => (ref.current = idA));
return idA;
}

View File

@@ -15,6 +15,6 @@ import React from 'react';
// During SSR, React emits a warning when calling useLayoutEffect.
// Since neither useLayoutEffect nor useEffect run on the server,
// we can suppress this by replace it with a noop on the server.
export const useLayoutEffect = typeof document !== 'undefined'
export const useLayoutEffect: typeof React.useLayoutEffect = typeof document !== 'undefined'
? React.useLayoutEffect
: () => {};

View File

@@ -12,7 +12,7 @@
import {RefObject, useCallback, useRef} from 'react';
import {useEvent} from './useEvent';
import {useLayoutEffect} from './useLayoutEffect';
export interface LoadMoreProps {
@@ -32,7 +32,7 @@ export interface LoadMoreProps {
items?: any
}
export function useLoadMore(props: LoadMoreProps, ref: RefObject<HTMLElement | null>) {
export function useLoadMore(props: LoadMoreProps, ref: RefObject<HTMLElement | null>): void {
let {isLoading, onLoadMore, scrollOffset = 1, items} = props;
// Handle scrolling, and call onLoadMore when nearing the bottom.

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import type {AsyncLoadable, Collection} from '@react-types/shared';
import {getScrollParent} from './getScrollParent';
import {RefObject, useRef} from 'react';
import {useEffectEvent} from './useEffectEvent';
import {useLayoutEffect} from './useLayoutEffect';
export interface LoadMoreSentinelProps extends Omit<AsyncLoadable, 'isLoading'> {
collection: Collection<any>,
/**
* The amount of offset from the bottom of your scrollable region that should trigger load more.
* Uses a percentage value relative to the scroll body's client height. Load more is then triggered
* when your current scroll position's distance from the bottom of the currently loaded list of items is less than
* or equal to the provided value. (e.g. 1 = 100% of the scroll region's height).
* @default 1
*/
scrollOffset?: number
}
export function useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject<HTMLElement | null>): void {
let {collection, onLoadMore, scrollOffset = 1} = props;
let sentinelObserver = useRef<IntersectionObserver>(null);
let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => {
// Use "isIntersecting" over an equality check of 0 since it seems like there is cases where
// a intersection ratio of 0 can be reported when isIntersecting is actually true
for (let entry of entries) {
// Note that this will be called if the collection changes, even if onLoadMore was already called and is being processed.
// Up to user discretion as to how to handle these multiple onLoadMore calls
if (entry.isIntersecting && onLoadMore) {
onLoadMore();
}
}
});
useLayoutEffect(() => {
if (ref.current) {
// Tear down and set up a new IntersectionObserver when the collection changes so that we can properly trigger additional loadMores if there is room for more items
// Need to do this tear down and set up since using a large rootMargin will mean the observer's callback isn't called even when scrolling the item into view beause its visibility hasn't actually changed
// https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21
sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: getScrollParent(ref?.current) as HTMLElement, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`});
sentinelObserver.current.observe(ref.current);
}
return () => {
if (sentinelObserver.current) {
sentinelObserver.current.disconnect();
}
};
}, [collection, ref, scrollOffset]);
}

View File

@@ -10,30 +10,60 @@
* governing permissions and limitations under the License.
*/
import {MutableRefObject, useMemo, useRef} from 'react';
import {MutableRefObject, useCallback, useMemo, useRef} from 'react';
/**
* Offers an object ref for a given callback ref or an object ref. Especially
* helfpul when passing forwarded refs (created using `React.forwardRef`) to
* React Aria hooks.
*
* @param forwardedRef The original ref intended to be used.
* @param ref The original ref intended to be used.
* @returns An object ref that updates the given ref.
* @see https://reactjs.org/docs/forwarding-refs.html
* @see https://react.dev/reference/react/forwardRef
*/
export function useObjectRef<T>(forwardedRef?: ((instance: T | null) => void) | MutableRefObject<T | null> | null): MutableRefObject<T | null> {
export function useObjectRef<T>(ref?: ((instance: T | null) => (() => void) | void) | MutableRefObject<T | null> | null): MutableRefObject<T | null> {
const objRef: MutableRefObject<T | null> = useRef<T>(null);
return useMemo(() => ({
get current() {
return objRef.current;
},
set current(value) {
objRef.current = value;
if (typeof forwardedRef === 'function') {
forwardedRef(value);
} else if (forwardedRef) {
forwardedRef.current = value;
const cleanupRef: MutableRefObject<(() => void) | void> = useRef(undefined);
const refEffect = useCallback(
(instance: T | null) => {
if (typeof ref === 'function') {
const refCallback = ref;
const refCleanup = refCallback(instance);
return () => {
if (typeof refCleanup === 'function') {
refCleanup();
} else {
refCallback(null);
}
};
} else if (ref) {
ref.current = instance;
return () => {
ref.current = null;
};
}
}
}), [forwardedRef]);
},
[ref]
);
return useMemo(
() => ({
get current() {
return objRef.current;
},
set current(value) {
objRef.current = value;
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = undefined;
}
if (value != null) {
cleanupRef.current = refEffect(value);
}
}
}),
[refEffect]
);
}

View File

@@ -1,6 +1,7 @@
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
import {useEffectEvent} from './useEffectEvent';
function hasResizeObserver() {
return typeof window.ResizeObserver !== 'undefined';
@@ -12,8 +13,11 @@ type useResizeObserverOptionsType<T> = {
onResize: () => void
}
export function useResizeObserver<T extends Element>(options: useResizeObserverOptionsType<T>) {
export function useResizeObserver<T extends Element>(options: useResizeObserverOptionsType<T>): void {
// Only call onResize from inside the effect, otherwise we'll void our assumption that
// useEffectEvents are safe to pass in.
const {ref, box, onResize} = options;
let onResizeEvent = useEffectEvent(onResize);
useEffect(() => {
let element = ref?.current;
@@ -22,9 +26,9 @@ export function useResizeObserver<T extends Element>(options: useResizeObserverO
}
if (!hasResizeObserver()) {
window.addEventListener('resize', onResize, false);
window.addEventListener('resize', onResizeEvent, false);
return () => {
window.removeEventListener('resize', onResize, false);
window.removeEventListener('resize', onResizeEvent, false);
};
} else {
@@ -33,7 +37,7 @@ export function useResizeObserver<T extends Element>(options: useResizeObserverO
return;
}
onResize();
onResizeEvent();
});
resizeObserverInstance.observe(element, {box});
@@ -44,5 +48,5 @@ export function useResizeObserver<T extends Element>(options: useResizeObserverO
};
}
}, [onResize, ref, box]);
}, [ref, box]);
}

View File

@@ -19,7 +19,7 @@ interface ContextValue<T> {
}
// Syncs ref from context with ref passed to hook
export function useSyncRef<T>(context?: ContextValue<T> | null, ref?: RefObject<T | null>) {
export function useSyncRef<T>(context?: ContextValue<T> | null, ref?: RefObject<T | null>): void {
useLayoutEffect(() => {
if (context && context.ref && ref) {
context.ref.current = ref.current;

View File

@@ -11,11 +11,13 @@
*/
import {EffectCallback, useEffect, useRef} from 'react';
import {useEffectEvent} from './useEffectEvent';
// Like useEffect, but only called for updates after the initial render.
export function useUpdateEffect(effect: EffectCallback, dependencies: any[]) {
export function useUpdateEffect(cb: EffectCallback, dependencies: any[]): void {
const isInitialMount = useRef(true);
const lastDeps = useRef<any[] | null>(null);
let cbEvent = useEffectEvent(cb);
useEffect(() => {
isInitialMount.current = true;
@@ -25,12 +27,13 @@ export function useUpdateEffect(effect: EffectCallback, dependencies: any[]) {
}, []);
useEffect(() => {
let prevDeps = lastDeps.current;
if (isInitialMount.current) {
isInitialMount.current = false;
} else if (!lastDeps.current || dependencies.some((dep, i) => !Object.is(dep, lastDeps[i]))) {
effect();
} else if (!prevDeps || dependencies.some((dep, i) => !Object.is(dep, prevDeps[i]))) {
cbEvent();
}
lastDeps.current = dependencies;
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
}

View File

@@ -14,7 +14,7 @@ import {EffectCallback, useRef} from 'react';
import {useLayoutEffect} from './useLayoutEffect';
// Like useLayoutEffect, but only called for updates after the initial render.
export function useUpdateLayoutEffect(effect: EffectCallback, dependencies: any[]) {
export function useUpdateLayoutEffect(effect: EffectCallback, dependencies: any[]): void {
const isInitialMount = useRef(true);
const lastDeps = useRef<any[] | null>(null);

View File

@@ -10,8 +10,8 @@
* governing permissions and limitations under the License.
*/
import {Dispatch, MutableRefObject, useRef, useState} from 'react';
import {useEffectEvent, useLayoutEffect} from './';
import {Dispatch, RefObject, useCallback, useRef, useState} from 'react';
import {useLayoutEffect} from './';
type SetValueAction<S> = (prev: S) => Generator<any, void, unknown>;
@@ -21,11 +21,14 @@ type SetValueAction<S> = (prev: S) => Generator<any, void, unknown>;
// written linearly.
export function useValueEffect<S>(defaultValue: S | (() => S)): [S, Dispatch<SetValueAction<S>>] {
let [value, setValue] = useState(defaultValue);
let effect: MutableRefObject<Generator<S> | null> = useRef<Generator<S> | null>(null);
// Keep an up to date copy of value in a ref so we can access the current value in the generator.
// This allows us to maintain a stable queue function.
let currValue = useRef(value);
let effect: RefObject<Generator<S> | null> = useRef<Generator<S> | null>(null);
// Store the function in a ref so we can always access the current version
// which has the proper `value` in scope.
let nextRef = useEffectEvent(() => {
let nextRef = useRef(() => {
if (!effect.current) {
return;
}
@@ -41,24 +44,25 @@ export function useValueEffect<S>(defaultValue: S | (() => S)): [S, Dispatch<Set
// If the value is the same as the current value,
// then continue to the next yield. Otherwise,
// set the value in state and wait for the next layout effect.
if (value === newValue.value) {
nextRef();
if (currValue.current === newValue.value) {
nextRef.current();
} else {
setValue(newValue.value);
}
});
useLayoutEffect(() => {
currValue.current = value;
// If there is an effect currently running, continue to the next yield.
if (effect.current) {
nextRef();
nextRef.current();
}
});
let queue = useEffectEvent(fn => {
effect.current = fn(value);
nextRef();
});
let queue = useCallback(fn => {
effect.current = fn(currValue.current);
nextRef.current();
}, [nextRef]);
return [value, queue];
}

View File

@@ -12,6 +12,7 @@
import {useEffect, useState} from 'react';
import {useIsSSR} from '@react-aria/ssr';
import {willOpenKeyboard} from './keyboard';
interface ViewportSize {
width: number,
@@ -25,10 +26,8 @@ export function useViewportSize(): ViewportSize {
let [size, setSize] = useState(() => isSSR ? {width: 0, height: 0} : getViewportSize());
useEffect(() => {
// Use visualViewport api to track available height even on iOS virtual keyboard opening
let onResize = () => {
let updateSize = (newSize: ViewportSize) => {
setSize(size => {
let newSize = getViewportSize();
if (newSize.width === size.width && newSize.height === size.height) {
return size;
}
@@ -36,6 +35,38 @@ export function useViewportSize(): ViewportSize {
});
};
// Use visualViewport api to track available height even on iOS virtual keyboard opening
let onResize = () => {
// Ignore updates when zoomed.
if (visualViewport && visualViewport.scale > 1) {
return;
}
updateSize(getViewportSize());
};
// When closing the keyboard, iOS does not fire the visual viewport resize event until the animation is complete.
// We can anticipate this and resize early by handling the blur event and using the layout size.
let frame: number;
let onBlur = (e: FocusEvent) => {
if (visualViewport && visualViewport.scale > 1) {
return;
}
if (willOpenKeyboard(e.target as Element)) {
// Wait one frame to see if a new element gets focused.
frame = requestAnimationFrame(() => {
if (!document.activeElement || !willOpenKeyboard(document.activeElement)) {
updateSize({width: document.documentElement.clientWidth, height: document.documentElement.clientHeight});
}
});
}
};
updateSize(getViewportSize());
window.addEventListener('blur', onBlur, true);
if (!visualViewport) {
window.addEventListener('resize', onResize);
} else {
@@ -43,6 +74,8 @@ export function useViewportSize(): ViewportSize {
}
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('blur', onBlur, true);
if (!visualViewport) {
window.removeEventListener('resize', onResize);
} else {
@@ -56,7 +89,8 @@ export function useViewportSize(): ViewportSize {
function getViewportSize(): ViewportSize {
return {
width: (visualViewport && visualViewport?.width) || window.innerWidth,
height: (visualViewport && visualViewport?.height) || window.innerHeight
// Multiply by the visualViewport scale to get the "natural" size, unaffected by pinch zooming.
width: visualViewport ? visualViewport.width * visualViewport.scale : document.documentElement.clientWidth,
height: visualViewport ? visualViewport.height * visualViewport.scale : document.documentElement.clientHeight
};
}