chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,20 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import isDisabled from './isDisabled';
import propsToAccessibilityComponent from './propsToAccessibilityComponent';
import propsToAriaRole from './propsToAriaRole';
const AccessibilityUtil = {
isDisabled,
propsToAccessibilityComponent,
propsToAriaRole
};
export default AccessibilityUtil;
@@ -0,0 +1,15 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
const isDisabled = (props: Object): boolean =>
props.disabled ||
(Array.isArray(props.accessibilityStates) &&
props.accessibilityStates.indexOf('disabled') > -1);
export default isDisabled;
@@ -0,0 +1,58 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import propsToAriaRole from './propsToAriaRole';
const roleComponents = {
article: 'article',
banner: 'header',
blockquote: 'blockquote',
button: 'button',
code: 'code',
complementary: 'aside',
contentinfo: 'footer',
deletion: 'del',
emphasis: 'em',
figure: 'figure',
insertion: 'ins',
form: 'form',
list: 'ul',
listitem: 'li',
main: 'main',
navigation: 'nav',
paragraph: 'p',
region: 'section',
strong: 'strong'
};
const emptyObject = {};
const propsToAccessibilityComponent = (
props: Object = emptyObject
): void | string => {
const roleProp = props.role || props.accessibilityRole;
// special-case for "label" role which doesn't map to an ARIA role
if (roleProp === 'label') {
return 'label';
}
const role = propsToAriaRole(props);
if (role) {
if (role === 'heading') {
const level = props.accessibilityLevel || props['aria-level'];
if (level != null) {
return `h${level}`;
}
return 'h1';
}
return roleComponents[role];
}
};
export default propsToAccessibilityComponent;
@@ -0,0 +1,42 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
const accessibilityRoleToWebRole = {
adjustable: 'slider',
button: 'button',
header: 'heading',
image: 'img',
imagebutton: null,
keyboardkey: null,
label: null,
link: 'link',
none: 'presentation',
search: 'search',
summary: 'region',
text: null
};
const propsToAriaRole = ({
accessibilityRole,
role
}: {
accessibilityRole?: string,
role?: string
}): string | void => {
const _role = role || accessibilityRole;
if (_role) {
const inferredRole = accessibilityRoleToWebRole[_role];
if (inferredRole !== null) {
// ignore roles that don't map to web
return inferredRole || _role;
}
}
};
export default propsToAriaRole;
@@ -0,0 +1,32 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export type PackagerAsset = {
__packager_asset: boolean,
fileSystemLocation: string,
httpServerLocation: string,
width: ?number,
height: ?number,
scales: Array<number>,
hash: string,
name: string,
type: string
};
const assets: Array<PackagerAsset> = [];
export function registerAsset(asset: PackagerAsset): number {
// `push` returns new array length, so the first asset will
// get id 1 (not 0) to make the value truthy
return assets.push(asset);
}
export function getAssetByID(assetId: number): PackagerAsset {
return assets[assetId - 1];
}
@@ -0,0 +1,167 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
const dataUriPattern = /^data:/;
export class ImageUriCache {
static _maximumEntries: number = 256;
static _entries = {};
static has(uri: string): boolean {
const entries = ImageUriCache._entries;
const isDataUri = dataUriPattern.test(uri);
return isDataUri || Boolean(entries[uri]);
}
static add(uri: string) {
const entries = ImageUriCache._entries;
const lastUsedTimestamp = Date.now();
if (entries[uri]) {
entries[uri].lastUsedTimestamp = lastUsedTimestamp;
entries[uri].refCount += 1;
} else {
entries[uri] = {
lastUsedTimestamp,
refCount: 1
};
}
}
static remove(uri: string) {
const entries = ImageUriCache._entries;
if (entries[uri]) {
entries[uri].refCount -= 1;
}
// Free up entries when the cache is "full"
ImageUriCache._cleanUpIfNeeded();
}
static _cleanUpIfNeeded() {
const entries = ImageUriCache._entries;
const imageUris = Object.keys(entries);
if (imageUris.length + 1 > ImageUriCache._maximumEntries) {
let leastRecentlyUsedKey;
let leastRecentlyUsedEntry;
imageUris.forEach((uri) => {
const entry = entries[uri];
if (
(!leastRecentlyUsedEntry ||
entry.lastUsedTimestamp <
leastRecentlyUsedEntry.lastUsedTimestamp) &&
entry.refCount === 0
) {
leastRecentlyUsedKey = uri;
leastRecentlyUsedEntry = entry;
}
});
if (leastRecentlyUsedKey) {
delete entries[leastRecentlyUsedKey];
}
}
}
}
let id = 0;
const requests = {};
const ImageLoader = {
abort(requestId: number) {
let image = requests[`${requestId}`];
if (image) {
image.onerror = null;
image.onload = null;
image = null;
delete requests[`${requestId}`];
}
},
getSize(
uri: string,
success: (width: number, height: number) => void,
failure: () => void
) {
let complete = false;
const interval = setInterval(callback, 16);
const requestId = ImageLoader.load(uri, callback, errorCallback);
function callback() {
const image = requests[`${requestId}`];
if (image) {
const { naturalHeight, naturalWidth } = image;
if (naturalHeight && naturalWidth) {
success(naturalWidth, naturalHeight);
complete = true;
}
}
if (complete) {
ImageLoader.abort(requestId);
clearInterval(interval);
}
}
function errorCallback() {
if (typeof failure === 'function') {
failure();
}
ImageLoader.abort(requestId);
clearInterval(interval);
}
},
has(uri: string): boolean {
return ImageUriCache.has(uri);
},
load(uri: string, onLoad: Function, onError: Function): number {
id += 1;
const image = new window.Image();
image.onerror = onError;
image.onload = (e) => {
// avoid blocking the main thread
const onDecode = () => onLoad({ nativeEvent: e });
if (typeof image.decode === 'function') {
// Safari currently throws exceptions when decoding svgs.
// We want to catch that error and allow the load handler
// to be forwarded to the onLoad handler in this case
image.decode().then(onDecode, onDecode);
} else {
setTimeout(onDecode, 0);
}
};
image.src = uri;
requests[`${id}`] = image;
return id;
},
prefetch(uri: string): Promise<void> {
return new Promise((resolve, reject) => {
ImageLoader.load(
uri,
() => {
// Add the uri to the cache so it can be immediately displayed when used
// but also immediately remove it to correctly reflect that it has no active references
ImageUriCache.add(uri);
ImageUriCache.remove(uri);
resolve();
},
reject
);
});
},
queryCache(uris: Array<string>): Promise<{| [uri: string]: 'disk/memory' |}> {
const result = {};
uris.forEach((u) => {
if (ImageUriCache.has(u)) {
result[u] = 'disk/memory';
}
});
return Promise.resolve(result);
}
};
export default ImageLoader;
@@ -0,0 +1,64 @@
/**
* Copyright (c) Nicolas Gallagher.
* Copyright (c) Meta Platforms, Inc. and 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 UIManager from '../../exports/UIManager';
/**
* This class is responsible for coordinating the "focused"
* state for TextInputs. All calls relating to the keyboard
* should be funneled through here
*/
const TextInputState = {
/**
* Internal state
*/
_currentlyFocusedNode: (null: ?Object),
/**
* Returns the ID of the currently focused text field, if one exists
* If no text field is focused it returns null
*/
currentlyFocusedField(): ?Object {
if (document.activeElement !== this._currentlyFocusedNode) {
this._currentlyFocusedNode = null;
}
return this._currentlyFocusedNode;
},
/**
* @param {Object} TextInputID id of the text field to focus
* Focuses the specified text field
* noop if the text field was already focused
*/
focusTextInput(textFieldNode: ?Object) {
if (textFieldNode !== null) {
this._currentlyFocusedNode = textFieldNode;
if (document.activeElement !== textFieldNode) {
UIManager.focus(textFieldNode);
}
}
},
/**
* @param {Object} textFieldNode id of the text field to focus
* Unfocuses the specified text field
* noop if it wasn't focused
*/
blurTextInput(textFieldNode: ?Object) {
if (textFieldNode !== null) {
this._currentlyFocusedNode = null;
if (document.activeElement === textFieldNode) {
UIManager.blur(textFieldNode);
}
}
}
};
export default TextInputState;
@@ -0,0 +1,31 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 type { Node } from 'React';
import type { ViewProps } from '../../exports/View/types';
import View from '../../exports/View';
import React from 'react';
/**
* Common implementation for a simple stubbed view.
*/
function UnimplementedView({ style, ...props }: ViewProps): Node {
return <View {...props} style={[unimplementedViewStyles, style]} />;
}
const unimplementedViewStyles =
process.env.NODE_ENV !== 'production'
? {
alignSelf: 'flex-start',
borderColor: 'red',
borderWidth: 1
}
: {};
export default UnimplementedView;
@@ -0,0 +1,89 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
'use strict';
import canUseDOM from '../canUseDom';
type Listener = (e: any) => void;
export type EventOptions = {
capture?: boolean,
passive?: boolean,
once?: boolean
};
const emptyFunction = () => {};
function supportsPassiveEvents(): boolean {
let supported = false;
// Check if browser supports event with passive listeners
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support
if (canUseDOM) {
try {
const options = {};
Object.defineProperty(options, 'passive', {
get() {
supported = true;
return false;
}
});
window.addEventListener('test', null, options);
window.removeEventListener('test', null, options);
} catch (e) {}
}
return supported;
}
const canUsePassiveEvents = supportsPassiveEvents();
function getOptions(options: ?EventOptions): EventOptions | boolean {
if (options == null) {
return false;
}
return canUsePassiveEvents ? options : Boolean(options.capture);
}
/**
* Shim generic API compatibility with ReactDOM's synthetic events, without needing the
* large amount of code ReactDOM uses to do this. Ideally we wouldn't use a synthetic
* event wrapper at all.
*/
function isPropagationStopped() {
return this.cancelBubble;
}
function isDefaultPrevented() {
return this.defaultPrevented;
}
function normalizeEvent(event: any) {
event.nativeEvent = event;
event.persist = emptyFunction;
event.isDefaultPrevented = isDefaultPrevented;
event.isPropagationStopped = isPropagationStopped;
return event;
}
/**
*
*/
export function addEventListener(
target: EventTarget,
type: any,
listener: Listener,
options: ?EventOptions
): () => void {
const opts = getOptions(options);
const compatListener = (e: any) => listener(normalizeEvent(e));
target.addEventListener(type, compatListener, opts);
return function removeEventListener() {
if (target != null) {
target.removeEventListener(type, compatListener, opts);
}
};
}
@@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/
const canUseDOM: boolean = !!(
typeof window !== 'undefined' &&
window.document &&
window.document.createElement
);
export default canUseDOM;
@@ -0,0 +1,919 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @noflow
*/
import AccessibilityUtil from '../AccessibilityUtil';
import StyleSheet from '../../exports/StyleSheet';
import { warnOnce } from '../warnOnce';
const emptyObject = {};
const hasOwnProperty = Object.prototype.hasOwnProperty;
const isArray = Array.isArray;
const uppercasePattern = /[A-Z]/g;
function toHyphenLower(match) {
return '-' + match.toLowerCase();
}
function hyphenateString(str: string): string {
return str.replace(uppercasePattern, toHyphenLower);
}
function processIDRefList(idRefList: string | Array<string>): string {
return isArray(idRefList) ? idRefList.join(' ') : idRefList;
}
const pointerEventsStyles = StyleSheet.create({
auto: {
pointerEvents: 'auto'
},
'box-none': {
pointerEvents: 'box-none'
},
'box-only': {
pointerEvents: 'box-only'
},
none: {
pointerEvents: 'none'
}
});
const createDOMProps = (elementType, props, options) => {
if (!props) {
props = emptyObject;
}
const {
'aria-activedescendant': ariaActiveDescendant,
accessibilityActiveDescendant,
'aria-atomic': ariaAtomic,
accessibilityAtomic,
'aria-autocomplete': ariaAutoComplete,
accessibilityAutoComplete,
'aria-busy': ariaBusy,
accessibilityBusy,
'aria-checked': ariaChecked,
accessibilityChecked,
'aria-colcount': ariaColumnCount,
accessibilityColumnCount,
'aria-colindex': ariaColumnIndex,
accessibilityColumnIndex,
'aria-colspan': ariaColumnSpan,
accessibilityColumnSpan,
'aria-controls': ariaControls,
accessibilityControls,
'aria-current': ariaCurrent,
accessibilityCurrent,
'aria-describedby': ariaDescribedBy,
accessibilityDescribedBy,
'aria-details': ariaDetails,
accessibilityDetails,
'aria-disabled': ariaDisabled,
accessibilityDisabled,
'aria-errormessage': ariaErrorMessage,
accessibilityErrorMessage,
'aria-expanded': ariaExpanded,
accessibilityExpanded,
'aria-flowto': ariaFlowTo,
accessibilityFlowTo,
'aria-haspopup': ariaHasPopup,
accessibilityHasPopup,
'aria-hidden': ariaHidden,
accessibilityHidden,
'aria-invalid': ariaInvalid,
accessibilityInvalid,
'aria-keyshortcuts': ariaKeyShortcuts,
accessibilityKeyShortcuts,
'aria-label': ariaLabel,
accessibilityLabel,
'aria-labelledby': ariaLabelledBy,
accessibilityLabelledBy,
'aria-level': ariaLevel,
accessibilityLevel,
'aria-live': ariaLive,
accessibilityLiveRegion,
'aria-modal': ariaModal,
accessibilityModal,
'aria-multiline': ariaMultiline,
accessibilityMultiline,
'aria-multiselectable': ariaMultiSelectable,
accessibilityMultiSelectable,
'aria-orientation': ariaOrientation,
accessibilityOrientation,
'aria-owns': ariaOwns,
accessibilityOwns,
'aria-placeholder': ariaPlaceholder,
accessibilityPlaceholder,
'aria-posinset': ariaPosInSet,
accessibilityPosInSet,
'aria-pressed': ariaPressed,
accessibilityPressed,
'aria-readonly': ariaReadOnly,
accessibilityReadOnly,
'aria-required': ariaRequired,
accessibilityRequired,
/* eslint-disable */
role: ariaRole,
accessibilityRole,
/* eslint-enable */
'aria-roledescription': ariaRoleDescription,
accessibilityRoleDescription,
'aria-rowcount': ariaRowCount,
accessibilityRowCount,
'aria-rowindex': ariaRowIndex,
accessibilityRowIndex,
'aria-rowspan': ariaRowSpan,
accessibilityRowSpan,
'aria-selected': ariaSelected,
accessibilitySelected,
'aria-setsize': ariaSetSize,
accessibilitySetSize,
'aria-sort': ariaSort,
accessibilitySort,
'aria-valuemax': ariaValueMax,
accessibilityValueMax,
'aria-valuemin': ariaValueMin,
accessibilityValueMin,
'aria-valuenow': ariaValueNow,
accessibilityValueNow,
'aria-valuetext': ariaValueText,
accessibilityValueText,
dataSet,
focusable,
id,
nativeID,
pointerEvents,
style,
tabIndex,
testID,
// Rest
...domProps
} = props;
/*
if (accessibilityDisabled != null) {
warnOnce('accessibilityDisabled', `accessibilityDisabled is deprecated.`);
}
*/
const disabled = ariaDisabled || accessibilityDisabled;
const role = AccessibilityUtil.propsToAriaRole(props);
// ACCESSIBILITY
/*
if (accessibilityActiveDescendant != null) {
warnOnce(
'accessibilityActiveDescendant',
`accessibilityActiveDescendant is deprecated. Use aria-activedescendant.`
);
}
*/
const _ariaActiveDescendant =
ariaActiveDescendant != null
? ariaActiveDescendant
: accessibilityActiveDescendant;
if (_ariaActiveDescendant != null) {
domProps['aria-activedescendant'] = _ariaActiveDescendant;
}
/*
if (accessibilityAtomic != null) {
warnOnce(
'accessibilityAtomic',
`accessibilityAtomic is deprecated. Use aria-atomic.`
);
}
*/
const _ariaAtomic =
ariaAtomic != null ? ariaActiveDescendant : accessibilityAtomic;
if (_ariaAtomic != null) {
domProps['aria-atomic'] = _ariaAtomic;
}
/*
if (accessibilityAutoComplete != null) {
warnOnce(
'accessibilityAutoComplete',
`accessibilityAutoComplete is deprecated. Use aria-autocomplete.`
);
}
*/
const _ariaAutoComplete =
ariaAutoComplete != null ? ariaAutoComplete : accessibilityAutoComplete;
if (_ariaAutoComplete != null) {
domProps['aria-autocomplete'] = _ariaAutoComplete;
}
/*
if (accessibilityBusy != null) {
warnOnce(
'accessibilityBusy',
`accessibilityBusy is deprecated. Use aria-busy.`
);
}
*/
const _ariaBusy = ariaBusy != null ? ariaBusy : accessibilityBusy;
if (_ariaBusy != null) {
domProps['aria-busy'] = _ariaBusy;
}
/*
if (accessibilityChecked != null) {
warnOnce(
'accessibilityChecked',
`accessibilityChecked is deprecated. Use aria-checked.`
);
}
*/
const _ariaChecked = ariaChecked != null ? ariaChecked : accessibilityChecked;
if (_ariaChecked != null) {
domProps['aria-checked'] = _ariaChecked;
}
/*
if (accessibilityColumnCount != null) {
warnOnce(
'accessibilityColumnCount',
`accessibilityColumnCount is deprecated. Use aria-colcount.`
);
}
*/
const _ariaColumnCount =
ariaColumnCount != null ? ariaColumnCount : accessibilityColumnCount;
if (_ariaColumnCount != null) {
domProps['aria-colcount'] = _ariaColumnCount;
}
/*
if (accessibilityColumnIndex != null) {
warnOnce(
'accessibilityColumnIndex',
`accessibilityColumnIndex is deprecated. Use aria-colindex.`
);
}
*/
const _ariaColumnIndex =
ariaColumnIndex != null ? ariaColumnIndex : accessibilityColumnIndex;
if (_ariaColumnIndex != null) {
domProps['aria-colindex'] = _ariaColumnIndex;
}
/*
if (accessibilityColumnSpan != null) {
warnOnce(
'accessibilityColumnSpan',
`accessibilityColumnSpan is deprecated. Use aria-colspan.`
);
}
*/
const _ariaColumnSpan =
ariaColumnSpan != null ? ariaColumnSpan : accessibilityColumnSpan;
if (_ariaColumnSpan != null) {
domProps['aria-colspan'] = _ariaColumnSpan;
}
/*
if (accessibilityControls != null) {
warnOnce(
'accessibilityControls',
`accessibilityControls is deprecated. Use aria-controls.`
);
}
*/
const _ariaControls =
ariaControls != null ? ariaControls : accessibilityControls;
if (_ariaControls != null) {
domProps['aria-controls'] = processIDRefList(_ariaControls);
}
/*
if (accessibilityCurrent != null) {
warnOnce(
'accessibilityCurrent',
`accessibilityCurrent is deprecated. Use aria-current.`
);
}
*/
const _ariaCurrent = ariaCurrent != null ? ariaCurrent : accessibilityCurrent;
if (_ariaCurrent != null) {
domProps['aria-current'] = _ariaCurrent;
}
/*
if (accessibilityDescribedBy != null) {
warnOnce(
'accessibilityDescribedBy',
`accessibilityDescribedBy is deprecated. Use aria-describedby.`
);
}
*/
const _ariaDescribedBy =
ariaDescribedBy != null ? ariaDescribedBy : accessibilityDescribedBy;
if (_ariaDescribedBy != null) {
domProps['aria-describedby'] = processIDRefList(_ariaDescribedBy);
}
/*
if (accessibilityDetails != null) {
warnOnce(
'accessibilityDetails',
`accessibilityDetails is deprecated. Use aria-details.`
);
}
*/
const _ariaDetails = ariaDetails != null ? ariaDetails : accessibilityDetails;
if (_ariaDetails != null) {
domProps['aria-details'] = _ariaDetails;
}
if (disabled === true) {
domProps['aria-disabled'] = true;
// Enhance with native semantics
if (
elementType === 'button' ||
elementType === 'form' ||
elementType === 'input' ||
elementType === 'select' ||
elementType === 'textarea'
) {
domProps.disabled = true;
}
}
/*
if (accessibilityErrorMessage != null) {
warnOnce(
'accessibilityErrorMessage',
`accessibilityErrorMessage is deprecated. Use aria-errormessage.`
);
}
*/
const _ariaErrorMessage =
ariaErrorMessage != null ? ariaErrorMessage : accessibilityErrorMessage;
if (_ariaErrorMessage != null) {
domProps['aria-errormessage'] = _ariaErrorMessage;
}
/*
if (accessibilityExpanded != null) {
warnOnce(
'accessibilityExpanded',
`accessibilityExpanded is deprecated. Use aria-expanded.`
);
}
*/
const _ariaExpanded =
ariaExpanded != null ? ariaExpanded : accessibilityExpanded;
if (_ariaExpanded != null) {
domProps['aria-expanded'] = _ariaExpanded;
}
/*
if (accessibilityFlowTo != null) {
warnOnce(
'accessibilityFlowTo',
`accessibilityFlowTo is deprecated. Use aria-flowto.`
);
}
*/
const _ariaFlowTo = ariaFlowTo != null ? ariaFlowTo : accessibilityFlowTo;
if (_ariaFlowTo != null) {
domProps['aria-flowto'] = processIDRefList(_ariaFlowTo);
}
/*
if (accessibilityHasPopup != null) {
warnOnce(
'accessibilityHasPopup',
`accessibilityHasPopup is deprecated. Use aria-haspopup.`
);
}
*/
const _ariaHasPopup =
ariaHasPopup != null ? ariaHasPopup : accessibilityHasPopup;
if (_ariaHasPopup != null) {
domProps['aria-haspopup'] = _ariaHasPopup;
}
/*
if (accessibilityHidden != null) {
warnOnce(
'accessibilityHidden',
`accessibilityHidden is deprecated. Use aria-hidden.`
);
}
*/
const _ariaHidden = ariaHidden != null ? ariaHidden : accessibilityHidden;
if (_ariaHidden === true) {
domProps['aria-hidden'] = _ariaHidden;
}
/*
if (accessibilityInvalid != null) {
warnOnce(
'accessibilityInvalid',
`accessibilityInvalid is deprecated. Use aria-invalid.`
);
}
*/
const _ariaInvalid = ariaInvalid != null ? ariaInvalid : accessibilityInvalid;
if (_ariaInvalid != null) {
domProps['aria-invalid'] = _ariaInvalid;
}
/*
if (accessibilityKeyShortcuts != null) {
warnOnce(
'accessibilityKeyShortcuts',
`accessibilityKeyShortcuts is deprecated. Use aria-keyshortcuts.`
);
}
*/
const _ariaKeyShortcuts =
ariaKeyShortcuts != null ? ariaKeyShortcuts : accessibilityKeyShortcuts;
if (_ariaKeyShortcuts != null) {
domProps['aria-keyshortcuts'] = processIDRefList(_ariaKeyShortcuts);
}
/*
if (accessibilityLabel != null) {
warnOnce(
'accessibilityLabel',
`accessibilityLabel is deprecated. Use aria-label.`
);
}
*/
const _ariaLabel = ariaLabel != null ? ariaLabel : accessibilityLabel;
if (_ariaLabel != null) {
domProps['aria-label'] = _ariaLabel;
}
/*
if (accessibilityLabelledBy != null) {
warnOnce(
'accessibilityLabelledBy',
`accessibilityLabelledBy is deprecated. Use aria-labelledby.`
);
}
*/
const _ariaLabelledBy =
ariaLabelledBy != null ? ariaLabelledBy : accessibilityLabelledBy;
if (_ariaLabelledBy != null) {
domProps['aria-labelledby'] = processIDRefList(_ariaLabelledBy);
}
/*
if (accessibilityLevel != null) {
warnOnce(
'accessibilityLevel',
`accessibilityLevel is deprecated. Use aria-level.`
);
}
*/
const _ariaLevel = ariaLevel != null ? ariaLevel : accessibilityLevel;
if (_ariaLevel != null) {
domProps['aria-level'] = _ariaLevel;
}
/*
if (accessibilityLiveRegion != null) {
warnOnce(
'accessibilityLiveRegion',
`accessibilityLiveRegion is deprecated. Use aria-live.`
);
}
*/
const _ariaLive = ariaLive != null ? ariaLive : accessibilityLiveRegion;
if (_ariaLive != null) {
domProps['aria-live'] = _ariaLive === 'none' ? 'off' : _ariaLive;
}
/*
if (accessibilityModal != null) {
warnOnce(
'accessibilityModal',
`accessibilityModal is deprecated. Use aria-modal.`
);
}
*/
const _ariaModal = ariaModal != null ? ariaModal : accessibilityModal;
if (_ariaModal != null) {
domProps['aria-modal'] = _ariaModal;
}
/*
if (accessibilityMultiline != null) {
warnOnce(
'accessibilityMultiline',
`accessibilityMultiline is deprecated. Use aria-multiline.`
);
}
*/
const _ariaMultiline =
ariaMultiline != null ? ariaMultiline : accessibilityMultiline;
if (_ariaMultiline != null) {
domProps['aria-multiline'] = _ariaMultiline;
}
/*
if (accessibilityMultiSelectable != null) {
warnOnce(
'accessibilityMultiSelectable',
`accessibilityMultiSelectable is deprecated. Use aria-multiselectable.`
);
}
*/
const _ariaMultiSelectable =
ariaMultiSelectable != null
? ariaMultiSelectable
: accessibilityMultiSelectable;
if (_ariaMultiSelectable != null) {
domProps['aria-multiselectable'] = _ariaMultiSelectable;
}
/*
if (accessibilityOrientation != null) {
warnOnce(
'accessibilityOrientation',
`accessibilityOrientation is deprecated. Use aria-orientation.`
);
}
*/
const _ariaOrientation =
ariaOrientation != null ? ariaOrientation : accessibilityOrientation;
if (_ariaOrientation != null) {
domProps['aria-orientation'] = _ariaOrientation;
}
/*
if (accessibilityOwns != null) {
warnOnce(
'accessibilityOwns',
`accessibilityOwns is deprecated. Use aria-owns.`
);
}
*/
const _ariaOwns = ariaOwns != null ? ariaOwns : accessibilityOwns;
if (_ariaOwns != null) {
domProps['aria-owns'] = processIDRefList(_ariaOwns);
}
/*
if (accessibilityPlaceholder != null) {
warnOnce(
'accessibilityPlaceholder',
`accessibilityPlaceholder is deprecated. Use aria-placeholder.`
);
}
*/
const _ariaPlaceholder =
ariaPlaceholder != null ? ariaPlaceholder : accessibilityPlaceholder;
if (_ariaPlaceholder != null) {
domProps['aria-placeholder'] = _ariaPlaceholder;
}
/*
if (accessibilityPosInSet != null) {
warnOnce(
'accessibilityPosInSet',
`accessibilityPosInSet is deprecated. Use aria-posinset.`
);
}
*/
const _ariaPosInSet =
ariaPosInSet != null ? ariaPosInSet : accessibilityPosInSet;
if (_ariaPosInSet != null) {
domProps['aria-posinset'] = _ariaPosInSet;
}
/*
if (accessibilityPressed != null) {
warnOnce(
'accessibilityPressed',
`accessibilityPressed is deprecated. Use aria-pressed.`
);
}
*/
const _ariaPressed = ariaPressed != null ? ariaPressed : accessibilityPressed;
if (_ariaPressed != null) {
domProps['aria-pressed'] = _ariaPressed;
}
/*
if (accessibilityReadOnly != null) {
warnOnce(
'accessibilityReadOnly',
`accessibilityReadOnly is deprecated. Use aria-readonly.`
);
}
*/
const _ariaReadOnly =
ariaReadOnly != null ? ariaReadOnly : accessibilityReadOnly;
if (_ariaReadOnly != null) {
domProps['aria-readonly'] = _ariaReadOnly;
// Enhance with native semantics
if (
elementType === 'input' ||
elementType === 'select' ||
elementType === 'textarea'
) {
domProps.readOnly = true;
}
}
/*
if (accessibilityRequired != null) {
warnOnce(
'accessibilityRequired',
`accessibilityRequired is deprecated. Use aria-required.`
);
}
*/
const _ariaRequired =
ariaRequired != null ? ariaRequired : accessibilityRequired;
if (_ariaRequired != null) {
domProps['aria-required'] = _ariaRequired;
// Enhance with native semantics
if (
elementType === 'input' ||
elementType === 'select' ||
elementType === 'textarea'
) {
domProps.required = accessibilityRequired;
}
}
/*
if (accessibilityRole != null) {
warnOnce('accessibilityRole', `accessibilityRole is deprecated. Use role.`);
}
*/
if (role != null) {
// 'presentation' synonym has wider browser support
domProps['role'] = role === 'none' ? 'presentation' : role;
}
/*
if (accessibilityRoleDescription != null) {
warnOnce(
'accessibilityRoleDescription',
`accessibilityRoleDescription is deprecated. Use aria-roledescription.`
);
}
*/
const _ariaRoleDescription =
ariaRoleDescription != null
? ariaRoleDescription
: accessibilityRoleDescription;
if (_ariaRoleDescription != null) {
domProps['aria-roledescription'] = _ariaRoleDescription;
}
/*
if (accessibilityRowCount != null) {
warnOnce(
'accessibilityRowCount',
`accessibilityRowCount is deprecated. Use aria-rowcount.`
);
}
*/
const _ariaRowCount =
ariaRowCount != null ? ariaRowCount : accessibilityRowCount;
if (_ariaRowCount != null) {
domProps['aria-rowcount'] = _ariaRowCount;
}
/*
if (accessibilityRowIndex != null) {
warnOnce(
'accessibilityRowIndex',
`accessibilityRowIndex is deprecated. Use aria-rowindex.`
);
}
*/
const _ariaRowIndex =
ariaRowIndex != null ? ariaRowIndex : accessibilityRowIndex;
if (_ariaRowIndex != null) {
domProps['aria-rowindex'] = _ariaRowIndex;
}
/*
if (accessibilityRowSpan != null) {
warnOnce(
'accessibilityRowSpan',
`accessibilityRowSpan is deprecated. Use aria-rowspan.`
);
}
*/
const _ariaRowSpan = ariaRowSpan != null ? ariaRowSpan : accessibilityRowSpan;
if (_ariaRowSpan != null) {
domProps['aria-rowspan'] = _ariaRowSpan;
}
/*
if (accessibilitySelected != null) {
warnOnce(
'accessibilitySelected',
`accessibilitySelected is deprecated. Use aria-selected.`
);
}
*/
const _ariaSelected =
ariaSelected != null ? ariaSelected : accessibilitySelected;
if (_ariaSelected != null) {
domProps['aria-selected'] = _ariaSelected;
}
/*
if (accessibilitySetSize != null) {
warnOnce(
'accessibilitySetSize',
`accessibilitySetSize is deprecated. Use aria-setsize.`
);
}
*/
const _ariaSetSize = ariaSetSize != null ? ariaSetSize : accessibilitySetSize;
if (_ariaSetSize != null) {
domProps['aria-setsize'] = _ariaSetSize;
}
/*
if (accessibilitySort != null) {
warnOnce(
'accessibilitySort',
`accessibilitySort is deprecated. Use aria-sort.`
);
}
*/
const _ariaSort = ariaSort != null ? ariaSort : accessibilitySort;
if (_ariaSort != null) {
domProps['aria-sort'] = _ariaSort;
}
/*
if (accessibilityValueMax != null) {
warnOnce(
'accessibilityValueMax',
`accessibilityValueMax is deprecated. Use aria-valuemax.`
);
}
*/
const _ariaValueMax =
ariaValueMax != null ? ariaValueMax : accessibilityValueMax;
if (_ariaValueMax != null) {
domProps['aria-valuemax'] = _ariaValueMax;
}
/*
if (accessibilityValueMin != null) {
warnOnce(
'accessibilityValueMin',
`accessibilityValueMin is deprecated. Use aria-valuemin.`
);
}
*/
const _ariaValueMin =
ariaValueMin != null ? ariaValueMin : accessibilityValueMin;
if (_ariaValueMin != null) {
domProps['aria-valuemin'] = _ariaValueMin;
}
/*
if (accessibilityValueNow != null) {
warnOnce(
'accessibilityValueNow',
`accessibilityValueNow is deprecated. Use aria-valuenow.`
);
}
*/
const _ariaValueNow =
ariaValueNow != null ? ariaValueNow : accessibilityValueNow;
if (_ariaValueNow != null) {
domProps['aria-valuenow'] = _ariaValueNow;
}
/*
if (accessibilityValueText != null) {
warnOnce(
'accessibilityValueText',
`accessibilityValueText is deprecated. Use aria-valuetext.`
);
}
*/
const _ariaValueText =
ariaValueText != null ? ariaValueText : accessibilityValueText;
if (_ariaValueText != null) {
domProps['aria-valuetext'] = _ariaValueText;
}
// "dataSet" replaced with "data-*"
if (dataSet != null) {
for (const dataProp in dataSet) {
if (hasOwnProperty.call(dataSet, dataProp)) {
const dataName = hyphenateString(dataProp);
const dataValue = dataSet[dataProp];
if (dataValue != null) {
domProps[`data-${dataName}`] = dataValue;
}
}
}
}
// FOCUS
if (
tabIndex === 0 ||
tabIndex === '0' ||
tabIndex === -1 ||
tabIndex === '-1'
) {
domProps.tabIndex = tabIndex;
} else {
/*
if (focusable != null) {
warnOnce('focusable', `focusable is deprecated.`);
}
*/
// "focusable" indicates that an element may be a keyboard tab-stop.
if (focusable === false) {
domProps.tabIndex = '-1';
}
if (
// These native elements are keyboard focusable by default
elementType === 'a' ||
elementType === 'button' ||
elementType === 'input' ||
elementType === 'select' ||
elementType === 'textarea'
) {
if (focusable === false || accessibilityDisabled === true) {
domProps.tabIndex = '-1';
}
} else if (
// These roles are made keyboard focusable by default
role === 'button' ||
role === 'checkbox' ||
role === 'link' ||
role === 'radio' ||
role === 'textbox' ||
role === 'switch'
) {
if (focusable !== false) {
domProps.tabIndex = '0';
}
} else {
// Everything else must explicitly set the prop
if (focusable === true) {
domProps.tabIndex = '0';
}
}
}
// Resolve styles
if (pointerEvents != null) {
warnOnce(
'pointerEvents',
`props.pointerEvents is deprecated. Use style.pointerEvents`
);
}
const [className, inlineStyle] = StyleSheet(
[style, pointerEvents && pointerEventsStyles[pointerEvents]],
{
writingDirection: 'ltr',
...options
}
);
if (className) {
domProps.className = className;
}
if (inlineStyle) {
domProps.style = inlineStyle;
}
// OTHER
// Native element ID
/*
if (nativeID != null) {
warnOnce('nativeID', `nativeID is deprecated. Use id.`);
}
*/
const _id = id != null ? id : nativeID;
if (_id != null) {
domProps.id = _id;
}
// Automated test IDs
if (testID != null) {
domProps['data-testid'] = testID;
}
if (domProps.type == null && elementType === 'button') {
domProps.type = 'button';
}
return domProps;
};
export default createDOMProps;
@@ -0,0 +1,16 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import TextInputState from '../TextInputState';
const dismissKeyboard = () => {
TextInputState.blurTextInput(TextInputState.currentlyFocusedField());
};
export default dismissKeyboard;
@@ -0,0 +1,172 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
*/
export const defaultProps = {
children: true,
dataSet: true,
dir: true,
id: true,
ref: true,
suppressHydrationWarning: true,
tabIndex: true,
testID: true,
// @deprecated
focusable: true,
nativeID: true
};
export const accessibilityProps = {
'aria-activedescendant': true,
'aria-atomic': true,
'aria-autocomplete': true,
'aria-busy': true,
'aria-checked': true,
'aria-colcount': true,
'aria-colindex': true,
'aria-colspan': true,
'aria-controls': true,
'aria-current': true,
'aria-describedby': true,
'aria-details': true,
'aria-disabled': true,
'aria-errormessage': true,
'aria-expanded': true,
'aria-flowto': true,
'aria-haspopup': true,
'aria-hidden': true,
'aria-invalid': true,
'aria-keyshortcuts': true,
'aria-label': true,
'aria-labelledby': true,
'aria-level': true,
'aria-live': true,
'aria-modal': true,
'aria-multiline': true,
'aria-multiselectable': true,
'aria-orientation': true,
'aria-owns': true,
'aria-placeholder': true,
'aria-posinset': true,
'aria-pressed': true,
'aria-readonly': true,
'aria-required': true,
inert: true,
role: true,
'aria-roledescription': true,
'aria-rowcount': true,
'aria-rowindex': true,
'aria-rowspan': true,
'aria-selected': true,
'aria-setsize': true,
'aria-sort': true,
'aria-valuemax': true,
'aria-valuemin': true,
'aria-valuenow': true,
'aria-valuetext': true,
// @deprecated
accessibilityActiveDescendant: true,
accessibilityAtomic: true,
accessibilityAutoComplete: true,
accessibilityBusy: true,
accessibilityChecked: true,
accessibilityColumnCount: true,
accessibilityColumnIndex: true,
accessibilityColumnSpan: true,
accessibilityControls: true,
accessibilityCurrent: true,
accessibilityDescribedBy: true,
accessibilityDetails: true,
accessibilityDisabled: true,
accessibilityErrorMessage: true,
accessibilityExpanded: true,
accessibilityFlowTo: true,
accessibilityHasPopup: true,
accessibilityHidden: true,
accessibilityInvalid: true,
accessibilityKeyShortcuts: true,
accessibilityLabel: true,
accessibilityLabelledBy: true,
accessibilityLevel: true,
accessibilityLiveRegion: true,
accessibilityModal: true,
accessibilityMultiline: true,
accessibilityMultiSelectable: true,
accessibilityOrientation: true,
accessibilityOwns: true,
accessibilityPlaceholder: true,
accessibilityPosInSet: true,
accessibilityPressed: true,
accessibilityReadOnly: true,
accessibilityRequired: true,
accessibilityRole: true,
accessibilityRoleDescription: true,
accessibilityRowCount: true,
accessibilityRowIndex: true,
accessibilityRowSpan: true,
accessibilitySelected: true,
accessibilitySetSize: true,
accessibilitySort: true,
accessibilityValueMax: true,
accessibilityValueMin: true,
accessibilityValueNow: true,
accessibilityValueText: true
};
export const clickProps = {
onClick: true,
onAuxClick: true,
onContextMenu: true,
onGotPointerCapture: true,
onLostPointerCapture: true,
onPointerCancel: true,
onPointerDown: true,
onPointerEnter: true,
onPointerMove: true,
onPointerLeave: true,
onPointerOut: true,
onPointerOver: true,
onPointerUp: true
};
export const focusProps = {
onBlur: true,
onFocus: true
};
export const keyboardProps = {
onKeyDown: true,
onKeyDownCapture: true,
onKeyUp: true,
onKeyUpCapture: true
};
export const mouseProps = {
onMouseDown: true,
onMouseEnter: true,
onMouseLeave: true,
onMouseMove: true,
onMouseOver: true,
onMouseOut: true,
onMouseUp: true
};
export const touchProps = {
onTouchCancel: true,
onTouchCancelCapture: true,
onTouchEnd: true,
onTouchEndCapture: true,
onTouchMove: true,
onTouchMoveCapture: true,
onTouchStart: true,
onTouchStartCapture: true
};
export const styleProps = {
style: true
};
@@ -0,0 +1,19 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/
const getBoundingClientRect = (node: ?HTMLElement): void | ClientRect => {
if (node != null) {
const isElement = node.nodeType === 1; /* Node.ELEMENT_NODE */
if (isElement && typeof node.getBoundingClientRect === 'function') {
return node.getBoundingClientRect();
}
}
};
export default getBoundingClientRect;
@@ -0,0 +1,19 @@
/**
* Copyright (c) Nicolas Gallagher
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export default function isSelectionValid(): boolean {
const selection = window.getSelection();
const string = selection.toString();
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
const isTextNode =
(anchorNode && anchorNode.nodeType === window.Node.TEXT_NODE) ||
(focusNode && focusNode.nodeType === window.Node.TEXT_NODE);
return string.length >= 1 && string !== '\n' && isTextNode;
}
@@ -0,0 +1,16 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
const isWebColor = (color: string): boolean =>
color === 'currentcolor' ||
color === 'currentColor' ||
color === 'inherit' ||
color.indexOf('var(') === 0;
export default isWebColor;
@@ -0,0 +1,35 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/
import * as React from 'react';
export default function mergeRefs(
...args: $ReadOnlyArray<React.ElementRef<any>>
): (node: HTMLElement | null) => void {
return function forwardRef(node: HTMLElement | null) {
args.forEach((ref: React.ElementRef<any>) => {
if (ref == null) {
return;
}
if (typeof ref === 'function') {
ref(node);
return;
}
if (typeof ref === 'object') {
ref.current = node;
return;
}
console.error(
`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(
ref
)}`
);
});
};
}
@@ -0,0 +1,215 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import { addEventListener } from '../addEventListener';
import canUseDOM from '../canUseDom';
export type Modality = 'keyboard' | 'mouse' | 'touch' | 'pen';
const supportsPointerEvent = () =>
!!(typeof window !== 'undefined' && window.PointerEvent != null);
let activeModality = 'keyboard';
let modality = 'keyboard';
let previousModality;
let previousActiveModality;
let isEmulatingMouseEvents = false;
const listeners = new Set();
const KEYBOARD = 'keyboard';
const MOUSE = 'mouse';
const TOUCH = 'touch';
const BLUR = 'blur';
const CONTEXTMENU = 'contextmenu';
const FOCUS = 'focus';
const KEYDOWN = 'keydown';
const MOUSEDOWN = 'mousedown';
const MOUSEMOVE = 'mousemove';
const MOUSEUP = 'mouseup';
const POINTERDOWN = 'pointerdown';
const POINTERMOVE = 'pointermove';
const SCROLL = 'scroll';
const SELECTIONCHANGE = 'selectionchange';
const TOUCHCANCEL = 'touchcancel';
const TOUCHMOVE = 'touchmove';
const TOUCHSTART = 'touchstart';
const VISIBILITYCHANGE = 'visibilitychange';
const bubbleOptions = { passive: true };
const captureOptions = { capture: true, passive: true };
function restoreModality() {
if (previousModality != null || previousActiveModality != null) {
if (previousModality != null) {
modality = previousModality;
previousModality = null;
}
if (previousActiveModality != null) {
activeModality = previousActiveModality;
previousActiveModality = null;
}
callListeners();
}
}
function onBlurWindow() {
previousModality = modality;
previousActiveModality = activeModality;
activeModality = KEYBOARD;
modality = KEYBOARD;
callListeners();
// for fallback events
isEmulatingMouseEvents = false;
}
function onFocusWindow() {
restoreModality();
}
function onKeyDown(event) {
if (event.metaKey || event.altKey || event.ctrlKey) {
return;
}
if (modality !== KEYBOARD) {
modality = KEYBOARD;
activeModality = KEYBOARD;
callListeners();
}
}
function onVisibilityChange() {
if (document.visibilityState !== 'hidden') {
restoreModality();
}
}
function onPointerish(event: any) {
const eventType = event.type;
if (supportsPointerEvent()) {
if (eventType === POINTERDOWN) {
if (activeModality !== event.pointerType) {
modality = event.pointerType;
activeModality = event.pointerType;
callListeners();
}
return;
}
if (eventType === POINTERMOVE) {
if (modality !== event.pointerType) {
modality = event.pointerType;
callListeners();
}
return;
}
}
// Fallback for non-PointerEvent environment
else {
if (!isEmulatingMouseEvents) {
if (eventType === MOUSEDOWN) {
if (activeModality !== MOUSE) {
modality = MOUSE;
activeModality = MOUSE;
callListeners();
}
}
if (eventType === MOUSEMOVE) {
if (modality !== MOUSE) {
modality = MOUSE;
callListeners();
}
}
}
// Flag when browser may produce emulated events
if (eventType === TOUCHSTART) {
isEmulatingMouseEvents = true;
if (event.touches && event.touches.length > 1) {
isEmulatingMouseEvents = false;
}
if (activeModality !== TOUCH) {
modality = TOUCH;
activeModality = TOUCH;
callListeners();
}
return;
}
// Remove flag after emulated events are finished or cancelled, and if an
// event occurs that cuts short a touch event sequence.
if (
eventType === CONTEXTMENU ||
eventType === MOUSEUP ||
eventType === SELECTIONCHANGE ||
eventType === SCROLL ||
eventType === TOUCHCANCEL ||
eventType === TOUCHMOVE
) {
isEmulatingMouseEvents = false;
}
}
}
if (canUseDOM) {
// Window events
addEventListener(window, BLUR, onBlurWindow, bubbleOptions);
addEventListener(window, FOCUS, onFocusWindow, bubbleOptions);
// Must be capture phase because 'stopPropagation' might prevent these
// events bubbling to the document.
addEventListener(document, KEYDOWN, onKeyDown, captureOptions);
addEventListener(
document,
VISIBILITYCHANGE,
onVisibilityChange,
captureOptions
);
addEventListener(document, POINTERDOWN, onPointerish, captureOptions);
addEventListener(document, POINTERMOVE, onPointerish, captureOptions);
// Fallback events
addEventListener(document, CONTEXTMENU, onPointerish, captureOptions);
addEventListener(document, MOUSEDOWN, onPointerish, captureOptions);
addEventListener(document, MOUSEMOVE, onPointerish, captureOptions);
addEventListener(document, MOUSEUP, onPointerish, captureOptions);
addEventListener(document, TOUCHCANCEL, onPointerish, captureOptions);
addEventListener(document, TOUCHMOVE, onPointerish, captureOptions);
addEventListener(document, TOUCHSTART, onPointerish, captureOptions);
addEventListener(document, SELECTIONCHANGE, onPointerish, captureOptions);
addEventListener(document, SCROLL, onPointerish, captureOptions);
}
function callListeners() {
const value = { activeModality, modality };
listeners.forEach((listener) => {
listener(value);
});
}
export function getActiveModality(): Modality {
return activeModality;
}
export function getModality(): Modality {
return modality;
}
export function addModalityListener(
listener: ({ activeModality: Modality, modality: Modality }) => void
): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function testOnly_resetActiveModality() {
isEmulatingMouseEvents = false;
activeModality = KEYBOARD;
modality = KEYBOARD;
}
@@ -0,0 +1,28 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @noflow
*/
const CSS_UNIT_RE = /^[+-]?\d*(?:\.\d+)?(?:[Ee][+-]?\d+)?(%|\w*)/;
const getUnit = (str) => str.match(CSS_UNIT_RE)[1];
const isNumeric = (n) => {
return !isNaN(parseFloat(n)) && isFinite(n);
};
const multiplyStyleLengthValue = (value: string | number, multiple) => {
if (typeof value === 'string') {
const number = parseFloat(value) * multiple;
const unit = getUnit(value);
return `${number}${unit}`;
} else if (isNumeric(value)) {
return value * multiple;
}
};
export default multiplyStyleLengthValue;
@@ -0,0 +1,34 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import isWebColor from '../isWebColor';
import processColor from '../../exports/processColor';
const normalizeColor = (
color?: number | string,
opacity?: number = 1
): void | string => {
if (color == null) return;
if (typeof color === 'string' && isWebColor(color)) {
return color;
}
const colorInt = processColor(color);
if (colorInt != null) {
const r = (colorInt >> 16) & 255;
const g = (colorInt >> 8) & 255;
const b = colorInt & 255;
const a = ((colorInt >> 24) & 255) / 255;
const alpha = (a * opacity).toFixed(2);
return `rgba(${r},${g},${b},${alpha})`;
}
};
export default normalizeColor;
+20
View File
@@ -0,0 +1,20 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export default function pick(obj: Object, list: { [string]: boolean }): Object {
const nextObj = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (list[key] === true) {
nextObj[key] = obj[key];
}
}
}
return nextObj;
}
@@ -0,0 +1,17 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import createPrefixer from 'inline-style-prefixer/lib/createPrefixer';
import staticData from './static';
type StyleModifier = (style: Object) => Object;
const prefixAll: StyleModifier = createPrefixer(staticData);
export default prefixAll;
@@ -0,0 +1,69 @@
import crossFade from 'inline-style-prefixer/lib/plugins/crossFade';
import imageSet from 'inline-style-prefixer/lib/plugins/imageSet';
import logical from 'inline-style-prefixer/lib/plugins/logical';
import position from 'inline-style-prefixer/lib/plugins/position';
import sizing from 'inline-style-prefixer/lib/plugins/sizing';
import transition from 'inline-style-prefixer/lib/plugins/transition';
const w = ['Webkit'];
const m = ['Moz'];
const wm = ['Webkit', 'Moz'];
const wms = ['Webkit', 'ms'];
const wmms = ['Webkit', 'Moz', 'ms'];
export default {
plugins: [crossFade, imageSet, logical, position, sizing, transition],
prefixMap: {
appearance: wmms,
userSelect: wm,
textEmphasisPosition: wms,
textEmphasis: wms,
textEmphasisStyle: wms,
textEmphasisColor: wms,
boxDecorationBreak: wms,
clipPath: w,
maskImage: wms,
maskMode: wms,
maskRepeat: wms,
maskPosition: wms,
maskClip: wms,
maskOrigin: wms,
maskSize: wms,
maskComposite: wms,
mask: wms,
maskBorderSource: wms,
maskBorderMode: wms,
maskBorderSlice: wms,
maskBorderWidth: wms,
maskBorderOutset: wms,
maskBorderRepeat: wms,
maskBorder: wms,
maskType: wms,
textDecorationStyle: w,
textDecorationSkip: w,
textDecorationLine: w,
textDecorationColor: w,
filter: w,
breakAfter: w,
breakBefore: w,
breakInside: w,
columnCount: w,
columnFill: w,
columnGap: w,
columnRule: w,
columnRuleColor: w,
columnRuleStyle: w,
columnRuleWidth: w,
columns: w,
columnSpan: w,
columnWidth: w,
backdropFilter: w,
hyphens: w,
flowInto: w,
flowFrom: w,
regionFragment: w,
textOrientation: w,
tabSize: m,
fontKerning: w,
textSizeAdjust: w
}
};
@@ -0,0 +1,38 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import canUseDOM from '../canUseDom';
const _requestIdleCallback = function (cb: Function, options?: Object) {
return setTimeout(() => {
const start = Date.now();
cb({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50 - (Date.now() - start));
}
});
}, 1);
};
const _cancelIdleCallback = function (id) {
clearTimeout(id);
};
const isSupported =
canUseDOM && typeof window.requestIdleCallback !== 'undefined';
const requestIdleCallback: (cb: any, options?: any) => TimeoutID = isSupported
? window.requestIdleCallback
: _requestIdleCallback;
const cancelIdleCallback: (TimeoutID) => void = isSupported
? window.cancelIdleCallback
: _cancelIdleCallback;
export default requestIdleCallback;
export { cancelIdleCallback };
@@ -0,0 +1,52 @@
/* eslint-disable */
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* From React 16.0.0
* @noflow
*/
import isUnitlessNumber from '../unitlessNumbers';
/**
* Convert a value into the proper css writable value. The style name `name`
* should be logical (no hyphens), as specified
* in `CSSProperty.isUnitlessNumber`.
*
* @param {string} name CSS property name such as `topMargin`.
* @param {*} value CSS property value such as `10px`.
* @return {string} Normalized style value with dimensions applied.
*/
function dangerousStyleValue(name, value, isCustomProperty) {
// Note that we've removed escapeTextForBrowser() calls here since the
// whole string will be escaped when the attribute is injected into
// the markup. If you provide unsafe user data here they can inject
// arbitrary CSS which may be problematic (I couldn't repro this):
// https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet
// http://www.thespanner.co.uk/2007/11/26/ultimate-xss-css-injection/
// This is not an XSS hole but instead a potential CSS injection issue
// which has lead to a greater discussion about how we're going to
// trust URLs moving forward. See #2115901
var isEmpty = value == null || typeof value === 'boolean' || value === '';
if (isEmpty) {
return '';
}
if (
!isCustomProperty &&
typeof value === 'number' &&
value !== 0 &&
!(isUnitlessNumber.hasOwnProperty(name) && isUnitlessNumber[name])
) {
return value + 'px'; // Presumes implicit 'px' suffix for unitless numbers
}
return ('' + value).trim();
}
export default dangerousStyleValue;
@@ -0,0 +1,45 @@
/* eslint-disable */
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* From React 16.3.0
* @noflow
*/
import dangerousStyleValue from './dangerousStyleValue';
/**
* Sets the value for multiple styles on a node. If a value is specified as
* '' (empty string), the corresponding style property will be unset.
*
* @param {DOMElement} node
* @param {object} styles
*/
function setValueForStyles(node, styles) {
const style = node.style;
for (let styleName in styles) {
if (!styles.hasOwnProperty(styleName)) {
continue;
}
const isCustomProperty = styleName.indexOf('--') === 0;
const styleValue = dangerousStyleValue(
styleName,
styles[styleName],
isCustomProperty
);
if (styleName === 'float') {
styleName = 'cssFloat';
}
if (isCustomProperty) {
style.setProperty(styleName, styleValue);
} else {
style[styleName] = styleValue;
}
}
}
export default setValueForStyles;
@@ -0,0 +1,76 @@
/**
* Copyright (c) Nicolas Gallagher.
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
const unitlessNumbers = {
animationIterationCount: true,
aspectRatio: true,
borderImageOutset: true,
borderImageSlice: true,
borderImageWidth: true,
boxFlex: true,
boxFlexGroup: true,
boxOrdinalGroup: true,
columnCount: true,
flex: true,
flexGrow: true,
flexOrder: true,
flexPositive: true,
flexShrink: true,
flexNegative: true,
fontWeight: true,
gridRow: true,
gridRowEnd: true,
gridRowGap: true,
gridRowStart: true,
gridColumn: true,
gridColumnEnd: true,
gridColumnGap: true,
gridColumnStart: true,
lineClamp: true,
opacity: true,
order: true,
orphans: true,
tabSize: true,
widows: true,
zIndex: true,
zoom: true,
// SVG-related
fillOpacity: true,
floodOpacity: true,
stopOpacity: true,
strokeDasharray: true,
strokeDashoffset: true,
strokeMiterlimit: true,
strokeOpacity: true,
strokeWidth: true,
// transform types
scale: true,
scaleX: true,
scaleY: true,
scaleZ: true,
// RN properties
shadowOpacity: true
};
/**
* Support style names that may come passed in prefixed by adding permutations
* of vendor prefixes.
*/
const prefixes = ['ms', 'Moz', 'O', 'Webkit'];
const prefixKey = (prefix: string, key: string) => {
return prefix + key.charAt(0).toUpperCase() + key.substring(1);
};
Object.keys(unitlessNumbers).forEach((prop) => {
prefixes.forEach((prefix) => {
unitlessNumbers[prefixKey(prefix, prop)] = unitlessNumbers[prop];
});
});
export default unitlessNumbers;
@@ -0,0 +1,95 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type { ElementRef } from 'react';
import type { LayoutEvent } from '../../types';
import useLayoutEffect from '../useLayoutEffect';
import UIManager from '../../exports/UIManager';
import canUseDOM from '../canUseDom';
const DOM_LAYOUT_HANDLER_NAME = '__reactLayoutHandler';
let didWarn = !canUseDOM;
let resizeObserver = null;
function getResizeObserver(): ?ResizeObserver {
if (canUseDOM && typeof window.ResizeObserver !== 'undefined') {
if (resizeObserver == null) {
resizeObserver = new window.ResizeObserver(function (entries) {
entries.forEach((entry) => {
const node = entry.target;
const onLayout = node[DOM_LAYOUT_HANDLER_NAME];
if (typeof onLayout === 'function') {
// We still need to measure the view because browsers don't yet provide
// border-box dimensions in the entry
UIManager.measure(node, (x, y, width, height, left, top) => {
const event: LayoutEvent = {
// $FlowFixMe
nativeEvent: {
layout: { x, y, width, height, left, top }
},
timeStamp: Date.now()
};
Object.defineProperty(event.nativeEvent, 'target', {
enumerable: true,
get: () => entry.target
});
onLayout(event);
});
}
});
});
}
} else if (!didWarn) {
if (
process.env.NODE_ENV !== 'production' &&
process.env.NODE_ENV !== 'test'
) {
console.warn(
'onLayout relies on ResizeObserver which is not supported by your browser. ' +
'Please include a polyfill, e.g., https://github.com/que-etc/resize-observer-polyfill.'
);
didWarn = true;
}
}
return resizeObserver;
}
export default function useElementLayout(
ref: ElementRef<any>,
onLayout?: ?(e: LayoutEvent) => void
) {
const observer = getResizeObserver();
useLayoutEffect(() => {
const node = ref.current;
if (node != null) {
node[DOM_LAYOUT_HANDLER_NAME] = onLayout;
}
}, [ref, onLayout]);
// Observing is done in a separate effect to avoid this effect running
// when 'onLayout' changes.
useLayoutEffect(() => {
const node = ref.current;
if (node != null && observer != null) {
if (typeof node[DOM_LAYOUT_HANDLER_NAME] === 'function') {
observer.observe(node);
} else {
observer.unobserve(node);
}
}
return () => {
if (node != null && observer != null) {
observer.unobserve(node);
}
};
}, [ref, observer]);
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 { addEventListener } from '../addEventListener';
import useLayoutEffect from '../useLayoutEffect';
import useStable from '../useStable';
type Callback = null | ((any) => void);
type AddListener = (
target: EventTarget,
listener: null | ((any) => void)
) => () => void;
/**
* This can be used with any event type include custom events.
*
* const click = useEvent('click', options);
* useEffect(() => {
* click.setListener(target, onClick);
* return () => click.clear();
* }).
*/
export default function useEvent(
eventType: string,
options?: ?{
capture?: boolean,
passive?: boolean,
once?: boolean
}
): AddListener {
const targetListeners = useStable(() => new Map());
const addListener = useStable(() => {
return (target: EventTarget, callback: Callback) => {
const removeTargetListener = targetListeners.get(target);
if (removeTargetListener != null) {
removeTargetListener();
}
if (callback == null) {
targetListeners.delete(target);
callback = () => {};
}
const removeEventListener = addEventListener(
target,
eventType,
callback,
options
);
targetListeners.set(target, removeEventListener);
return removeEventListener;
};
});
useLayoutEffect(() => {
return () => {
targetListeners.forEach((removeListener) => {
removeListener();
});
targetListeners.clear();
};
}, [targetListeners]);
return addListener;
}
@@ -0,0 +1,197 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 { getModality } from '../modality';
import useEvent from '../useEvent';
import useLayoutEffect from '../useLayoutEffect';
/**
* Types
*/
export type HoverEventsConfig = {
contain?: ?boolean,
disabled?: ?boolean,
onHoverStart?: ?(e: any) => void,
onHoverChange?: ?(bool: boolean) => void,
onHoverUpdate?: ?(e: any) => void,
onHoverEnd?: ?(e: any) => void
};
/**
* Implementation
*/
const emptyObject = {};
const opts = { passive: true };
const lockEventType = 'react-gui:hover:lock';
const unlockEventType = 'react-gui:hover:unlock';
const supportsPointerEvent = () =>
!!(typeof window !== 'undefined' && window.PointerEvent != null);
function dispatchCustomEvent(
target: EventTarget,
type: string,
payload?: {
bubbles?: boolean,
cancelable?: boolean,
detail?: { [key: string]: mixed }
}
) {
const event = document.createEvent('CustomEvent');
const { bubbles = true, cancelable = true, detail } = payload || emptyObject;
event.initCustomEvent(type, bubbles, cancelable, detail);
target.dispatchEvent(event);
}
// This accounts for the non-PointerEvent fallback events.
function getPointerType(event) {
const { pointerType } = event;
return pointerType != null ? pointerType : getModality();
}
export default function useHover(
targetRef: any,
config: HoverEventsConfig
): void {
const {
contain,
disabled,
onHoverStart,
onHoverChange,
onHoverUpdate,
onHoverEnd
} = config;
const canUsePE = supportsPointerEvent();
const addMoveListener = useEvent(
canUsePE ? 'pointermove' : 'mousemove',
opts
);
const addEnterListener = useEvent(
canUsePE ? 'pointerenter' : 'mouseenter',
opts
);
const addLeaveListener = useEvent(
canUsePE ? 'pointerleave' : 'mouseleave',
opts
);
// These custom events are used to implement the "contain" prop.
const addLockListener = useEvent(lockEventType, opts);
const addUnlockListener = useEvent(unlockEventType, opts);
useLayoutEffect(() => {
const target = targetRef.current;
if (target !== null) {
/**
* End the hover gesture
*/
const hoverEnd = function (e) {
if (onHoverEnd != null) {
onHoverEnd(e);
}
if (onHoverChange != null) {
onHoverChange(false);
}
// Remove the listeners once finished.
addMoveListener(target, null);
addLeaveListener(target, null);
};
/**
* Leave element
*/
const leaveListener = function (e) {
const target = targetRef.current;
if (target != null && getPointerType(e) !== 'touch') {
if (contain) {
dispatchCustomEvent(target, unlockEventType);
}
hoverEnd(e);
}
};
/**
* Move within element
*/
const moveListener = function (e) {
if (getPointerType(e) !== 'touch') {
if (onHoverUpdate != null) {
// Not all browsers have these properties
if (e.x == null) {
e.x = e.clientX;
}
if (e.y == null) {
e.y = e.clientY;
}
onHoverUpdate(e);
}
}
};
/**
* Start the hover gesture
*/
const hoverStart = function (e) {
if (onHoverStart != null) {
onHoverStart(e);
}
if (onHoverChange != null) {
onHoverChange(true);
}
// Set the listeners needed for the rest of the hover gesture.
if (onHoverUpdate != null) {
addMoveListener(target, !disabled ? moveListener : null);
}
addLeaveListener(target, !disabled ? leaveListener : null);
};
/**
* Enter element
*/
const enterListener = function (e) {
const target = targetRef.current;
if (target != null && getPointerType(e) !== 'touch') {
if (contain) {
dispatchCustomEvent(target, lockEventType);
}
hoverStart(e);
const lockListener = function (lockEvent) {
if (lockEvent.target !== target) {
hoverEnd(e);
}
};
const unlockListener = function (lockEvent) {
if (lockEvent.target !== target) {
hoverStart(e);
}
};
addLockListener(target, !disabled ? lockListener : null);
addUnlockListener(target, !disabled ? unlockListener : null);
}
};
addEnterListener(target, !disabled ? enterListener : null);
}
}, [
addEnterListener,
addMoveListener,
addLeaveListener,
addLockListener,
addUnlockListener,
contain,
disabled,
onHoverStart,
onHoverChange,
onHoverUpdate,
onHoverEnd,
targetRef
]);
}
@@ -0,0 +1,20 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* useLayoutEffect throws an error on the server. On the few occasions where is
* problematic, use this hook.
*
* @flow
*/
import { useEffect, useLayoutEffect } from 'react';
import canUseDOM from '../canUseDom';
const useLayoutEffectImpl: typeof useLayoutEffect = canUseDOM
? useLayoutEffect
: useEffect;
export default useLayoutEffectImpl;
@@ -0,0 +1,60 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/
import type { Node } from 'react';
import React, { createContext, useContext } from 'react';
import { isLocaleRTL } from './isLocaleRTL';
type Locale = string;
type WritingDirection = 'ltr' | 'rtl';
type LocaleValue = {
// Locale writing direction.
direction: WritingDirection,
// Locale BCP47 language code: https://www.ietf.org/rfc/bcp/bcp47.txt
locale: ?Locale
};
type ProviderProps = {
...LocaleValue,
children: any
};
const defaultLocale = {
direction: 'ltr',
locale: 'en-US'
};
const LocaleContext = createContext<LocaleValue>(defaultLocale);
export function getLocaleDirection(locale: Locale): WritingDirection {
return isLocaleRTL(locale) ? 'rtl' : 'ltr';
}
export function LocaleProvider(props: ProviderProps): Node {
const { direction, locale, children } = props;
const needsContext = direction || locale;
return needsContext ? (
<LocaleContext.Provider
children={children}
value={{
direction: locale ? getLocaleDirection(locale) : direction,
locale
}}
/>
) : (
children
);
}
export function useLocaleContext(): LocaleValue {
return useContext(LocaleContext);
}
@@ -0,0 +1,81 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
const rtlScripts = new Set([
'Arab',
'Syrc',
'Samr',
'Mand',
'Thaa',
'Mend',
'Nkoo',
'Adlm',
'Rohg',
'Hebr'
]);
const rtlLangs = new Set([
'ae', // Avestan
'ar', // Arabic
'arc', // Aramaic
'bcc', // Southern Balochi
'bqi', // Bakthiari
'ckb', // Sorani
'dv', // Dhivehi
'fa',
'far', // Persian
'glk', // Gilaki
'he',
'iw', // Hebrew
'khw', // Khowar
'ks', // Kashmiri
'ku', // Kurdish
'mzn', // Mazanderani
'nqo', // N'Ko
'pnb', // Western Punjabi
'ps', // Pashto
'sd', // Sindhi
'ug', // Uyghur
'ur', // Urdu
'yi' // Yiddish
]);
const cache = new Map();
/**
* Determine the writing direction of a locale
*/
export function isLocaleRTL(locale: string): boolean {
const cachedRTL = cache.get(locale);
if (cachedRTL) {
return cachedRTL;
}
let isRTL = false;
// $FlowFixMe
if (Intl.Locale) {
try {
// $FlowFixMe
const script = new Intl.Locale(locale).maximize().script;
isRTL = rtlScripts.has(script);
} catch {
// RangeError: Incorrect locale information provided
// Fallback to inferring from language
const lang = locale.split('-')[0];
isRTL = rtlLangs.has(lang);
}
} else {
// Fallback to inferring from language
const lang = locale.split('-')[0];
isRTL = rtlLangs.has(lang);
}
cache.set(locale, isRTL);
return isRTL;
}
@@ -0,0 +1,21 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
*/
import * as React from 'react';
import mergeRefs from '../mergeRefs';
export default function useMergeRefs(
...args: $ReadOnlyArray<React.ElementRef<any>>
): (node: HTMLElement | null) => void {
return React.useMemo(
() => mergeRefs(...args),
// eslint-disable-next-line
[...args]
);
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type { GenericStyleProp } from '../../types';
import type { ViewProps } from '../../exports/View';
import UIManager from '../../exports/UIManager';
import useStable from '../useStable';
/**
* Adds non-standard methods to the hode element. This is temporarily until an
* API like `ReactNative.measure(hostRef, callback)` is added to React Native.
*/
export default function usePlatformMethods({
pointerEvents,
style
}: {
style?: GenericStyleProp<*>,
pointerEvents?: $PropertyType<ViewProps, 'pointerEvents'>
}): (hostNode: any) => void {
// Avoid creating a new ref on every render.
const ref = useStable(() => (hostNode: any) => {
if (hostNode != null) {
hostNode.measure = (callback) => UIManager.measure(hostNode, callback);
hostNode.measureLayout = (relativeToNode, success, failure) =>
UIManager.measureLayout(hostNode, relativeToNode, failure, success);
hostNode.measureInWindow = (callback) =>
UIManager.measureInWindow(hostNode, callback);
}
});
return ref;
}
@@ -0,0 +1,625 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
'use strict';
type ClickEvent = any;
type KeyboardEvent = any;
type ResponderEvent = any;
export type PressResponderConfig = $ReadOnly<{|
// The gesture can be interrupted by a parent gesture, e.g., scroll.
// Defaults to true.
cancelable?: ?boolean,
// Whether to disable initialization of the press gesture.
disabled?: ?boolean,
// Duration (in addition to `delayPressStart`) after which a press gesture is
// considered a long press gesture. Defaults to 500 (milliseconds).
delayLongPress?: ?number,
// Duration to wait after press down before calling `onPressStart`.
delayPressStart?: ?number,
// Duration to wait after letting up before calling `onPressEnd`.
delayPressEnd?: ?number,
// Called when a long press gesture has been triggered.
onLongPress?: ?(event: ResponderEvent) => void,
// Called when a press gestute has been triggered.
onPress?: ?(event: ClickEvent) => void,
// Called when the press is activated to provide visual feedback.
onPressChange?: ?(event: ResponderEvent) => void,
// Called when the press is activated to provide visual feedback.
onPressStart?: ?(event: ResponderEvent) => void,
// Called when the press location moves. (This should rarely be used.)
onPressMove?: ?(event: ResponderEvent) => void,
// Called when the press is deactivated to undo visual feedback.
onPressEnd?: ?(event: ResponderEvent) => void
|}>;
export type EventHandlers = $ReadOnly<{|
onClick: (event: ClickEvent) => void,
onContextMenu: (event: ClickEvent) => void,
onKeyDown: (event: KeyboardEvent) => void,
onResponderGrant: (event: ResponderEvent) => void,
onResponderMove: (event: ResponderEvent) => void,
onResponderRelease: (event: ResponderEvent) => void,
onResponderTerminate: (event: ResponderEvent) => void,
onResponderTerminationRequest: (event: ResponderEvent) => boolean,
onStartShouldSetResponder: (event: ResponderEvent) => boolean
|}>;
type TouchState =
| 'NOT_RESPONDER'
| 'RESPONDER_INACTIVE_PRESS_START'
| 'RESPONDER_ACTIVE_PRESS_START'
| 'RESPONDER_ACTIVE_LONG_PRESS_START'
| 'ERROR';
type TouchSignal =
| 'DELAY'
| 'RESPONDER_GRANT'
| 'RESPONDER_RELEASE'
| 'RESPONDER_TERMINATED'
| 'LONG_PRESS_DETECTED';
const DELAY = 'DELAY';
const ERROR = 'ERROR';
const LONG_PRESS_DETECTED = 'LONG_PRESS_DETECTED';
const NOT_RESPONDER = 'NOT_RESPONDER';
const RESPONDER_ACTIVE_LONG_PRESS_START = 'RESPONDER_ACTIVE_LONG_PRESS_START';
const RESPONDER_ACTIVE_PRESS_START = 'RESPONDER_ACTIVE_PRESS_START';
const RESPONDER_INACTIVE_PRESS_START = 'RESPONDER_INACTIVE_PRESS_START';
const RESPONDER_GRANT = 'RESPONDER_GRANT';
const RESPONDER_RELEASE = 'RESPONDER_RELEASE';
const RESPONDER_TERMINATED = 'RESPONDER_TERMINATED';
const Transitions = Object.freeze({
NOT_RESPONDER: {
DELAY: ERROR,
RESPONDER_GRANT: RESPONDER_INACTIVE_PRESS_START,
RESPONDER_RELEASE: ERROR,
RESPONDER_TERMINATED: ERROR,
LONG_PRESS_DETECTED: ERROR
},
RESPONDER_INACTIVE_PRESS_START: {
DELAY: RESPONDER_ACTIVE_PRESS_START,
RESPONDER_GRANT: ERROR,
RESPONDER_RELEASE: NOT_RESPONDER,
RESPONDER_TERMINATED: NOT_RESPONDER,
LONG_PRESS_DETECTED: ERROR
},
RESPONDER_ACTIVE_PRESS_START: {
DELAY: ERROR,
RESPONDER_GRANT: ERROR,
RESPONDER_RELEASE: NOT_RESPONDER,
RESPONDER_TERMINATED: NOT_RESPONDER,
LONG_PRESS_DETECTED: RESPONDER_ACTIVE_LONG_PRESS_START
},
RESPONDER_ACTIVE_LONG_PRESS_START: {
DELAY: ERROR,
RESPONDER_GRANT: ERROR,
RESPONDER_RELEASE: NOT_RESPONDER,
RESPONDER_TERMINATED: NOT_RESPONDER,
LONG_PRESS_DETECTED: RESPONDER_ACTIVE_LONG_PRESS_START
},
ERROR: {
DELAY: NOT_RESPONDER,
RESPONDER_GRANT: RESPONDER_INACTIVE_PRESS_START,
RESPONDER_RELEASE: NOT_RESPONDER,
RESPONDER_TERMINATED: NOT_RESPONDER,
LONG_PRESS_DETECTED: NOT_RESPONDER
}
});
const getElementRole = (element) => element.getAttribute('role');
const getElementType = (element) => element.tagName.toLowerCase();
const isActiveSignal = (signal) =>
signal === RESPONDER_ACTIVE_PRESS_START ||
signal === RESPONDER_ACTIVE_LONG_PRESS_START;
const isButtonRole = (element) => getElementRole(element) === 'button';
const isPressStartSignal = (signal) =>
signal === RESPONDER_INACTIVE_PRESS_START ||
signal === RESPONDER_ACTIVE_PRESS_START ||
signal === RESPONDER_ACTIVE_LONG_PRESS_START;
const isTerminalSignal = (signal) =>
signal === RESPONDER_TERMINATED || signal === RESPONDER_RELEASE;
const isValidKeyPress = (event) => {
const { key, target } = event;
const isSpacebar = key === ' ' || key === 'Spacebar';
const isButtonish =
getElementType(target) === 'button' || isButtonRole(target);
return key === 'Enter' || (isSpacebar && isButtonish);
};
const DEFAULT_LONG_PRESS_DELAY_MS = 450; // 500 - 50
const DEFAULT_PRESS_DELAY_MS = 50;
/**
* =========================== PressResponder Tutorial ===========================
*
* The `PressResponder` class helps you create press interactions by analyzing the
* geometry of elements and observing when another responder (e.g. ScrollView)
* has stolen the touch lock. It offers hooks for your component to provide
* interaction feedback to the user:
*
* - When a press has activated (e.g. highlight an element)
* - When a press has deactivated (e.g. un-highlight an element)
* - When a press sould trigger an action, meaning it activated and deactivated
* while within the geometry of the element without the lock being stolen.
*
* A high quality interaction isn't as simple as you might think. There should
* be a slight delay before activation. Moving your finger beyond an element's
* bounds should trigger deactivation, but moving the same finger back within an
* element's bounds should trigger reactivation.
*
* In order to use `PressResponder`, do the following:
*
* const pressResponder = new PressResponder(config);
*
* 2. Choose the rendered component who should collect the press events. On that
* element, spread `pressability.getEventHandlers()` into its props.
*
* return (
* <View {...this.state.pressResponder.getEventHandlers()} />
* );
*
* 3. Reset `PressResponder` when your component unmounts.
*
* componentWillUnmount() {
* this.state.pressResponder.reset();
* }
*
* ==================== Implementation Details ====================
*
* `PressResponder` only assumes that there exists a `HitRect` node. The `PressRect`
* is an abstract box that is extended beyond the `HitRect`.
*
* # Geometry
*
* ┌────────────────────────┐
* │ ┌──────────────────┐ │ - Presses start anywhere within `HitRect`.
* │ │ ┌────────────┐ │ │
* │ │ │ VisualRect │ │ │
* │ │ └────────────┘ │ │ - When pressed down for sufficient amount of time
* │ │ HitRect │ │ before letting up, `VisualRect` activates.
* │ └──────────────────┘ │
* │ Out Region o │
* └────────────────────│───┘
* └────── When the press is released outside the `HitRect`,
* the responder is NOT eligible for a "press".
*
* # State Machine
*
* ┌───────────────┐ ◀──── RESPONDER_RELEASE
* │ NOT_RESPONDER │
* └───┬───────────┘ ◀──── RESPONDER_TERMINATED
* │
* │ RESPONDER_GRANT (HitRect)
* │
* ▼
* ┌─────────────────────┐ ┌───────────────────┐ ┌───────────────────┐
* │ RESPONDER_INACTIVE_ │ DELAY │ RESPONDER_ACTIVE_ │ T + DELAY │ RESPONDER_ACTIVE_ │
* │ PRESS_START ├────────▶ │ PRESS_START ├────────────▶ │ LONG_PRESS_START │
* └─────────────────────┘ └───────────────────┘ └───────────────────┘
*
* T + DELAY => LONG_PRESS_DELAY + DELAY
*
* Not drawn are the side effects of each transition. The most important side
* effect is the invocation of `onLongPress`. Only when the browser produces a
* `click` event is `onPress` invoked.
*/
export default class PressResponder {
_config: PressResponderConfig;
_eventHandlers: ?EventHandlers = null;
_isPointerTouch: ?boolean = false;
_longPressDelayTimeout: ?TimeoutID = null;
_longPressDispatched: ?boolean = false;
_pressDelayTimeout: ?TimeoutID = null;
_pressOutDelayTimeout: ?TimeoutID = null;
_selectionTerminated: ?boolean;
_touchActivatePosition: ?$ReadOnly<{|
pageX: number,
pageY: number
|}>;
_touchState: TouchState = NOT_RESPONDER;
_responderElement: ?HTMLElement = null;
constructor(config: PressResponderConfig) {
this.configure(config);
}
configure(config: PressResponderConfig): void {
this._config = config;
}
/**
* Resets any pending timers. This should be called on unmount.
*/
reset(): void {
this._cancelLongPressDelayTimeout();
this._cancelPressDelayTimeout();
this._cancelPressOutDelayTimeout();
}
/**
* Returns a set of props to spread into the interactive element.
*/
getEventHandlers(): EventHandlers {
if (this._eventHandlers == null) {
this._eventHandlers = this._createEventHandlers();
}
return this._eventHandlers;
}
_createEventHandlers(): EventHandlers {
const start = (event: ResponderEvent, shouldDelay?: boolean): void => {
event.persist();
this._cancelPressOutDelayTimeout();
this._longPressDispatched = false;
this._selectionTerminated = false;
this._touchState = NOT_RESPONDER;
this._isPointerTouch = event.nativeEvent.type === 'touchstart';
this._receiveSignal(RESPONDER_GRANT, event);
const delayPressStart = normalizeDelay(
this._config.delayPressStart,
0,
DEFAULT_PRESS_DELAY_MS
);
if (shouldDelay !== false && delayPressStart > 0) {
this._pressDelayTimeout = setTimeout(() => {
this._receiveSignal(DELAY, event);
}, delayPressStart);
} else {
this._receiveSignal(DELAY, event);
}
const delayLongPress = normalizeDelay(
this._config.delayLongPress,
10,
DEFAULT_LONG_PRESS_DELAY_MS
);
this._longPressDelayTimeout = setTimeout(() => {
this._handleLongPress(event);
}, delayLongPress + delayPressStart);
};
const end = (event: ResponderEvent): void => {
this._receiveSignal(RESPONDER_RELEASE, event);
};
const keyupHandler = (event: KeyboardEvent) => {
const { onPress } = this._config;
const { target } = event;
if (this._touchState !== NOT_RESPONDER && isValidKeyPress(event)) {
end(event);
document.removeEventListener('keyup', keyupHandler);
const role = target.getAttribute('role');
const elementType = getElementType(target);
const isNativeInteractiveElement =
role === 'link' ||
elementType === 'a' ||
elementType === 'button' ||
elementType === 'input' ||
elementType === 'select' ||
elementType === 'textarea';
const isActiveElement = this._responderElement === target;
if (onPress != null && !isNativeInteractiveElement && isActiveElement) {
onPress(event);
}
this._responderElement = null;
}
};
return {
onStartShouldSetResponder: (event): boolean => {
const { disabled } = this._config;
if (disabled && isButtonRole(event.currentTarget)) {
event.stopPropagation();
}
if (disabled == null) {
return true;
}
return !disabled;
},
onKeyDown: (event) => {
const { disabled } = this._config;
const { key, target } = event;
if (!disabled && isValidKeyPress(event)) {
if (this._touchState === NOT_RESPONDER) {
start(event, false);
this._responderElement = target;
// Listen to 'keyup' on document to account for situations where
// focus is moved to another element during 'keydown'.
document.addEventListener('keyup', keyupHandler);
}
const isSpacebarKey = key === ' ' || key === 'Spacebar';
const role = getElementRole(target);
const isButtonLikeRole = role === 'button' || role === 'menuitem';
if (
isSpacebarKey &&
isButtonLikeRole &&
getElementType(target) !== 'button'
) {
// Prevent spacebar scrolling the window if using non-native button
event.preventDefault();
}
event.stopPropagation();
}
},
onResponderGrant: (event) => start(event),
onResponderMove: (event) => {
if (this._config.onPressMove != null) {
this._config.onPressMove(event);
}
const touch = getTouchFromResponderEvent(event);
if (this._touchActivatePosition != null) {
const deltaX = this._touchActivatePosition.pageX - touch.pageX;
const deltaY = this._touchActivatePosition.pageY - touch.pageY;
if (Math.hypot(deltaX, deltaY) > 10) {
this._cancelLongPressDelayTimeout();
}
}
},
onResponderRelease: (event) => end(event),
onResponderTerminate: (event) => {
if (event.nativeEvent.type === 'selectionchange') {
this._selectionTerminated = true;
}
this._receiveSignal(RESPONDER_TERMINATED, event);
},
onResponderTerminationRequest: (event): boolean => {
const { cancelable, disabled, onLongPress } = this._config;
// If `onLongPress` is provided, don't terminate on `contextmenu` as default
// behavior will be prevented for non-mouse pointers.
if (
!disabled &&
onLongPress != null &&
this._isPointerTouch &&
event.nativeEvent.type === 'contextmenu'
) {
return false;
}
if (cancelable == null) {
return true;
}
return cancelable;
},
// NOTE: this diverges from react-native in 3 significant ways:
// * The `onPress` callback is not connected to the responder system (the native
// `click` event must be used but is dispatched in many scenarios where no pointers
// are on the screen.) Therefore, it's possible for `onPress` to be called without
// `onPress{Start,End}` being called first.
// * The `onPress` callback is only be called on the first ancestor of the native
// `click` target that is using the PressResponder.
// * The event's `nativeEvent` is a `MouseEvent` not a `TouchEvent`.
onClick: (event: any): void => {
const { disabled, onPress } = this._config;
if (!disabled) {
// If long press dispatched, cancel default click behavior.
// If the responder terminated because text was selected during the gesture,
// cancel the default click behavior.
event.stopPropagation();
if (this._longPressDispatched || this._selectionTerminated) {
event.preventDefault();
} else if (onPress != null && event.altKey === false) {
onPress(event);
}
} else {
if (isButtonRole(event.currentTarget)) {
event.stopPropagation();
}
}
},
// If `onLongPress` is provided and a touch pointer is being used, prevent the
// default context menu from opening.
onContextMenu: (event: any): void => {
const { disabled, onLongPress } = this._config;
if (!disabled) {
if (
onLongPress != null &&
this._isPointerTouch &&
!event.defaultPrevented
) {
event.preventDefault();
event.stopPropagation();
}
} else {
if (isButtonRole(event.currentTarget)) {
event.stopPropagation();
}
}
}
};
}
/**
* Receives a state machine signal, performs side effects of the transition
* and stores the new state. Validates the transition as well.
*/
_receiveSignal(signal: TouchSignal, event: ResponderEvent): void {
const prevState = this._touchState;
let nextState = null;
if (Transitions[prevState] != null) {
nextState = Transitions[prevState][signal];
}
if (this._touchState === NOT_RESPONDER && signal === RESPONDER_RELEASE) {
return;
}
if (nextState == null || nextState === ERROR) {
console.error(
`PressResponder: Invalid signal ${signal} for state ${prevState} on responder`
);
} else if (prevState !== nextState) {
this._performTransitionSideEffects(prevState, nextState, signal, event);
this._touchState = nextState;
}
}
/**
* Performs a transition between touchable states and identify any activations
* or deactivations (and callback invocations).
*/
_performTransitionSideEffects(
prevState: TouchState,
nextState: TouchState,
signal: TouchSignal,
event: ResponderEvent
): void {
if (isTerminalSignal(signal)) {
// Pressable suppression of contextmenu on windows.
// On Windows, the contextmenu is displayed after pointerup.
// https://github.com/necolas/react-native-web/issues/2296
setTimeout(() => {
this._isPointerTouch = false;
}, 0);
this._touchActivatePosition = null;
this._cancelLongPressDelayTimeout();
}
if (isPressStartSignal(prevState) && signal === LONG_PRESS_DETECTED) {
const { onLongPress } = this._config;
// Long press is not supported for keyboards because 'click' can be dispatched
// immediately (and multiple times) after 'keydown'.
if (onLongPress != null && event.nativeEvent.key == null) {
onLongPress(event);
this._longPressDispatched = true;
}
}
const isPrevActive = isActiveSignal(prevState);
const isNextActive = isActiveSignal(nextState);
if (!isPrevActive && isNextActive) {
this._activate(event);
} else if (isPrevActive && !isNextActive) {
this._deactivate(event);
}
if (isPressStartSignal(prevState) && signal === RESPONDER_RELEASE) {
const { onLongPress, onPress } = this._config;
if (onPress != null) {
const isPressCanceledByLongPress =
onLongPress != null &&
prevState === RESPONDER_ACTIVE_LONG_PRESS_START;
if (!isPressCanceledByLongPress) {
// If we never activated (due to delays), activate and deactivate now.
if (!isNextActive && !isPrevActive) {
this._activate(event);
this._deactivate(event);
}
}
}
}
this._cancelPressDelayTimeout();
}
_activate(event: ResponderEvent): void {
const { onPressChange, onPressStart } = this._config;
const touch = getTouchFromResponderEvent(event);
this._touchActivatePosition = {
pageX: touch.pageX,
pageY: touch.pageY
};
if (onPressStart != null) {
onPressStart(event);
}
if (onPressChange != null) {
onPressChange(true);
}
}
_deactivate(event: ResponderEvent): void {
const { onPressChange, onPressEnd } = this._config;
function end() {
if (onPressEnd != null) {
onPressEnd(event);
}
if (onPressChange != null) {
onPressChange(false);
}
}
const delayPressEnd = normalizeDelay(this._config.delayPressEnd);
if (delayPressEnd > 0) {
this._pressOutDelayTimeout = setTimeout(() => {
end();
}, delayPressEnd);
} else {
end();
}
}
_handleLongPress(event: ResponderEvent): void {
if (
this._touchState === RESPONDER_ACTIVE_PRESS_START ||
this._touchState === RESPONDER_ACTIVE_LONG_PRESS_START
) {
this._receiveSignal(LONG_PRESS_DETECTED, event);
}
}
_cancelLongPressDelayTimeout(): void {
if (this._longPressDelayTimeout != null) {
clearTimeout(this._longPressDelayTimeout);
this._longPressDelayTimeout = null;
}
}
_cancelPressDelayTimeout(): void {
if (this._pressDelayTimeout != null) {
clearTimeout(this._pressDelayTimeout);
this._pressDelayTimeout = null;
}
}
_cancelPressOutDelayTimeout(): void {
if (this._pressOutDelayTimeout != null) {
clearTimeout(this._pressOutDelayTimeout);
this._pressOutDelayTimeout = null;
}
}
}
function normalizeDelay(delay: ?number, min = 0, fallback = 0): number {
return Math.max(min, delay ?? fallback);
}
function getTouchFromResponderEvent(event: ResponderEvent) {
const { changedTouches, touches } = event.nativeEvent;
if (touches != null && touches.length > 0) {
return touches[0];
}
if (changedTouches != null && changedTouches.length > 0) {
return changedTouches[0];
}
return event.nativeEvent;
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
'use strict';
import type { EventHandlers, PressResponderConfig } from './PressResponder';
import PressResponder from './PressResponder';
import { useDebugValue, useEffect, useRef } from 'react';
export default function usePressEvents(
hostRef: any,
config: PressResponderConfig
): EventHandlers {
const pressResponderRef = useRef<?PressResponder>(null);
if (pressResponderRef.current == null) {
pressResponderRef.current = new PressResponder(config);
}
const pressResponder = pressResponderRef.current;
// Re-configure to use the current node and configuration.
useEffect(() => {
pressResponder.configure(config);
}, [config, pressResponder]);
// Reset the `pressResponder` when cleanup needs to occur. This is
// a separate effect because we do not want to rest the responder when `config` changes.
useEffect(() => {
return () => {
pressResponder.reset();
};
}, [pressResponder]);
useDebugValue(config);
return pressResponder.getEventHandlers();
}
@@ -0,0 +1,209 @@
# Responder Event System
The Responder Event System is a gesture system that manages the lifecycle of gestures. It was designed for [React Native](https://reactnative.dev/docs/next/gesture-responder-system) to help support the development of native-quality gestures. A pointer may transition through several different phases while the gesture is being determined (e.g., tap, scroll, swipe) and be used simultaneously alongside other pointers. The Responder Event System provides a single, global “interaction lock” on views. For a view to become the “responder” means that pointer interactions are exclusive to that view and none other. A view can negotiate to become the “responder” without requiring knowledge of other views.
NOTE: Although the responder events mention only `touches`, this is for historical reasons (originating from React Native); the system does respond to mouse events which are converted into emulated touches. In the future we could adjust the events to align more with the `PointerEvent` API which would remove this ambiguity and surface more information to developers (e.g., `pointerType`).
## How it works
A view can become the "responder" after the following native events: `scroll`, `selectionchange`, `touchstart`, `touchmove`, `mousedown`, `mousemove`. If nothing is already the "responder", the event propagates to (capture) and from (bubble) the event target until a view returns `true` for `on*ShouldSetResponder(Capture)`.
If something is *already* the responder, the negotiation event propagates to (capture) and from (bubble) the lowest common ancestor of the event target and the current responder. Then negotiation happens between the current responder and the view that wants to become the responder.
## API
### useResponderEvents
The `useResponderEvents` hook takes a ref to a host element and an object of responder callbacks.
```js
function View(props) {
const hostRef = useRef(null);
const callbacks: ResponderCallbacks = {
onMoveShouldSetResponder: props.onMoveShouldSetResponder,
onMoveShouldSetResponderCapture: props.onMoveShouldSetResponderCapture,
onResponderEnd: props.onResponderEnd,
onResponderGrant: props.onResponderGrant,
onResponderMove: props.onResponderMove,
onResponderReject: props.onResponderReject,
onResponderRelease: props.onResponderRelease,
onResponderStart: props.onResponderStart,
onResponderTerminate: props.onResponderTerminate,
onResponderTerminationRequest: props.onResponderTerminationRequest,
onScrollShouldSetResponder: props.onScrollShouldSetResponder,
onScrollShouldSetResponderCapture: props.onScrollShouldSetResponderCapture,
onSelectionChangeShouldSetResponder: props.onSelectionChangeShouldSetResponder,
onSelectionChangeShouldSetResponderCapture: props.onSelectionChangeShouldSetResponderCapture,
onStartShouldSetResponder: props.onStartShouldSetResponder,
onStartShouldSetResponderCapture: props.onStartShouldSetResponderCapture
}
useResponderEvents(hostRef, callbacks);
return (
<div ref={hostRef} />
);
}
```
### Responder negotiation
A view can become the responder by using the negotiation methods. During the capture phase the deepest node is called last. During the bubble phase the deepest node is called first. The capture phase should be used when a view wants to prevent a descendant from becoming the responder. The first view to return `true` from any of the `on*ShouldSetResponderCapture`/`on*ShouldSetResponder` methods will either become the responder or enter into negotiation with the existing responder.
N.B. If `stopPropagation` is called on the event for any of the negotiation methods, it only stops further negotiation within the Responder System. It will not stop the propagation of the native event (which has already bubbled to the `document` by this time.)
#### onStartShouldSetResponder / onStartShouldSetResponderCapture
On pointer down, should this view attempt to become the responder? If the view is not the responder, these methods may be called for every pointer start on the view.
#### onMoveShouldSetResponder / onMoveShouldSetResponderCapture
On pointer move, should this view attempt to become the responder? If the view is not the responder, these methods may be called for every pointer move on the view.
#### onScrollShouldSetResponder / onScrollShouldSetResponderCapture
On scroll, should this view attempt to become the responder? If the view is not the responder, these methods may be called for every scroll on the view.
#### onSelectionChangeShouldSetResponder / onSelectionChangeShouldSetResponderCapture
On text selection change, should this view attempt to become the responder? Does not capture or bubble and is only called on the view that is the first ancestor of the selection `anchorNode`.
#### onResponderTerminationRequest
The view is the responder, but another view now wants to become the responder. Should this view release the responder? Returning `true` allows the responder to be released.
### Responder transfer
If a view returns `true` for a negotiation method then it will either become the responder (if none exists) or be involved in the responder transfer. The following methods are called only for the views involved in the responder transfer (i.e., no bubbling.)
#### onResponderGrant
The view is granted the responder and is now responding to pointer events. The lifecycle methods will be called for this view. This is the point at which you should provide visual feedback for users that the interaction has begun.
#### onResponderReject
The view was not granted the responder. It was rejected because another view is already the responder and will not release it.
#### onResponderTerminate
The responder has been taken from this view. It may have been taken by another view after a call to `onResponderTerminationRequest`, or it might have been taken by the browser without asking (e.g., window blur, document scroll, context menu open). This is the point at which you should provide visual feedback for users that the interaction has been cancelled.
### Responder lifecycle
If a view is the responder, the following methods will be called only for this view (i.e., no bubbling.) These methods are *always* bookended by `onResponderGrant` (before) and either `onResponderRelease` or `onResponderTerminate` (after).
#### onResponderStart
A pointer down event occured on the screen. The responder is notified of all start events, even if the pointer target is not this view (i.e., additional pointers are being used). Therefore, this method may be called multiple times while the view is the responder.
#### onResponderMove
A pointer move event occured on the screen. The responder is notified of all move events, even if the pointer target is not this view (i.e., additional pointers are being used). Therefore, this method may be called multiple times while the view is the responder.
#### onResponderEnd
A pointer up event occured on the screen. The responder is notified of all end events, even if the pointer target is not this view (i.e., additional pointers are being used). Therefore, this method may be called multiple times while the view is the responder.
#### onResponderRelease
As soon as there are no more pointers that *started* inside descendants of the responder, this method is called on the responder and the interaction lock is released. This is the point at which you should provide visual feedback for users that the interaction is over.
### Responder events
Every method is called with a responder event. The type of the event is shown below. The `currentTarget` of the event is always `null` for the negotiation methods. Data dervied from the native events, e.g., the native `target` and pointer coordinates, can be used to determine the return value of the negotiation methods, etc.
## Types
```js
type ResponderCallbacks = {
onResponderEnd?: ?(e: ResponderEvent) => void,
onResponderGrant?: ?(e: ResponderEvent) => void,
onResponderMove?: ?(e: ResponderEvent) => void,
onResponderRelease?: ?(e: ResponderEvent) => void,
onResponderReject?: ?(e: ResponderEvent) => void,
onResponderStart?: ?(e: ResponderEvent) => void,
onResponderTerminate?: ?(e: ResponderEvent) => void,
onResponderTerminationRequest?: ?(e: ResponderEvent) => boolean,
onStartShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onStartShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
onMoveShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onMoveShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
onScrollShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onScrollShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
onSelectionChangeShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onSelectionChangeShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean
};
```
```js
type ResponderEvent = {
// The DOM element acting as the responder view
currentTarget: ?HTMLElement,
defaultPrevented: boolean,
eventPhase: ?number,
isDefaultPrevented: () => boolean,
isPropagationStopped: () => boolean,
isTrusted: boolean,
preventDefault: () => void,
stopPropagation: () => void,
nativeEvent: TouchEvent,
persist: () => void,
target: HTMLElement,
timeStamp: number,
touchHistory: $ReadOnly<{|
indexOfSingleActiveTouch: number,
mostRecentTimeStamp: number,
numberActiveTouches: number,
touchBank: Array<{|
currentPageX: number,
currentPageY: number,
currentTimeStamp: number,
previousPageX: number,
previousPageY: number,
previousTimeStamp: number,
startPageX: number,
startPageY: number,
startTimeStamp: number,
touchActive: boolean
|}>
|}>
};
```
```js
type TouchEvent = {
// Array of all touch events that have changed since the last event
changedTouches: Array<Touch>,
force: number,
// ID of the touch
identifier: number,
// The X position of the pointer, relative to the currentTarget
locationX: number,
// The Y position of the pointer, relative to the currentTarget
locationY: number,
// The X position of the pointer, relative to the page
pageX: number,
// The Y position of the pointer, relative to the page
pageY: number,
// The DOM element receiving the pointer event
target: HTMLElement,
// A time identifier for the pointer, useful for velocity calculation
timestamp: number,
// Array of all current touches on the screen
touches: Array<Touch>
};
```
```js
type Touch = {
force: number,
identifier: number,
locationX: number,
locationY: number,
pageX: number,
pageY: number,
target: HTMLElement,
timestamp: number
};
```
@@ -0,0 +1,83 @@
/**
* Copyright (c) Nicolas Gallagher
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export type Touch = {
force: number,
identifier: number,
// The locationX and locationY properties are non-standard additions
locationX: any,
locationY: any,
pageX: number,
pageY: number,
target: any,
// Touches in a list have a timestamp property
timestamp: number
};
export type TouchEvent = {
altKey: boolean,
ctrlKey: boolean,
metaKey: boolean,
shiftKey: boolean,
// TouchList is an array in the Responder system
changedTouches: Array<Touch>,
force: number,
// React Native adds properties to the "nativeEvent that are usually only found on W3C Touches ‾\_(ツ)_/‾
identifier: number,
locationX: any,
locationY: any,
pageX: number,
pageY: number,
target: any,
// The timestamp has a lowercase "s" in the Responder system
timestamp: number,
// TouchList is an array in the Responder system
touches: Array<Touch>
};
export const BLUR = 'blur';
export const CONTEXT_MENU = 'contextmenu';
export const FOCUS_OUT = 'focusout';
export const MOUSE_DOWN = 'mousedown';
export const MOUSE_MOVE = 'mousemove';
export const MOUSE_UP = 'mouseup';
export const MOUSE_CANCEL = 'dragstart';
export const TOUCH_START = 'touchstart';
export const TOUCH_MOVE = 'touchmove';
export const TOUCH_END = 'touchend';
export const TOUCH_CANCEL = 'touchcancel';
export const SCROLL = 'scroll';
export const SELECT = 'select';
export const SELECTION_CHANGE = 'selectionchange';
export function isStartish(eventType: mixed): boolean {
return eventType === TOUCH_START || eventType === MOUSE_DOWN;
}
export function isMoveish(eventType: mixed): boolean {
return eventType === TOUCH_MOVE || eventType === MOUSE_MOVE;
}
export function isEndish(eventType: mixed): boolean {
return (
eventType === TOUCH_END || eventType === MOUSE_UP || isCancelish(eventType)
);
}
export function isCancelish(eventType: mixed): boolean {
return eventType === TOUCH_CANCEL || eventType === MOUSE_CANCEL;
}
export function isScroll(eventType: mixed): boolean {
return eventType === SCROLL;
}
export function isSelectionChange(eventType: mixed): boolean {
return eventType === SELECT || eventType === SELECTION_CHANGE;
}
@@ -0,0 +1,688 @@
/**
* Copyright (c) Nicolas Gallagher
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
/**
* RESPONDER EVENT SYSTEM
*
* A single, global "interaction lock" on views. For a view to be the "responder" means
* that pointer interactions are exclusive to that view and none other. The "interaction
* lock" can be transferred (only) to ancestors of the current "responder" as long as
* pointers continue to be active.
*
* Responder being granted:
*
* A view can become the "responder" after the following events:
* * "pointerdown" (implemented using "touchstart", "mousedown")
* * "pointermove" (implemented using "touchmove", "mousemove")
* * "scroll" (while a pointer is down)
* * "selectionchange" (while a pointer is down)
*
* If nothing is already the "responder", the event propagates to (capture) and from
* (bubble) the event target until a view returns `true` for
* `on*ShouldSetResponder(Capture)`.
*
* If something is already the responder, the event propagates to (capture) and from
* (bubble) the lowest common ancestor of the event target and the current "responder".
* Then negotiation happens between the current "responder" and a view that wants to
* become the "responder": see the timing diagram below.
*
* (NOTE: Scrolled views either automatically become the "responder" or release the
* "interaction lock". A native scroll view that isn't built on top of the responder
* system must result in the current "responder" being notified that it no longer has
* the "interaction lock" - the native system has taken over.
*
* Responder being released:
*
* As soon as there are no more active pointers that *started* inside descendants
* of the *current* "responder", an `onResponderRelease` event is dispatched to the
* current "responder", and the responder lock is released.
*
* Typical sequence of events:
* * startShouldSetResponder
* * responderGrant/Reject
* * responderStart
* * responderMove
* * responderEnd
* * responderRelease
*/
/* Negotiation Performed
+-----------------------+
/ \
Process low level events to + Current Responder + wantsResponderID
determine who to perform negot-| (if any exists at all) |
iation/transition | Otherwise just pass through|
-------------------------------+----------------------------+------------------+
Bubble to find first ID | |
to return true:wantsResponderID| |
| |
+--------------+ | |
| onTouchStart | | |
+------+-------+ none | |
| return| |
+-----------v-------------+true| +------------------------+ |
|onStartShouldSetResponder|----->| onResponderStart (cur) |<-----------+
+-----------+-------------+ | +------------------------+ | |
| | | +--------+-------+
| returned true for| false:REJECT +-------->|onResponderReject
| wantsResponderID | | | +----------------+
| (now attempt | +------------------+-----+ |
| handoff) | | onResponder | |
+------------------->| TerminationRequest | |
| +------------------+-----+ |
| | | +----------------+
| true:GRANT +-------->|onResponderGrant|
| | +--------+-------+
| +------------------------+ | |
| | onResponderTerminate |<-----------+
| +------------------+-----+ |
| | | +----------------+
| +-------->|onResponderStart|
| | +----------------+
Bubble to find first ID | |
to return true:wantsResponderID| |
| |
+-------------+ | |
| onTouchMove | | |
+------+------+ none | |
| return| |
+-----------v-------------+true| +------------------------+ |
|onMoveShouldSetResponder |----->| onResponderMove (cur) |<-----------+
+-----------+-------------+ | +------------------------+ | |
| | | +--------+-------+
| returned true for| false:REJECT +-------->|onResponderReject
| wantsResponderID | | | +----------------+
| (now attempt | +------------------+-----+ |
| handoff) | | onResponder | |
+------------------->| TerminationRequest| |
| +------------------+-----+ |
| | | +----------------+
| true:GRANT +-------->|onResponderGrant|
| | +--------+-------+
| +------------------------+ | |
| | onResponderTerminate |<-----------+
| +------------------+-----+ |
| | | +----------------+
| +-------->|onResponderMove |
| | +----------------+
| |
| |
Some active touch started| |
inside current responder | +------------------------+ |
+------------------------->| onResponderEnd | |
| | +------------------------+ |
+---+---------+ | |
| onTouchEnd | | |
+---+---------+ | |
| | +------------------------+ |
+------------------------->| onResponderEnd | |
No active touches started| +-----------+------------+ |
inside current responder | | |
| v |
| +------------------------+ |
| | onResponderRelease | |
| +------------------------+ |
| |
+ + */
import type { ResponderEvent } from './createResponderEvent';
import createResponderEvent from './createResponderEvent';
import {
isCancelish,
isEndish,
isMoveish,
isScroll,
isSelectionChange,
isStartish
} from './ResponderEventTypes';
import {
getLowestCommonAncestor,
getResponderPaths,
hasTargetTouches,
hasValidSelection,
isPrimaryPointerDown,
setResponderId
} from './utils';
import { ResponderTouchHistoryStore } from './ResponderTouchHistoryStore';
import canUseDOM from '../canUseDom';
/* ------------ TYPES ------------ */
type ResponderId = number;
type ActiveResponderInstance = {
id: ResponderId,
idPath: Array<number>,
node: any
};
type EmptyResponderInstance = {
id: null,
idPath: null,
node: null
};
type ResponderInstance = ActiveResponderInstance | EmptyResponderInstance;
export type ResponderConfig = {
// Direct responder events dispatched directly to responder. Do not bubble.
onResponderEnd?: ?(e: ResponderEvent) => void,
onResponderGrant?: ?(e: ResponderEvent) => void | boolean,
onResponderMove?: ?(e: ResponderEvent) => void,
onResponderRelease?: ?(e: ResponderEvent) => void,
onResponderReject?: ?(e: ResponderEvent) => void,
onResponderStart?: ?(e: ResponderEvent) => void,
onResponderTerminate?: ?(e: ResponderEvent) => void,
onResponderTerminationRequest?: ?(e: ResponderEvent) => boolean,
// On pointer down, should this element become the responder?
onStartShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onStartShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
// On pointer move, should this element become the responder?
onMoveShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onMoveShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
// On scroll, should this element become the responder? Do no bubble
onScrollShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onScrollShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
// On text selection change, should this element become the responder?
onSelectionChangeShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onSelectionChangeShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean
};
const emptyObject = {};
/* ------------ IMPLEMENTATION ------------ */
const startRegistration = [
'onStartShouldSetResponderCapture',
'onStartShouldSetResponder',
{ bubbles: true }
];
const moveRegistration = [
'onMoveShouldSetResponderCapture',
'onMoveShouldSetResponder',
{ bubbles: true }
];
const scrollRegistration = [
'onScrollShouldSetResponderCapture',
'onScrollShouldSetResponder',
{ bubbles: false }
];
const shouldSetResponderEvents = {
touchstart: startRegistration,
mousedown: startRegistration,
touchmove: moveRegistration,
mousemove: moveRegistration,
scroll: scrollRegistration
};
const emptyResponder = { id: null, idPath: null, node: null };
const responderListenersMap = new Map();
let isEmulatingMouseEvents = false;
let trackedTouchCount = 0;
let currentResponder: ResponderInstance = {
id: null,
node: null,
idPath: null
};
const responderTouchHistoryStore = new ResponderTouchHistoryStore();
function changeCurrentResponder(responder: ResponderInstance) {
currentResponder = responder;
}
function getResponderConfig(id: ResponderId): ResponderConfig | Object {
const config = responderListenersMap.get(id);
return config != null ? config : emptyObject;
}
/**
* Process native events
*
* A single event listener is used to manage the responder system.
* All pointers are tracked in the ResponderTouchHistoryStore. Native events
* are interpreted in terms of the Responder System and checked to see if
* the responder should be transferred. Each host node that is attached to
* the Responder System has an ID, which is used to look up its associated
* callbacks.
*/
function eventListener(domEvent: any) {
const eventType = domEvent.type;
const eventTarget = domEvent.target;
/**
* Manage emulated events and early bailout.
* Since PointerEvent is not used yet (lack of support in older Safari), it's
* necessary to manually manage the mess of browser touch/mouse events.
* And bailout early for termination events when there is no active responder.
*/
// Flag when browser may produce emulated events
if (eventType === 'touchstart') {
isEmulatingMouseEvents = true;
}
// Remove flag when browser will not produce emulated events
if (eventType === 'touchmove' || trackedTouchCount > 1) {
isEmulatingMouseEvents = false;
}
// Ignore various events in particular circumstances
if (
// Ignore browser emulated mouse events
(eventType === 'mousedown' && isEmulatingMouseEvents) ||
(eventType === 'mousemove' && isEmulatingMouseEvents) ||
// Ignore mousemove if a mousedown didn't occur first
(eventType === 'mousemove' && trackedTouchCount < 1)
) {
return;
}
// Remove flag after emulated events are finished
if (isEmulatingMouseEvents && eventType === 'mouseup') {
if (trackedTouchCount === 0) {
isEmulatingMouseEvents = false;
}
return;
}
const isStartEvent = isStartish(eventType) && isPrimaryPointerDown(domEvent);
const isMoveEvent = isMoveish(eventType);
const isEndEvent = isEndish(eventType);
const isScrollEvent = isScroll(eventType);
const isSelectionChangeEvent = isSelectionChange(eventType);
const responderEvent = createResponderEvent(
domEvent,
responderTouchHistoryStore
);
/**
* Record the state of active pointers
*/
if (isStartEvent || isMoveEvent || isEndEvent) {
if (domEvent.touches) {
trackedTouchCount = domEvent.touches.length;
} else {
if (isStartEvent) {
trackedTouchCount = 1;
} else if (isEndEvent) {
trackedTouchCount = 0;
}
}
responderTouchHistoryStore.recordTouchTrack(
eventType,
responderEvent.nativeEvent
);
}
/**
* Responder System logic
*/
let eventPaths = getResponderPaths(domEvent);
let wasNegotiated = false;
let wantsResponder;
// If an event occured that might change the current responder...
if (isStartEvent || isMoveEvent || (isScrollEvent && trackedTouchCount > 0)) {
// If there is already a responder, prune the event paths to the lowest common ancestor
// of the existing responder and deepest target of the event.
const currentResponderIdPath = currentResponder.idPath;
const eventIdPath = eventPaths.idPath;
if (currentResponderIdPath != null && eventIdPath != null) {
const lowestCommonAncestor = getLowestCommonAncestor(
currentResponderIdPath,
eventIdPath
);
if (lowestCommonAncestor != null) {
const indexOfLowestCommonAncestor =
eventIdPath.indexOf(lowestCommonAncestor);
// Skip the current responder so it doesn't receive unexpected "shouldSet" events.
const index =
indexOfLowestCommonAncestor +
(lowestCommonAncestor === currentResponder.id ? 1 : 0);
eventPaths = {
idPath: eventIdPath.slice(index),
nodePath: eventPaths.nodePath.slice(index)
};
} else {
eventPaths = null;
}
}
if (eventPaths != null) {
// If a node wants to become the responder, attempt to transfer.
wantsResponder = findWantsResponder(eventPaths, domEvent, responderEvent);
if (wantsResponder != null) {
// Sets responder if none exists, or negotates with existing responder.
attemptTransfer(responderEvent, wantsResponder);
wasNegotiated = true;
}
}
}
// If there is now a responder, invoke its callbacks for the lifecycle of the gesture.
if (currentResponder.id != null && currentResponder.node != null) {
const { id, node } = currentResponder;
const {
onResponderStart,
onResponderMove,
onResponderEnd,
onResponderRelease,
onResponderTerminate,
onResponderTerminationRequest
} = getResponderConfig(id);
responderEvent.bubbles = false;
responderEvent.cancelable = false;
responderEvent.currentTarget = node;
// Start
if (isStartEvent) {
if (onResponderStart != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderStart';
onResponderStart(responderEvent);
}
}
// Move
else if (isMoveEvent) {
if (onResponderMove != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderMove';
onResponderMove(responderEvent);
}
} else {
const isTerminateEvent =
isCancelish(eventType) ||
// native context menu
eventType === 'contextmenu' ||
// window blur
(eventType === 'blur' && eventTarget === window) ||
// responder (or ancestors) blur
(eventType === 'blur' &&
eventTarget.contains(node) &&
domEvent.relatedTarget !== node) ||
// native scroll without using a pointer
(isScrollEvent && trackedTouchCount === 0) ||
// native scroll on node that is parent of the responder (allow siblings to scroll)
(isScrollEvent && eventTarget.contains(node) && eventTarget !== node) ||
// native select/selectionchange on node
(isSelectionChangeEvent && hasValidSelection(domEvent));
const isReleaseEvent =
isEndEvent &&
!isTerminateEvent &&
!hasTargetTouches(node, domEvent.touches);
// End
if (isEndEvent) {
if (onResponderEnd != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderEnd';
onResponderEnd(responderEvent);
}
}
// Release
if (isReleaseEvent) {
if (onResponderRelease != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderRelease';
onResponderRelease(responderEvent);
}
changeCurrentResponder(emptyResponder);
}
// Terminate
if (isTerminateEvent) {
let shouldTerminate = true;
// Responders can still avoid termination but only for these events.
if (
eventType === 'contextmenu' ||
eventType === 'scroll' ||
eventType === 'selectionchange'
) {
// Only call this function is it wasn't already called during negotiation.
if (wasNegotiated) {
shouldTerminate = false;
} else if (onResponderTerminationRequest != null) {
responderEvent.dispatchConfig.registrationName =
'onResponderTerminationRequest';
if (onResponderTerminationRequest(responderEvent) === false) {
shouldTerminate = false;
}
}
}
if (shouldTerminate) {
if (onResponderTerminate != null) {
responderEvent.dispatchConfig.registrationName =
'onResponderTerminate';
onResponderTerminate(responderEvent);
}
changeCurrentResponder(emptyResponder);
isEmulatingMouseEvents = false;
trackedTouchCount = 0;
}
}
}
}
}
/**
* Walk the event path to/from the target node. At each node, stop and call the
* relevant "shouldSet" functions for the given event type. If any of those functions
* call "stopPropagation" on the event, stop searching for a responder.
*/
function findWantsResponder(eventPaths, domEvent, responderEvent) {
const shouldSetCallbacks = shouldSetResponderEvents[(domEvent.type: any)]; // for Flow
if (shouldSetCallbacks != null) {
const { idPath, nodePath } = eventPaths;
const shouldSetCallbackCaptureName = shouldSetCallbacks[0];
const shouldSetCallbackBubbleName = shouldSetCallbacks[1];
const { bubbles } = shouldSetCallbacks[2];
const check = function (id, node, callbackName) {
const config = getResponderConfig(id);
const shouldSetCallback = config[callbackName];
if (shouldSetCallback != null) {
responderEvent.currentTarget = node;
if (shouldSetCallback(responderEvent) === true) {
// Start the path from the potential responder
const prunedIdPath = idPath.slice(idPath.indexOf(id));
return { id, node, idPath: prunedIdPath };
}
}
};
// capture
for (let i = idPath.length - 1; i >= 0; i--) {
const id = idPath[i];
const node = nodePath[i];
const result = check(id, node, shouldSetCallbackCaptureName);
if (result != null) {
return result;
}
if (responderEvent.isPropagationStopped() === true) {
return;
}
}
// bubble
if (bubbles) {
for (let i = 0; i < idPath.length; i++) {
const id = idPath[i];
const node = nodePath[i];
const result = check(id, node, shouldSetCallbackBubbleName);
if (result != null) {
return result;
}
if (responderEvent.isPropagationStopped() === true) {
return;
}
}
} else {
const id = idPath[0];
const node = nodePath[0];
const target = domEvent.target;
if (target === node) {
return check(id, node, shouldSetCallbackBubbleName);
}
}
}
}
/**
* Attempt to transfer the responder.
*/
function attemptTransfer(
responderEvent: ResponderEvent,
wantsResponder: ActiveResponderInstance
) {
const { id: currentId, node: currentNode } = currentResponder;
const { id, node } = wantsResponder;
const { onResponderGrant, onResponderReject } = getResponderConfig(id);
responderEvent.bubbles = false;
responderEvent.cancelable = false;
responderEvent.currentTarget = node;
// Set responder
if (currentId == null) {
if (onResponderGrant != null) {
responderEvent.currentTarget = node;
responderEvent.dispatchConfig.registrationName = 'onResponderGrant';
onResponderGrant(responderEvent);
}
changeCurrentResponder(wantsResponder);
}
// Negotiate with current responder
else {
const { onResponderTerminate, onResponderTerminationRequest } =
getResponderConfig(currentId);
let allowTransfer = true;
if (onResponderTerminationRequest != null) {
responderEvent.currentTarget = currentNode;
responderEvent.dispatchConfig.registrationName =
'onResponderTerminationRequest';
if (onResponderTerminationRequest(responderEvent) === false) {
allowTransfer = false;
}
}
if (allowTransfer) {
// Terminate existing responder
if (onResponderTerminate != null) {
responderEvent.currentTarget = currentNode;
responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
onResponderTerminate(responderEvent);
}
// Grant next responder
if (onResponderGrant != null) {
responderEvent.currentTarget = node;
responderEvent.dispatchConfig.registrationName = 'onResponderGrant';
onResponderGrant(responderEvent);
}
changeCurrentResponder(wantsResponder);
} else {
// Reject responder request
if (onResponderReject != null) {
responderEvent.currentTarget = node;
responderEvent.dispatchConfig.registrationName = 'onResponderReject';
onResponderReject(responderEvent);
}
}
}
}
/* ------------ PUBLIC API ------------ */
/**
* Attach Listeners
*
* Use native events as ReactDOM doesn't have a non-plugin API to implement
* this system.
*/
const documentEventsCapturePhase = ['blur', 'scroll'];
const documentEventsBubblePhase = [
// mouse
'mousedown',
'mousemove',
'mouseup',
'dragstart',
// touch
'touchstart',
'touchmove',
'touchend',
'touchcancel',
// other
'contextmenu',
'select',
'selectionchange'
];
export function attachListeners() {
if (canUseDOM && window.__reactResponderSystemActive == null) {
window.addEventListener('blur', eventListener);
documentEventsBubblePhase.forEach((eventType) => {
document.addEventListener(eventType, eventListener);
});
documentEventsCapturePhase.forEach((eventType) => {
document.addEventListener(eventType, eventListener, true);
});
window.__reactResponderSystemActive = true;
}
}
/**
* Register a node with the ResponderSystem.
*/
export function addNode(id: ResponderId, node: any, config: ResponderConfig) {
setResponderId(node, id);
responderListenersMap.set(id, config);
}
/**
* Unregister a node with the ResponderSystem.
*/
export function removeNode(id: ResponderId) {
if (currentResponder.id === id) {
terminateResponder();
}
if (responderListenersMap.has(id)) {
responderListenersMap.delete(id);
}
}
/**
* Allow the current responder to be terminated from within components to support
* more complex requirements, such as use with other React libraries for working
* with scroll views, input views, etc.
*/
export function terminateResponder() {
const { id, node } = currentResponder;
if (id != null && node != null) {
const { onResponderTerminate } = getResponderConfig(id);
if (onResponderTerminate != null) {
const event = createResponderEvent({}, responderTouchHistoryStore);
event.currentTarget = node;
onResponderTerminate(event);
}
changeCurrentResponder(emptyResponder);
}
isEmulatingMouseEvents = false;
trackedTouchCount = 0;
}
/**
* Allow unit tests to inspect the current responder in the system.
* FOR TESTING ONLY.
*/
export function getResponderNode(): any {
return currentResponder.node;
}
@@ -0,0 +1,219 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 type { Touch, TouchEvent } from './ResponderEventTypes';
import { isStartish, isMoveish, isEndish } from './ResponderEventTypes';
type TouchRecord = {|
currentPageX: number,
currentPageY: number,
currentTimeStamp: number,
previousPageX: number,
previousPageY: number,
previousTimeStamp: number,
startPageX: number,
startPageY: number,
startTimeStamp: number,
touchActive: boolean
|};
export type TouchHistory = $ReadOnly<{|
indexOfSingleActiveTouch: number,
mostRecentTimeStamp: number,
numberActiveTouches: number,
touchBank: Array<TouchRecord>
|}>;
/**
* Tracks the position and time of each active touch by `touch.identifier`. We
* should typically only see IDs in the range of 1-20 because IDs get recycled
* when touches end and start again.
*/
const __DEV__ = process.env.NODE_ENV !== 'production';
const MAX_TOUCH_BANK = 20;
function timestampForTouch(touch: Touch): number {
// The legacy internal implementation provides "timeStamp", which has been
// renamed to "timestamp".
return (touch: any).timeStamp || touch.timestamp;
}
/**
* TODO: Instead of making gestures recompute filtered velocity, we could
* include a built in velocity computation that can be reused globally.
*/
function createTouchRecord(touch: Touch): TouchRecord {
return {
touchActive: true,
startPageX: touch.pageX,
startPageY: touch.pageY,
startTimeStamp: timestampForTouch(touch),
currentPageX: touch.pageX,
currentPageY: touch.pageY,
currentTimeStamp: timestampForTouch(touch),
previousPageX: touch.pageX,
previousPageY: touch.pageY,
previousTimeStamp: timestampForTouch(touch)
};
}
function resetTouchRecord(touchRecord: TouchRecord, touch: Touch): void {
touchRecord.touchActive = true;
touchRecord.startPageX = touch.pageX;
touchRecord.startPageY = touch.pageY;
touchRecord.startTimeStamp = timestampForTouch(touch);
touchRecord.currentPageX = touch.pageX;
touchRecord.currentPageY = touch.pageY;
touchRecord.currentTimeStamp = timestampForTouch(touch);
touchRecord.previousPageX = touch.pageX;
touchRecord.previousPageY = touch.pageY;
touchRecord.previousTimeStamp = timestampForTouch(touch);
}
function getTouchIdentifier({ identifier }: Touch): number {
if (identifier == null) {
console.error('Touch object is missing identifier.');
}
if (__DEV__) {
if (identifier > MAX_TOUCH_BANK) {
console.error(
'Touch identifier %s is greater than maximum supported %s which causes ' +
'performance issues backfilling array locations for all of the indices.',
identifier,
MAX_TOUCH_BANK
);
}
}
return identifier;
}
function recordTouchStart(touch: Touch, touchHistory): void {
const identifier = getTouchIdentifier(touch);
const touchRecord = touchHistory.touchBank[identifier];
if (touchRecord) {
resetTouchRecord(touchRecord, touch);
} else {
touchHistory.touchBank[identifier] = createTouchRecord(touch);
}
touchHistory.mostRecentTimeStamp = timestampForTouch(touch);
}
function recordTouchMove(touch: Touch, touchHistory): void {
const touchRecord = touchHistory.touchBank[getTouchIdentifier(touch)];
if (touchRecord) {
touchRecord.touchActive = true;
touchRecord.previousPageX = touchRecord.currentPageX;
touchRecord.previousPageY = touchRecord.currentPageY;
touchRecord.previousTimeStamp = touchRecord.currentTimeStamp;
touchRecord.currentPageX = touch.pageX;
touchRecord.currentPageY = touch.pageY;
touchRecord.currentTimeStamp = timestampForTouch(touch);
touchHistory.mostRecentTimeStamp = timestampForTouch(touch);
} else {
console.warn(
'Cannot record touch move without a touch start.\n',
`Touch Move: ${printTouch(touch)}\n`,
`Touch Bank: ${printTouchBank(touchHistory)}`
);
}
}
function recordTouchEnd(touch: Touch, touchHistory): void {
const touchRecord = touchHistory.touchBank[getTouchIdentifier(touch)];
if (touchRecord) {
touchRecord.touchActive = false;
touchRecord.previousPageX = touchRecord.currentPageX;
touchRecord.previousPageY = touchRecord.currentPageY;
touchRecord.previousTimeStamp = touchRecord.currentTimeStamp;
touchRecord.currentPageX = touch.pageX;
touchRecord.currentPageY = touch.pageY;
touchRecord.currentTimeStamp = timestampForTouch(touch);
touchHistory.mostRecentTimeStamp = timestampForTouch(touch);
} else {
console.warn(
'Cannot record touch end without a touch start.\n',
`Touch End: ${printTouch(touch)}\n`,
`Touch Bank: ${printTouchBank(touchHistory)}`
);
}
}
function printTouch(touch: Touch): string {
return JSON.stringify({
identifier: touch.identifier,
pageX: touch.pageX,
pageY: touch.pageY,
timestamp: timestampForTouch(touch)
});
}
function printTouchBank(touchHistory): string {
const { touchBank } = touchHistory;
let printed = JSON.stringify(touchBank.slice(0, MAX_TOUCH_BANK));
if (touchBank.length > MAX_TOUCH_BANK) {
printed += ' (original size: ' + touchBank.length + ')';
}
return printed;
}
export class ResponderTouchHistoryStore {
_touchHistory = {
touchBank: [], //Array<TouchRecord>
numberActiveTouches: 0,
// If there is only one active touch, we remember its location. This prevents
// us having to loop through all of the touches all the time in the most
// common case.
indexOfSingleActiveTouch: -1,
mostRecentTimeStamp: 0
};
recordTouchTrack(topLevelType: string, nativeEvent: TouchEvent): void {
const touchHistory = this._touchHistory;
if (isMoveish(topLevelType)) {
nativeEvent.changedTouches.forEach((touch) =>
recordTouchMove(touch, touchHistory)
);
} else if (isStartish(topLevelType)) {
nativeEvent.changedTouches.forEach((touch) =>
recordTouchStart(touch, touchHistory)
);
touchHistory.numberActiveTouches = nativeEvent.touches.length;
if (touchHistory.numberActiveTouches === 1) {
touchHistory.indexOfSingleActiveTouch =
nativeEvent.touches[0].identifier;
}
} else if (isEndish(topLevelType)) {
nativeEvent.changedTouches.forEach((touch) =>
recordTouchEnd(touch, touchHistory)
);
touchHistory.numberActiveTouches = nativeEvent.touches.length;
if (touchHistory.numberActiveTouches === 1) {
const { touchBank } = touchHistory;
for (let i = 0; i < touchBank.length; i++) {
const touchTrackToCheck = touchBank[i];
if (touchTrackToCheck != null && touchTrackToCheck.touchActive) {
touchHistory.indexOfSingleActiveTouch = i;
break;
}
}
if (__DEV__) {
const activeRecord = touchBank[touchHistory.indexOfSingleActiveTouch];
if (!(activeRecord != null && activeRecord.touchActive)) {
console.error('Cannot find single active touch.');
}
}
}
}
}
get touchHistory(): TouchHistory {
return this._touchHistory;
}
}
@@ -0,0 +1,206 @@
/**
* Copyright (c) Nicolas Gallagher
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {
ResponderTouchHistoryStore,
TouchHistory
} from './ResponderTouchHistoryStore';
import type { TouchEvent } from './ResponderEventTypes';
import getBoundingClientRect from '../../modules/getBoundingClientRect';
export type ResponderEvent = {|
bubbles: boolean,
cancelable: boolean,
currentTarget: any,
defaultPrevented: ?boolean,
dispatchConfig: {
registrationName?: string,
phasedRegistrationNames?: {
bubbled: string,
captured: string
}
},
eventPhase: ?number,
isDefaultPrevented: () => boolean,
isPropagationStopped: () => boolean,
isTrusted: ?boolean,
preventDefault: () => void,
stopPropagation: () => void,
nativeEvent: TouchEvent,
persist: () => void,
target: ?any,
timeStamp: number,
touchHistory: TouchHistory
|};
const emptyFunction = () => {};
const emptyObject = {};
const emptyArray = [];
/**
* Safari produces very large identifiers that would cause the `touchBank` array
* length to be so large as to crash the browser, if not normalized like this.
* In the future the `touchBank` should use an object/map instead.
*/
function normalizeIdentifier(identifier) {
return identifier > 20 ? identifier % 20 : identifier;
}
/**
* Converts a native DOM event to a ResponderEvent.
* Mouse events are transformed into fake touch events.
*/
export default function createResponderEvent(
domEvent: any,
responderTouchHistoryStore: ResponderTouchHistoryStore
): ResponderEvent {
let rect;
let propagationWasStopped = false;
let changedTouches;
let touches;
const domEventChangedTouches = domEvent.changedTouches;
const domEventType = domEvent.type;
const metaKey = domEvent.metaKey === true;
const shiftKey = domEvent.shiftKey === true;
const force =
(domEventChangedTouches && domEventChangedTouches[0].force) || 0;
const identifier = normalizeIdentifier(
(domEventChangedTouches && domEventChangedTouches[0].identifier) || 0
);
const clientX =
(domEventChangedTouches && domEventChangedTouches[0].clientX) ||
domEvent.clientX;
const clientY =
(domEventChangedTouches && domEventChangedTouches[0].clientY) ||
domEvent.clientY;
const pageX =
(domEventChangedTouches && domEventChangedTouches[0].pageX) ||
domEvent.pageX;
const pageY =
(domEventChangedTouches && domEventChangedTouches[0].pageY) ||
domEvent.pageY;
const preventDefault =
typeof domEvent.preventDefault === 'function'
? domEvent.preventDefault.bind(domEvent)
: emptyFunction;
const timestamp = domEvent.timeStamp;
function normalizeTouches(touches) {
return Array.prototype.slice.call(touches).map((touch) => {
return {
force: touch.force,
identifier: normalizeIdentifier(touch.identifier),
get locationX() {
return locationX(touch.clientX);
},
get locationY() {
return locationY(touch.clientY);
},
pageX: touch.pageX,
pageY: touch.pageY,
target: touch.target,
timestamp
};
});
}
if (domEventChangedTouches != null) {
changedTouches = normalizeTouches(domEventChangedTouches);
touches = normalizeTouches(domEvent.touches);
} else {
const emulatedTouches = [
{
force,
identifier,
get locationX() {
return locationX(clientX);
},
get locationY() {
return locationY(clientY);
},
pageX,
pageY,
target: domEvent.target,
timestamp
}
];
changedTouches = emulatedTouches;
touches =
domEventType === 'mouseup' || domEventType === 'dragstart'
? emptyArray
: emulatedTouches;
}
const responderEvent = {
bubbles: true,
cancelable: true,
// `currentTarget` is set before dispatch
currentTarget: null,
defaultPrevented: domEvent.defaultPrevented,
dispatchConfig: emptyObject,
eventPhase: domEvent.eventPhase,
isDefaultPrevented() {
return domEvent.defaultPrevented;
},
isPropagationStopped() {
return propagationWasStopped;
},
isTrusted: domEvent.isTrusted,
nativeEvent: {
altKey: false,
ctrlKey: false,
metaKey,
shiftKey,
changedTouches,
force,
identifier,
get locationX() {
return locationX(clientX);
},
get locationY() {
return locationY(clientY);
},
pageX,
pageY,
target: domEvent.target,
timestamp,
touches,
type: domEventType
},
persist: emptyFunction,
preventDefault,
stopPropagation() {
propagationWasStopped = true;
},
target: domEvent.target,
timeStamp: timestamp,
touchHistory: responderTouchHistoryStore.touchHistory
};
// Using getters and functions serves two purposes:
// 1) The value of `currentTarget` is not initially available.
// 2) Measuring the clientRect may cause layout jank and should only be done on-demand.
function locationX(x) {
rect = rect || getBoundingClientRect(responderEvent.currentTarget);
if (rect) {
return x - rect.left;
}
}
function locationY(y) {
rect = rect || getBoundingClientRect(responderEvent.currentTarget);
if (rect) {
return y - rect.top;
}
}
return responderEvent;
}
@@ -0,0 +1,91 @@
/**
* Copyright (c) Nicolas Gallagher
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
/**
* Hook for integrating the Responder System into React
*
* function SomeComponent({ onStartShouldSetResponder }) {
* const ref = useRef(null);
* useResponderEvents(ref, { onStartShouldSetResponder });
* return <div ref={ref} />
* }
*/
import type { ResponderConfig } from './ResponderSystem';
import * as React from 'react';
import * as ResponderSystem from './ResponderSystem';
const emptyObject = {};
let idCounter = 0;
function useStable<T>(getInitialValue: () => T): T {
const ref = React.useRef<T | null>(null);
if (ref.current == null) {
ref.current = getInitialValue();
}
return ref.current;
}
export default function useResponderEvents(
hostRef: any,
config: ResponderConfig = emptyObject
) {
const id = useStable(() => idCounter++);
const isAttachedRef = React.useRef(false);
// This is a separate effects so it doesn't run when the config changes.
// On initial mount, attach global listeners if needed.
// On unmount, remove node potentially attached to the Responder System.
React.useEffect(() => {
ResponderSystem.attachListeners();
return () => {
ResponderSystem.removeNode(id);
};
}, [id]);
// Register and unregister with the Responder System as necessary
React.useEffect(() => {
const {
onMoveShouldSetResponder,
onMoveShouldSetResponderCapture,
onScrollShouldSetResponder,
onScrollShouldSetResponderCapture,
onSelectionChangeShouldSetResponder,
onSelectionChangeShouldSetResponderCapture,
onStartShouldSetResponder,
onStartShouldSetResponderCapture
} = config;
const requiresResponderSystem =
onMoveShouldSetResponder != null ||
onMoveShouldSetResponderCapture != null ||
onScrollShouldSetResponder != null ||
onScrollShouldSetResponderCapture != null ||
onSelectionChangeShouldSetResponder != null ||
onSelectionChangeShouldSetResponderCapture != null ||
onStartShouldSetResponder != null ||
onStartShouldSetResponderCapture != null;
const node = hostRef.current;
if (requiresResponderSystem) {
ResponderSystem.addNode(id, node, config);
isAttachedRef.current = true;
} else if (isAttachedRef.current) {
ResponderSystem.removeNode(id);
isAttachedRef.current = false;
}
}, [config, hostRef, id]);
React.useDebugValue({
isResponder: hostRef.current === ResponderSystem.getResponderNode()
});
React.useDebugValue(config);
}
@@ -0,0 +1,179 @@
/**
* Copyright (c) Nicolas Gallagher
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import isSelectionValid from '../../modules/isSelectionValid';
const keyName = '__reactResponderId';
function getEventPath(domEvent: any): Array<any> {
// The 'selectionchange' event always has the 'document' as the target.
// Use the anchor node as the initial target to reconstruct a path.
// (We actually only need the first "responder" node in practice.)
if (domEvent.type === 'selectionchange') {
const target = window.getSelection().anchorNode;
return composedPathFallback(target);
} else {
const path =
domEvent.composedPath != null
? domEvent.composedPath()
: composedPathFallback(domEvent.target);
return path;
}
}
function composedPathFallback(target: any): Array<any> {
const path = [];
while (target != null && target !== document.body) {
path.push(target);
target = target.parentNode;
}
return path;
}
/**
* Retrieve the responderId from a host node
*/
function getResponderId(node: any): ?number {
if (node != null) {
return node[keyName];
}
return null;
}
/**
* Store the responderId on a host node
*/
export function setResponderId(node: any, id: number) {
if (node != null) {
node[keyName] = id;
}
}
/**
* Filter the event path to contain only the nodes attached to the responder system
*/
export function getResponderPaths(domEvent: any): {|
idPath: Array<number>,
nodePath: Array<any>
|} {
const idPath = [];
const nodePath = [];
const eventPath = getEventPath(domEvent);
for (let i = 0; i < eventPath.length; i++) {
const node = eventPath[i];
const id = getResponderId(node);
if (id != null) {
idPath.push(id);
nodePath.push(node);
}
}
return { idPath, nodePath };
}
/**
* Walk the paths and find the first common ancestor
*/
export function getLowestCommonAncestor(
pathA: Array<any>,
pathB: Array<any>
): any {
let pathALength = pathA.length;
let pathBLength = pathB.length;
if (
// If either path is empty
pathALength === 0 ||
pathBLength === 0 ||
// If the last elements aren't the same there can't be a common ancestor
// that is connected to the responder system
pathA[pathALength - 1] !== pathB[pathBLength - 1]
) {
return null;
}
let itemA = pathA[0];
let indexA = 0;
let itemB = pathB[0];
let indexB = 0;
// If A is deeper, skip indices that can't match.
if (pathALength - pathBLength > 0) {
indexA = pathALength - pathBLength;
itemA = pathA[indexA];
pathALength = pathBLength;
}
// If B is deeper, skip indices that can't match
if (pathBLength - pathALength > 0) {
indexB = pathBLength - pathALength;
itemB = pathB[indexB];
pathBLength = pathALength;
}
// Walk in lockstep until a match is found
let depth = pathALength;
while (depth--) {
if (itemA === itemB) {
return itemA;
}
itemA = pathA[indexA++];
itemB = pathB[indexB++];
}
return null;
}
/**
* Determine whether any of the active touches are within the current responder.
* This cannot rely on W3C `targetTouches`, as neither IE11 nor Safari implement it.
*/
export function hasTargetTouches(target: any, touches: any): boolean {
if (!touches || touches.length === 0) {
return false;
}
for (let i = 0; i < touches.length; i++) {
const node = touches[i].target;
if (node != null) {
if (target.contains(node)) {
return true;
}
}
}
return false;
}
/**
* Ignore 'selectionchange' events that don't correspond with a person's intent to
* select text.
*/
export function hasValidSelection(domEvent: any): boolean {
if (domEvent.type === 'selectionchange') {
return isSelectionValid();
}
return domEvent.type === 'select';
}
/**
* Events are only valid if the primary button was used without specific modifier keys.
*/
export function isPrimaryPointerDown(domEvent: any): boolean {
const { altKey, button, buttons, ctrlKey, type } = domEvent;
const isTouch = type === 'touchstart' || type === 'touchmove';
const isPrimaryMouseDown =
type === 'mousedown' && (button === 0 || buttons === 1);
const isPrimaryMouseMove = type === 'mousemove' && buttons === 1;
const noModifiers = altKey === false && ctrlKey === false;
if (
isTouch ||
(isPrimaryMouseDown && noModifiers) ||
(isPrimaryMouseMove && noModifiers)
) {
return true;
}
return false;
}
@@ -0,0 +1,24 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
*/
import * as React from 'react';
const UNINITIALIZED =
typeof Symbol === 'function' && typeof Symbol() === 'symbol'
? Symbol()
: Object.freeze({});
export default function useStable<T>(getInitialValue: () => T): T {
const ref = React.useRef(UNINITIALIZED);
if (ref.current === UNINITIALIZED) {
ref.current = getInitialValue();
}
// $FlowFixMe (#64650789) Trouble refining types where `Symbol` is concerned.
return ref.current;
}
@@ -0,0 +1,29 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/
const warnedKeys: { [string]: boolean, ... } = {};
/**
* A simple function that prints a warning message once per session.
*
* @param {string} key - The key used to ensure the message is printed once.
* This should be unique to the callsite.
* @param {string} message - The message to print
*/
export function warnOnce(key: string, message: string) {
if (process.env.NODE_ENV !== 'production') {
if (warnedKeys[key]) {
return;
}
console.warn(message);
warnedKeys[key] = true;
}
}