Set up comprehensive frontend testing infrastructure

- Install Jest for unit testing with React Testing Library
- Install Playwright for end-to-end testing
- Configure Jest with proper TypeScript support and module mapping
- Create test setup files and utilities for both unit and e2e tests

Components:
* Jest configuration with coverage thresholds
* Playwright configuration with browser automation
* Unit tests for LoginForm, AuthContext, and useSocketIO hook
* E2E tests for authentication, dashboard, and agents workflows
* GitHub Actions workflow for automated testing
* Mock data and API utilities for consistent testing
* Test documentation with best practices

Testing features:
- Unit tests with 70% coverage threshold
- E2E tests with API mocking and user journey testing
- CI/CD integration for automated test runs
- Cross-browser testing support with Playwright
- Authentication system testing end-to-end

🚀 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
anthonyrawlins
2025-07-11 14:06:34 +10:00
parent c6d69695a8
commit aacb45156b
6109 changed files with 777927 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
import { isElementType } from '../../utils/misc/isElementType.js';
import '../../utils/dataTransfer/Clipboard.js';
import { getWindow } from '../../utils/misc/getWindow.js';
import { isFocusable } from '../../utils/focus/isFocusable.js';
import { cloneEvent } from '../../utils/misc/cloneEvent.js';
import { focusElement, blurElement } from '../focus.js';
import { behavior } from './registry.js';
behavior.click = (event, target, instance)=>{
const context = target.closest('button,input,label,select,textarea');
const control = context && isElementType(context, 'label') && context.control;
if (control && control !== target) {
return ()=>{
if (isFocusable(control)) {
focusElement(control);
instance.dispatchEvent(control, cloneEvent(event));
}
};
} else if (isElementType(target, 'input', {
type: 'file'
})) {
return ()=>{
// blur fires when the file selector pops up
blurElement(target);
target.dispatchEvent(new (getWindow(target)).Event('fileDialog'));
// focus fires after the file selector has been closed
focusElement(target);
};
}
};

View File

@@ -0,0 +1,12 @@
import '../../utils/dataTransfer/Clipboard.js';
import { isEditable } from '../../utils/edit/isEditable.js';
import { input } from '../input.js';
import { behavior } from './registry.js';
behavior.cut = (event, target, instance)=>{
return ()=>{
if (isEditable(target)) {
input(instance, target, '', 'deleteByCut');
}
};
};

View File

@@ -0,0 +1,7 @@
import './click.js';
import './cut.js';
import './keydown.js';
import './keypress.js';
import './keyup.js';
import './paste.js';
export { behavior } from './registry.js';

View File

@@ -0,0 +1,126 @@
import { getUIValue, setUISelection } from '../../document/UI.js';
import { getValueOrTextContent } from '../../document/getValueOrTextContent.js';
import { isElementType } from '../../utils/misc/isElementType.js';
import '../../utils/dataTransfer/Clipboard.js';
import { isContentEditable } from '../../utils/edit/isContentEditable.js';
import { isEditable } from '../../utils/edit/isEditable.js';
import { getTabDestination } from '../../utils/focus/getTabDestination.js';
import { hasOwnSelection } from '../../utils/focus/selection.js';
import { focusElement } from '../focus.js';
import { input } from '../input.js';
import { moveSelection } from '../selection/moveSelection.js';
import { selectAll } from '../selection/selectAll.js';
import { setSelectionRange } from '../selection/setSelectionRange.js';
import { walkRadio } from '../radio.js';
import { behavior } from './registry.js';
behavior.keydown = (event, target, instance)=>{
var _keydownBehavior_event_key;
var _keydownBehavior_event_key1;
return (_keydownBehavior_event_key1 = (_keydownBehavior_event_key = keydownBehavior[event.key]) === null || _keydownBehavior_event_key === undefined ? undefined : _keydownBehavior_event_key.call(keydownBehavior, event, target, instance)) !== null && _keydownBehavior_event_key1 !== undefined ? _keydownBehavior_event_key1 : combinationBehavior(event, target, instance);
};
const keydownBehavior = {
ArrowDown: (event, target, instance)=>{
/* istanbul ignore else */ if (isElementType(target, 'input', {
type: 'radio'
})) {
return ()=>walkRadio(instance, target, 1);
}
},
ArrowLeft: (event, target, instance)=>{
if (isElementType(target, 'input', {
type: 'radio'
})) {
return ()=>walkRadio(instance, target, -1);
}
return ()=>moveSelection(target, -1);
},
ArrowRight: (event, target, instance)=>{
if (isElementType(target, 'input', {
type: 'radio'
})) {
return ()=>walkRadio(instance, target, 1);
}
return ()=>moveSelection(target, 1);
},
ArrowUp: (event, target, instance)=>{
/* istanbul ignore else */ if (isElementType(target, 'input', {
type: 'radio'
})) {
return ()=>walkRadio(instance, target, -1);
}
},
Backspace: (event, target, instance)=>{
if (isEditable(target)) {
return ()=>{
input(instance, target, '', 'deleteContentBackward');
};
}
},
Delete: (event, target, instance)=>{
if (isEditable(target)) {
return ()=>{
input(instance, target, '', 'deleteContentForward');
};
}
},
End: (event, target)=>{
if (isElementType(target, [
'input',
'textarea'
]) || isContentEditable(target)) {
return ()=>{
var _getValueOrTextContent;
var _getValueOrTextContent_length;
const newPos = (_getValueOrTextContent_length = (_getValueOrTextContent = getValueOrTextContent(target)) === null || _getValueOrTextContent === undefined ? undefined : _getValueOrTextContent.length) !== null && _getValueOrTextContent_length !== undefined ? _getValueOrTextContent_length : /* istanbul ignore next */ 0;
setSelectionRange(target, newPos, newPos);
};
}
},
Home: (event, target)=>{
if (isElementType(target, [
'input',
'textarea'
]) || isContentEditable(target)) {
return ()=>{
setSelectionRange(target, 0, 0);
};
}
},
PageDown: (event, target)=>{
if (isElementType(target, [
'input'
])) {
return ()=>{
const newPos = getUIValue(target).length;
setSelectionRange(target, newPos, newPos);
};
}
},
PageUp: (event, target)=>{
if (isElementType(target, [
'input'
])) {
return ()=>{
setSelectionRange(target, 0, 0);
};
}
},
Tab: (event, target, instance)=>{
return ()=>{
const dest = getTabDestination(target, instance.system.keyboard.modifiers.Shift);
focusElement(dest);
if (hasOwnSelection(dest)) {
setUISelection(dest, {
anchorOffset: 0,
focusOffset: dest.value.length
});
}
};
}
};
const combinationBehavior = (event, target, instance)=>{
if (event.code === 'KeyA' && instance.system.keyboard.modifiers.Control) {
return ()=>selectAll(target);
}
};

View File

@@ -0,0 +1,49 @@
import { isElementType } from '../../utils/misc/isElementType.js';
import '../../utils/dataTransfer/Clipboard.js';
import { isContentEditable } from '../../utils/edit/isContentEditable.js';
import { isEditable } from '../../utils/edit/isEditable.js';
import { input } from '../input.js';
import { behavior } from './registry.js';
behavior.keypress = (event, target, instance)=>{
if (event.key === 'Enter') {
if (isElementType(target, 'button') || isElementType(target, 'input') && ClickInputOnEnter.includes(target.type) || isElementType(target, 'a') && Boolean(target.href)) {
return ()=>{
instance.dispatchUIEvent(target, 'click');
};
} else if (isElementType(target, 'input')) {
const form = target.form;
const submit = form === null || form === undefined ? undefined : form.querySelector('input[type="submit"], button:not([type]), button[type="submit"]');
if (submit) {
return ()=>instance.dispatchUIEvent(submit, 'click');
} else if (form && SubmitSingleInputOnEnter.includes(target.type) && form.querySelectorAll('input').length === 1) {
return ()=>instance.dispatchUIEvent(form, 'submit');
} else {
return;
}
}
}
if (isEditable(target)) {
const inputType = event.key === 'Enter' ? isContentEditable(target) && !instance.system.keyboard.modifiers.Shift ? 'insertParagraph' : 'insertLineBreak' : 'insertText';
const inputData = event.key === 'Enter' ? '\n' : event.key;
return ()=>input(instance, target, inputData, inputType);
}
};
const ClickInputOnEnter = [
'button',
'color',
'file',
'image',
'reset',
'submit'
];
const SubmitSingleInputOnEnter = [
'email',
'month',
'password',
'search',
'tel',
'text',
'url',
'week'
];

View File

@@ -0,0 +1,15 @@
import { isClickableInput } from '../../utils/click/isClickableInput.js';
import '../../utils/dataTransfer/Clipboard.js';
import { behavior } from './registry.js';
behavior.keyup = (event, target, instance)=>{
var _keyupBehavior_event_key;
return (_keyupBehavior_event_key = keyupBehavior[event.key]) === null || _keyupBehavior_event_key === undefined ? undefined : _keyupBehavior_event_key.call(keyupBehavior, event, target, instance);
};
const keyupBehavior = {
' ': (event, target, instance)=>{
if (isClickableInput(target)) {
return ()=>instance.dispatchUIEvent(target, 'click');
}
}
};

View File

@@ -0,0 +1,16 @@
import '../../utils/dataTransfer/Clipboard.js';
import { isEditable } from '../../utils/edit/isEditable.js';
import { input } from '../input.js';
import { behavior } from './registry.js';
behavior.paste = (event, target, instance)=>{
if (isEditable(target)) {
return ()=>{
var _event_clipboardData;
const insertData = (_event_clipboardData = event.clipboardData) === null || _event_clipboardData === undefined ? undefined : _event_clipboardData.getData('text');
if (insertData) {
input(instance, target, insertData, 'insertFromPaste');
}
};
}
};

View File

@@ -0,0 +1,3 @@
const behavior = {};
export { behavior };

View File

@@ -0,0 +1,205 @@
import '../utils/dataTransfer/Clipboard.js';
import { getWindow } from '../utils/misc/getWindow.js';
import { eventMap } from './eventMap.js';
const eventInitializer = {
ClipboardEvent: [
initClipboardEvent
],
Event: [],
FocusEvent: [
initUIEvent,
initFocusEvent
],
InputEvent: [
initUIEvent,
initInputEvent
],
MouseEvent: [
initUIEvent,
initUIEventModifiers,
initMouseEvent
],
PointerEvent: [
initUIEvent,
initUIEventModifiers,
initMouseEvent,
initPointerEvent
],
KeyboardEvent: [
initUIEvent,
initUIEventModifiers,
initKeyboardEvent
]
};
function createEvent(type, target, init) {
const window = getWindow(target);
const { EventType, defaultInit } = eventMap[type];
const event = new (getEventConstructors(window))[EventType](type, defaultInit);
eventInitializer[EventType].forEach((f)=>f(event, init !== null && init !== undefined ? init : {}));
return event;
}
/* istanbul ignore next */ function getEventConstructors(window) {
var _window_Event;
/* eslint-disable @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-extraneous-class */ const Event = (_window_Event = window.Event) !== null && _window_Event !== undefined ? _window_Event : class Event {
};
var _window_AnimationEvent;
const AnimationEvent = (_window_AnimationEvent = window.AnimationEvent) !== null && _window_AnimationEvent !== undefined ? _window_AnimationEvent : class AnimationEvent extends Event {
};
var _window_ClipboardEvent;
const ClipboardEvent = (_window_ClipboardEvent = window.ClipboardEvent) !== null && _window_ClipboardEvent !== undefined ? _window_ClipboardEvent : class ClipboardEvent extends Event {
};
var _window_PopStateEvent;
const PopStateEvent = (_window_PopStateEvent = window.PopStateEvent) !== null && _window_PopStateEvent !== undefined ? _window_PopStateEvent : class PopStateEvent extends Event {
};
var _window_ProgressEvent;
const ProgressEvent = (_window_ProgressEvent = window.ProgressEvent) !== null && _window_ProgressEvent !== undefined ? _window_ProgressEvent : class ProgressEvent extends Event {
};
var _window_TransitionEvent;
const TransitionEvent = (_window_TransitionEvent = window.TransitionEvent) !== null && _window_TransitionEvent !== undefined ? _window_TransitionEvent : class TransitionEvent extends Event {
};
var _window_UIEvent;
const UIEvent = (_window_UIEvent = window.UIEvent) !== null && _window_UIEvent !== undefined ? _window_UIEvent : class UIEvent extends Event {
};
var _window_CompositionEvent;
const CompositionEvent = (_window_CompositionEvent = window.CompositionEvent) !== null && _window_CompositionEvent !== undefined ? _window_CompositionEvent : class CompositionEvent extends UIEvent {
};
var _window_FocusEvent;
const FocusEvent = (_window_FocusEvent = window.FocusEvent) !== null && _window_FocusEvent !== undefined ? _window_FocusEvent : class FocusEvent extends UIEvent {
};
var _window_InputEvent;
const InputEvent = (_window_InputEvent = window.InputEvent) !== null && _window_InputEvent !== undefined ? _window_InputEvent : class InputEvent extends UIEvent {
};
var _window_KeyboardEvent;
const KeyboardEvent = (_window_KeyboardEvent = window.KeyboardEvent) !== null && _window_KeyboardEvent !== undefined ? _window_KeyboardEvent : class KeyboardEvent extends UIEvent {
};
var _window_MouseEvent;
const MouseEvent = (_window_MouseEvent = window.MouseEvent) !== null && _window_MouseEvent !== undefined ? _window_MouseEvent : class MouseEvent extends UIEvent {
};
var _window_DragEvent;
const DragEvent = (_window_DragEvent = window.DragEvent) !== null && _window_DragEvent !== undefined ? _window_DragEvent : class DragEvent extends MouseEvent {
};
var _window_PointerEvent;
const PointerEvent = (_window_PointerEvent = window.PointerEvent) !== null && _window_PointerEvent !== undefined ? _window_PointerEvent : class PointerEvent extends MouseEvent {
};
var _window_TouchEvent;
const TouchEvent = (_window_TouchEvent = window.TouchEvent) !== null && _window_TouchEvent !== undefined ? _window_TouchEvent : class TouchEvent extends UIEvent {
};
/* eslint-enable @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-extraneous-class */ return {
Event,
AnimationEvent,
ClipboardEvent,
PopStateEvent,
ProgressEvent,
TransitionEvent,
UIEvent,
CompositionEvent,
FocusEvent,
InputEvent,
KeyboardEvent,
MouseEvent,
DragEvent,
PointerEvent,
TouchEvent
};
}
function assignProps(obj, props) {
for (const [key, value] of Object.entries(props)){
Object.defineProperty(obj, key, {
get: ()=>value !== null && value !== undefined ? value : null
});
}
}
function sanitizeNumber(n) {
return Number(n !== null && n !== undefined ? n : 0);
}
function initClipboardEvent(event, { clipboardData }) {
assignProps(event, {
clipboardData
});
}
function initFocusEvent(event, { relatedTarget }) {
assignProps(event, {
relatedTarget
});
}
function initInputEvent(event, { data, inputType, isComposing }) {
assignProps(event, {
data,
isComposing: Boolean(isComposing),
inputType: String(inputType)
});
}
function initUIEvent(event, { view, detail }) {
assignProps(event, {
view,
detail: sanitizeNumber(detail !== null && detail !== undefined ? detail : 0)
});
}
function initUIEventModifiers(event, { altKey, ctrlKey, metaKey, shiftKey, modifierAltGraph, modifierCapsLock, modifierFn, modifierFnLock, modifierNumLock, modifierScrollLock, modifierSymbol, modifierSymbolLock }) {
assignProps(event, {
altKey: Boolean(altKey),
ctrlKey: Boolean(ctrlKey),
metaKey: Boolean(metaKey),
shiftKey: Boolean(shiftKey),
getModifierState (k) {
return Boolean({
Alt: altKey,
AltGraph: modifierAltGraph,
CapsLock: modifierCapsLock,
Control: ctrlKey,
Fn: modifierFn,
FnLock: modifierFnLock,
Meta: metaKey,
NumLock: modifierNumLock,
ScrollLock: modifierScrollLock,
Shift: shiftKey,
Symbol: modifierSymbol,
SymbolLock: modifierSymbolLock
}[k]);
}
});
}
function initKeyboardEvent(event, { key, code, location, repeat, isComposing, charCode }) {
assignProps(event, {
key: String(key),
code: String(code),
location: sanitizeNumber(location),
repeat: Boolean(repeat),
isComposing: Boolean(isComposing),
charCode
});
}
function initMouseEvent(event, { x, y, screenX, screenY, clientX = x, clientY = y, button, buttons, relatedTarget, offsetX, offsetY, pageX, pageY }) {
assignProps(event, {
screenX: sanitizeNumber(screenX),
screenY: sanitizeNumber(screenY),
clientX: sanitizeNumber(clientX),
x: sanitizeNumber(clientX),
clientY: sanitizeNumber(clientY),
y: sanitizeNumber(clientY),
button: sanitizeNumber(button),
buttons: sanitizeNumber(buttons),
relatedTarget,
offsetX: sanitizeNumber(offsetX),
offsetY: sanitizeNumber(offsetY),
pageX: sanitizeNumber(pageX),
pageY: sanitizeNumber(pageY)
});
}
function initPointerEvent(event, { pointerId, width, height, pressure, tangentialPressure, tiltX, tiltY, twist, pointerType, isPrimary }) {
assignProps(event, {
pointerId: sanitizeNumber(pointerId),
width: sanitizeNumber(width !== null && width !== undefined ? width : 1),
height: sanitizeNumber(height !== null && height !== undefined ? height : 1),
pressure: sanitizeNumber(pressure),
tangentialPressure: sanitizeNumber(tangentialPressure),
tiltX: sanitizeNumber(tiltX),
tiltY: sanitizeNumber(tiltY),
twist: sanitizeNumber(twist),
pointerType: String(pointerType),
isPrimary: Boolean(isPrimary)
});
}
export { createEvent };

View File

@@ -0,0 +1,50 @@
import './behavior/click.js';
import './behavior/cut.js';
import './behavior/keydown.js';
import './behavior/keypress.js';
import './behavior/keyup.js';
import './behavior/paste.js';
import { behavior } from './behavior/registry.js';
import { wrapEvent } from './wrapEvent.js';
import { isMouseEvent, isKeyboardEvent } from './eventMap.js';
import { createEvent } from './createEvent.js';
function dispatchUIEvent(target, type, init, preventDefault = false) {
if (isMouseEvent(type) || isKeyboardEvent(type)) {
init = {
...init,
...this.system.getUIEventModifiers()
};
}
const event = createEvent(type, target, init);
return dispatchEvent.call(this, target, event, preventDefault);
}
function dispatchEvent(target, event, preventDefault = false) {
var _behavior_type;
const type = event.type;
const behaviorImplementation = preventDefault ? ()=>{} : (_behavior_type = behavior[type]) === null || _behavior_type === undefined ? undefined : _behavior_type.call(behavior, event, target, this);
if (behaviorImplementation) {
event.preventDefault();
let defaultPrevented = false;
Object.defineProperty(event, 'defaultPrevented', {
get: ()=>defaultPrevented
});
Object.defineProperty(event, 'preventDefault', {
value: ()=>{
defaultPrevented = event.cancelable;
}
});
wrapEvent(()=>target.dispatchEvent(event));
if (!defaultPrevented) {
behaviorImplementation();
}
return !defaultPrevented;
}
return wrapEvent(()=>target.dispatchEvent(event));
}
function dispatchDOMEvent(target, type, init) {
const event = createEvent(type, target, init);
wrapEvent(()=>target.dispatchEvent(event));
}
export { dispatchDOMEvent, dispatchEvent, dispatchUIEvent };

View File

@@ -0,0 +1,277 @@
const eventMap = {
auxclick: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
beforeinput: {
EventType: 'InputEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
blur: {
EventType: 'FocusEvent',
defaultInit: {
bubbles: false,
cancelable: false,
composed: true
}
},
click: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
contextmenu: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
copy: {
EventType: 'ClipboardEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
change: {
EventType: 'Event',
defaultInit: {
bubbles: true,
cancelable: false
}
},
cut: {
EventType: 'ClipboardEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
dblclick: {
EventType: 'MouseEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
focus: {
EventType: 'FocusEvent',
defaultInit: {
bubbles: false,
cancelable: false,
composed: true
}
},
focusin: {
EventType: 'FocusEvent',
defaultInit: {
bubbles: true,
cancelable: false,
composed: true
}
},
focusout: {
EventType: 'FocusEvent',
defaultInit: {
bubbles: true,
cancelable: false,
composed: true
}
},
keydown: {
EventType: 'KeyboardEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
keypress: {
EventType: 'KeyboardEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
keyup: {
EventType: 'KeyboardEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
paste: {
EventType: 'ClipboardEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
input: {
EventType: 'InputEvent',
defaultInit: {
bubbles: true,
cancelable: false,
composed: true
}
},
mousedown: {
EventType: 'MouseEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
mouseenter: {
EventType: 'MouseEvent',
defaultInit: {
bubbles: false,
cancelable: false,
composed: true
}
},
mouseleave: {
EventType: 'MouseEvent',
defaultInit: {
bubbles: false,
cancelable: false,
composed: true
}
},
mousemove: {
EventType: 'MouseEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
mouseout: {
EventType: 'MouseEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
mouseover: {
EventType: 'MouseEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
mouseup: {
EventType: 'MouseEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
pointerover: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
pointerenter: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: false,
cancelable: false
}
},
pointerdown: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
pointermove: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
pointerup: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
pointercancel: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: false,
composed: true
}
},
pointerout: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: true,
cancelable: true,
composed: true
}
},
pointerleave: {
EventType: 'PointerEvent',
defaultInit: {
bubbles: false,
cancelable: false
}
},
submit: {
EventType: 'Event',
defaultInit: {
bubbles: true,
cancelable: true
}
}
};
function getEventClass(type) {
return eventMap[type].EventType;
}
const mouseEvents = [
'MouseEvent',
'PointerEvent'
];
function isMouseEvent(type) {
return mouseEvents.includes(getEventClass(type));
}
function isKeyboardEvent(type) {
return getEventClass(type) === 'KeyboardEvent';
}
export { eventMap, isKeyboardEvent, isMouseEvent };

View File

@@ -0,0 +1,31 @@
import '../utils/dataTransfer/Clipboard.js';
import { getActiveElement } from '../utils/focus/getActiveElement.js';
import { isFocusable } from '../utils/focus/isFocusable.js';
import { findClosest } from '../utils/misc/findClosest.js';
import { updateSelectionOnFocus } from './selection/updateSelectionOnFocus.js';
import { wrapEvent } from './wrapEvent.js';
// Browsers do not dispatch FocusEvent if the document does not have focus.
// TODO: simulate FocusEvent in browsers
/**
* Focus closest focusable element.
*/ function focusElement(element) {
const target = findClosest(element, isFocusable);
const activeElement = getActiveElement(element.ownerDocument);
if ((target !== null && target !== undefined ? target : element.ownerDocument.body) === activeElement) {
return;
} else if (target) {
wrapEvent(()=>target.focus());
} else {
wrapEvent(()=>activeElement === null || activeElement === undefined ? undefined : activeElement.blur());
}
updateSelectionOnFocus(target !== null && target !== undefined ? target : element.ownerDocument.body);
}
function blurElement(element) {
if (!isFocusable(element)) return;
const wasActive = getActiveElement(element.ownerDocument) === element;
if (!wasActive) return;
wrapEvent(()=>element.blur());
}
export { blurElement, focusElement };

View File

@@ -0,0 +1,7 @@
export { dispatchDOMEvent, dispatchEvent, dispatchUIEvent } from './dispatchEvent.js';
export { blurElement, focusElement } from './focus.js';
export { input } from './input.js';
import '../utils/dataTransfer/Clipboard.js';
export { setSelectionPerMouseDown } from './selection/setSelectionPerMouse.js';
export { modifySelectionPerMouseMove } from './selection/modifySelectionPerMouse.js';
export { isAllSelected, selectAll } from './selection/selectAll.js';

View File

@@ -0,0 +1,155 @@
import { setUIValue, clearInitialValue, getUIValue } from '../document/UI.js';
import { isElementType } from '../utils/misc/isElementType.js';
import '../utils/dataTransfer/Clipboard.js';
import { isValidDateOrTimeValue, buildTimeValue } from '../utils/edit/timeValue.js';
import { supportsMaxLength, getMaxLength } from '../utils/edit/maxLength.js';
import { getNextCursorPosition } from '../utils/focus/cursor.js';
import { commitValueAfterInput } from '../document/trackValue.js';
import { getInputRange } from './selection/getInputRange.js';
import { setSelection } from './selection/setSelection.js';
function isDateOrTime(element) {
return isElementType(element, 'input') && [
'date',
'time'
].includes(element.type);
}
function input(instance, element, data, inputType = 'insertText') {
const inputRange = getInputRange(element);
/* istanbul ignore if */ if (!inputRange) {
return;
}
// There is no `beforeinput` event on `date` and `time` input
if (!isDateOrTime(element)) {
const unprevented = instance.dispatchUIEvent(element, 'beforeinput', {
inputType,
data
});
if (!unprevented) {
return;
}
}
if ('startContainer' in inputRange) {
editContenteditable(instance, element, inputRange, data, inputType);
} else {
editInputElement(instance, element, inputRange, data, inputType);
}
}
function editContenteditable(instance, element, inputRange, data, inputType) {
let del = false;
if (!inputRange.collapsed) {
del = true;
inputRange.deleteContents();
} else if ([
'deleteContentBackward',
'deleteContentForward'
].includes(inputType)) {
const nextPosition = getNextCursorPosition(inputRange.startContainer, inputRange.startOffset, inputType === 'deleteContentBackward' ? -1 : 1, inputType);
if (nextPosition) {
del = true;
const delRange = inputRange.cloneRange();
if (delRange.comparePoint(nextPosition.node, nextPosition.offset) < 0) {
delRange.setStart(nextPosition.node, nextPosition.offset);
} else {
delRange.setEnd(nextPosition.node, nextPosition.offset);
}
delRange.deleteContents();
}
}
if (data) {
if (inputRange.endContainer.nodeType === 3) {
const offset = inputRange.endOffset;
inputRange.endContainer.insertData(offset, data);
inputRange.setStart(inputRange.endContainer, offset + data.length);
inputRange.setEnd(inputRange.endContainer, offset + data.length);
} else {
const text = element.ownerDocument.createTextNode(data);
inputRange.insertNode(text);
inputRange.setStart(text, data.length);
inputRange.setEnd(text, data.length);
}
}
if (del || data) {
instance.dispatchUIEvent(element, 'input', {
inputType
});
}
}
function editInputElement(instance, element, inputRange, data, inputType) {
let dataToInsert = data;
if (supportsMaxLength(element)) {
const maxLength = getMaxLength(element);
if (maxLength !== undefined && data.length > 0) {
const spaceUntilMaxLength = maxLength - element.value.length;
if (spaceUntilMaxLength > 0) {
dataToInsert = data.substring(0, spaceUntilMaxLength);
} else {
return;
}
}
}
const { newValue, newOffset, oldValue } = calculateNewValue(dataToInsert, element, inputRange, inputType);
if (newValue === oldValue && newOffset === inputRange.startOffset && newOffset === inputRange.endOffset) {
return;
}
if (isElementType(element, 'input', {
type: 'number'
}) && !isValidNumberInput(newValue)) {
return;
}
setUIValue(element, newValue);
setSelection({
focusNode: element,
anchorOffset: newOffset,
focusOffset: newOffset
});
if (isDateOrTime(element)) {
if (isValidDateOrTimeValue(element, newValue)) {
commitInput(instance, element, newOffset, {});
instance.dispatchUIEvent(element, 'change');
clearInitialValue(element);
}
} else {
commitInput(instance, element, newOffset, {
data,
inputType
});
}
}
function calculateNewValue(inputData, node, { startOffset, endOffset }, inputType) {
const value = getUIValue(node);
const prologEnd = Math.max(0, startOffset === endOffset && inputType === 'deleteContentBackward' ? startOffset - 1 : startOffset);
const prolog = value.substring(0, prologEnd);
const epilogStart = Math.min(value.length, startOffset === endOffset && inputType === 'deleteContentForward' ? startOffset + 1 : endOffset);
const epilog = value.substring(epilogStart, value.length);
let newValue = `${prolog}${inputData}${epilog}`;
let newOffset = prologEnd + inputData.length;
if (isElementType(node, 'input', {
type: 'time'
})) {
const builtValue = buildTimeValue(newValue);
if (builtValue !== '' && isValidDateOrTimeValue(node, builtValue)) {
newValue = builtValue;
newOffset = builtValue.length;
}
}
return {
oldValue: value,
newValue,
newOffset
};
}
function commitInput(instance, element, newOffset, inputInit) {
instance.dispatchUIEvent(element, 'input', inputInit);
commitValueAfterInput(element, newOffset);
}
function isValidNumberInput(value) {
var _value_match, _value_match1;
// the browser allows some invalid input but not others
// it allows up to two '-' at any place before any 'e' or one directly following 'e'
// it allows one '.' at any place before e
const valueParts = value.split('e', 2);
return !(/[^\d.\-e]/.test(value) || Number((_value_match = value.match(/-/g)) === null || _value_match === undefined ? undefined : _value_match.length) > 2 || Number((_value_match1 = value.match(/\./g)) === null || _value_match1 === undefined ? undefined : _value_match1.length) > 1 || valueParts[1] && !/^-?\d*$/.test(valueParts[1]));
}
export { input };

View File

@@ -0,0 +1,25 @@
import '../utils/dataTransfer/Clipboard.js';
import { isDisabled } from '../utils/misc/isDisabled.js';
import { getWindow } from '../utils/misc/getWindow.js';
import { focusElement } from './focus.js';
function walkRadio(instance, el, direction) {
const window = getWindow(el);
const group = Array.from(el.ownerDocument.querySelectorAll(el.name ? `input[type="radio"][name="${window.CSS.escape(el.name)}"]` : `input[type="radio"][name=""], input[type="radio"]:not([name])`));
for(let i = group.findIndex((e)=>e === el) + direction;; i += direction){
if (!group[i]) {
i = direction > 0 ? 0 : group.length - 1;
}
if (group[i] === el) {
return;
}
if (isDisabled(group[i])) {
continue;
}
focusElement(group[i]);
instance.dispatchUIEvent(group[i], 'click');
return;
}
}
export { walkRadio };

View File

@@ -0,0 +1,16 @@
import { getTargetTypeAndSelection } from './getTargetTypeAndSelection.js';
/**
* Get the range that would be overwritten by input.
*/ function getInputRange(focusNode) {
const typeAndSelection = getTargetTypeAndSelection(focusNode);
if (typeAndSelection.type === 'input') {
return typeAndSelection.selection;
} else if (typeAndSelection.type === 'contenteditable') {
var _typeAndSelection_selection;
// Multi-range on contenteditable edits the first selection instead of the last
return (_typeAndSelection_selection = typeAndSelection.selection) === null || _typeAndSelection_selection === undefined ? undefined : _typeAndSelection_selection.getRangeAt(0);
}
}
export { getInputRange };

View File

@@ -0,0 +1,29 @@
import { getUISelection } from '../../document/UI.js';
import '../../utils/dataTransfer/Clipboard.js';
import { getContentEditable } from '../../utils/edit/isContentEditable.js';
import { hasOwnSelection } from '../../utils/focus/selection.js';
/**
* Determine which selection logic and selection ranges to consider.
*/ function getTargetTypeAndSelection(node) {
const element = getElement(node);
if (element && hasOwnSelection(element)) {
return {
type: 'input',
selection: getUISelection(element)
};
}
const selection = element === null || element === undefined ? undefined : element.ownerDocument.getSelection();
// It is possible to extend a single-range selection into a contenteditable.
// This results in the range acting like a range outside of contenteditable.
const isCE = getContentEditable(node) && (selection === null || selection === undefined ? undefined : selection.anchorNode) && getContentEditable(selection.anchorNode);
return {
type: isCE ? 'contenteditable' : 'default',
selection
};
}
function getElement(node) {
return node.nodeType === 1 ? node : node.parentElement;
}
export { getTargetTypeAndSelection };

View File

@@ -0,0 +1,9 @@
export { getInputRange } from './getInputRange.js';
export { modifySelection } from './modifySelection.js';
export { moveSelection } from './moveSelection.js';
export { setSelectionPerMouseDown } from './setSelectionPerMouse.js';
export { modifySelectionPerMouseMove } from './modifySelectionPerMouse.js';
export { isAllSelected, selectAll } from './selectAll.js';
export { setSelectionRange } from './setSelectionRange.js';
export { setSelection } from './setSelection.js';
export { updateSelectionOnFocus } from './updateSelectionOnFocus.js';

View File

@@ -0,0 +1,19 @@
import { setUISelection } from '../../document/UI.js';
import '../../utils/dataTransfer/Clipboard.js';
import { getTargetTypeAndSelection } from './getTargetTypeAndSelection.js';
/**
* Extend/shrink the selection like with Shift+Arrows or Shift+Mouse
*/ function modifySelection({ focusNode, focusOffset }) {
var _focusNode_ownerDocument_getSelection, _focusNode_ownerDocument;
const typeAndSelection = getTargetTypeAndSelection(focusNode);
if (typeAndSelection.type === 'input') {
return setUISelection(focusNode, {
anchorOffset: typeAndSelection.selection.anchorOffset,
focusOffset
}, 'modify');
}
(_focusNode_ownerDocument = focusNode.ownerDocument) === null || _focusNode_ownerDocument === undefined ? undefined : (_focusNode_ownerDocument_getSelection = _focusNode_ownerDocument.getSelection()) === null || _focusNode_ownerDocument_getSelection === undefined ? undefined : _focusNode_ownerDocument_getSelection.extend(focusNode, focusOffset);
}
export { modifySelection };

View File

@@ -0,0 +1,38 @@
import { setUISelection } from '../../document/UI.js';
import '../../utils/dataTransfer/Clipboard.js';
import { resolveCaretPosition } from './resolveCaretPosition.js';
function modifySelectionPerMouseMove(selectionRange, { document, target, node, offset }) {
const selectionFocus = resolveCaretPosition({
target,
node,
offset
});
if ('node' in selectionRange) {
// When the mouse is dragged outside of an input/textarea,
// the selection is extended to the beginning or end of the input
// depending on pointer position.
// TODO: extend selection according to pointer position
/* istanbul ignore else */ if (selectionFocus.node === selectionRange.node) {
const anchorOffset = selectionFocus.offset < selectionRange.start ? selectionRange.end : selectionRange.start;
const focusOffset = selectionFocus.offset > selectionRange.end || selectionFocus.offset < selectionRange.start ? selectionFocus.offset : selectionRange.end;
setUISelection(selectionRange.node, {
anchorOffset,
focusOffset
});
}
} else {
const range = selectionRange.cloneRange();
const cmp = range.comparePoint(selectionFocus.node, selectionFocus.offset);
if (cmp < 0) {
range.setStart(selectionFocus.node, selectionFocus.offset);
} else if (cmp > 0) {
range.setEnd(selectionFocus.node, selectionFocus.offset);
}
const selection = document.getSelection();
selection === null || selection === undefined ? undefined : selection.removeAllRanges();
selection === null || selection === undefined ? undefined : selection.addRange(range.cloneRange());
}
}
export { modifySelectionPerMouseMove };

View File

@@ -0,0 +1,36 @@
import { getUISelection } from '../../document/UI.js';
import '../../utils/dataTransfer/Clipboard.js';
import { getNextCursorPosition } from '../../utils/focus/cursor.js';
import { hasOwnSelection } from '../../utils/focus/selection.js';
import { setSelection } from './setSelection.js';
/**
* Move the selection
*/ function moveSelection(node, direction) {
// TODO: implement shift
if (hasOwnSelection(node)) {
const selection = getUISelection(node);
setSelection({
focusNode: node,
focusOffset: selection.startOffset === selection.endOffset ? selection.focusOffset + direction : direction < 0 ? selection.startOffset : selection.endOffset
});
} else {
const selection = node.ownerDocument.getSelection();
if (!(selection === null || selection === undefined ? undefined : selection.focusNode)) {
return;
}
if (selection.isCollapsed) {
const nextPosition = getNextCursorPosition(selection.focusNode, selection.focusOffset, direction);
if (nextPosition) {
setSelection({
focusNode: nextPosition.node,
focusOffset: nextPosition.offset
});
}
} else {
selection[direction < 0 ? 'collapseToStart' : 'collapseToEnd']();
}
}
}
export { moveSelection };

View File

@@ -0,0 +1,59 @@
import { getUIValue } from '../../document/UI.js';
import '../../utils/dataTransfer/Clipboard.js';
import { hasOwnSelection } from '../../utils/focus/selection.js';
function resolveCaretPosition({ target, node, offset }) {
if (hasOwnSelection(target)) {
return {
node: target,
offset: offset !== null && offset !== undefined ? offset : getUIValue(target).length
};
} else if (node) {
return {
node,
offset: offset !== null && offset !== undefined ? offset : node.nodeType === 3 ? node.nodeValue.length : node.childNodes.length
};
}
return findNodeAtTextOffset(target, offset);
}
function findNodeAtTextOffset(node, offset, isRoot = true) {
// When clicking after the content the browser behavior can be complicated:
// 1. If there is textContent after the last element child,
// the cursor is moved there.
// 2. If there is textContent in the last element child,
// the browser moves the cursor to the last non-empty text node inside this element.
// 3. Otherwise the cursor is moved to the end of the target.
let i = offset === undefined ? node.childNodes.length - 1 : 0;
const step = offset === undefined ? -1 : 1;
while(offset === undefined ? i >= (isRoot ? Math.max(node.childNodes.length - 1, 0) : 0) : i <= node.childNodes.length){
if (offset && i === node.childNodes.length) {
throw new Error('The given offset is out of bounds.');
}
const c = node.childNodes.item(i);
const text = String(c.textContent);
if (text.length) {
if (offset !== undefined && text.length < offset) {
offset -= text.length;
} else if (c.nodeType === 1) {
return findNodeAtTextOffset(c, offset, false);
} else {
// The pre-commit hooks keeps changing this
// See https://github.com/kentcdodds/kcd-scripts/issues/218
/* istanbul ignore else */ // eslint-disable-next-line no-lonely-if
if (c.nodeType === 3) {
return {
node: c,
offset: offset !== null && offset !== undefined ? offset : c.nodeValue.length
};
}
}
}
i += step;
}
return {
node,
offset: node.childNodes.length
};
}
export { resolveCaretPosition };

View File

@@ -0,0 +1,35 @@
import { getUIValue, getUISelection } from '../../document/UI.js';
import '../../utils/dataTransfer/Clipboard.js';
import { getContentEditable } from '../../utils/edit/isContentEditable.js';
import { hasOwnSelection } from '../../utils/focus/selection.js';
import { setSelection } from './setSelection.js';
/**
* Expand a selection like the browser does when pressing Ctrl+A.
*/ function selectAll(target) {
if (hasOwnSelection(target)) {
return setSelection({
focusNode: target,
anchorOffset: 0,
focusOffset: getUIValue(target).length
});
}
var _getContentEditable;
const focusNode = (_getContentEditable = getContentEditable(target)) !== null && _getContentEditable !== undefined ? _getContentEditable : target.ownerDocument.body;
setSelection({
focusNode,
anchorOffset: 0,
focusOffset: focusNode.childNodes.length
});
}
function isAllSelected(target) {
if (hasOwnSelection(target)) {
return getUISelection(target).startOffset === 0 && getUISelection(target).endOffset === getUIValue(target).length;
}
var _getContentEditable;
const focusNode = (_getContentEditable = getContentEditable(target)) !== null && _getContentEditable !== undefined ? _getContentEditable : target.ownerDocument.body;
const selection = target.ownerDocument.getSelection();
return (selection === null || selection === undefined ? undefined : selection.anchorNode) === focusNode && selection.focusNode === focusNode && selection.anchorOffset === 0 && selection.focusOffset === focusNode.childNodes.length;
}
export { isAllSelected, selectAll };

View File

@@ -0,0 +1,19 @@
import { setUISelection } from '../../document/UI.js';
import '../../utils/dataTransfer/Clipboard.js';
import { getTargetTypeAndSelection } from './getTargetTypeAndSelection.js';
/**
* Set the selection
*/ function setSelection({ focusNode, focusOffset, anchorNode = focusNode, anchorOffset = focusOffset }) {
var _anchorNode_ownerDocument_getSelection, _anchorNode_ownerDocument;
const typeAndSelection = getTargetTypeAndSelection(focusNode);
if (typeAndSelection.type === 'input') {
return setUISelection(focusNode, {
anchorOffset,
focusOffset
});
}
(_anchorNode_ownerDocument = anchorNode.ownerDocument) === null || _anchorNode_ownerDocument === undefined ? undefined : (_anchorNode_ownerDocument_getSelection = _anchorNode_ownerDocument.getSelection()) === null || _anchorNode_ownerDocument_getSelection === undefined ? undefined : _anchorNode_ownerDocument_getSelection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
}
export { setSelection };

View File

@@ -0,0 +1,79 @@
import { getUIValue, setUISelection } from '../../document/UI.js';
import '../../utils/dataTransfer/Clipboard.js';
import { hasNoSelection, hasOwnSelection } from '../../utils/focus/selection.js';
import { resolveCaretPosition } from './resolveCaretPosition.js';
function setSelectionPerMouseDown({ document, target, clickCount, node, offset }) {
if (hasNoSelection(target)) {
return;
}
const targetHasOwnSelection = hasOwnSelection(target);
// On non-input elements the text selection per multiple click
// can extend beyond the target boundaries.
// The exact mechanism what is considered in the same line is unclear.
// Looks it might be every inline element.
// TODO: Check what might be considered part of the same line of text.
const text = String(targetHasOwnSelection ? getUIValue(target) : target.textContent);
const [start, end] = node ? // which elements might be considered in the same line of text.
// TODO: support expanding initial range on multiple clicks if node is given
[
offset,
offset
] : getTextRange(text, offset, clickCount);
// TODO: implement modifying selection per shift/ctrl+mouse
if (targetHasOwnSelection) {
setUISelection(target, {
anchorOffset: start !== null && start !== undefined ? start : text.length,
focusOffset: end !== null && end !== undefined ? end : text.length
});
return {
node: target,
start: start !== null && start !== undefined ? start : 0,
end: end !== null && end !== undefined ? end : text.length
};
} else {
const { node: startNode, offset: startOffset } = resolveCaretPosition({
target,
node,
offset: start
});
const { node: endNode, offset: endOffset } = resolveCaretPosition({
target,
node,
offset: end
});
const range = target.ownerDocument.createRange();
try {
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
} catch (e) {
throw new Error('The given offset is out of bounds.');
}
const selection = document.getSelection();
selection === null || selection === undefined ? undefined : selection.removeAllRanges();
selection === null || selection === undefined ? undefined : selection.addRange(range.cloneRange());
return range;
}
}
function getTextRange(text, pos, clickCount) {
if (clickCount % 3 === 1 || text.length === 0) {
return [
pos,
pos
];
}
const textPos = pos !== null && pos !== undefined ? pos : text.length;
if (clickCount % 3 === 2) {
return [
textPos - text.substr(0, pos).match(/(\w+|\s+|\W)?$/)[0].length,
pos === undefined ? pos : pos + text.substr(pos).match(/^(\w+|\s+|\W)?/)[0].length
];
}
// triple click
return [
textPos - text.substr(0, pos).match(/[^\r\n]*$/)[0].length,
pos === undefined ? pos : pos + text.substr(pos).match(/^[^\r\n]*/)[0].length
];
}
export { setSelectionPerMouseDown };

View File

@@ -0,0 +1,29 @@
import '../../utils/dataTransfer/Clipboard.js';
import { isContentEditable } from '../../utils/edit/isContentEditable.js';
import { hasOwnSelection } from '../../utils/focus/selection.js';
import { setSelection } from './setSelection.js';
/**
* Backward-compatible selection.
*
* Handles input elements and contenteditable if it only contains a single text node.
*/ function setSelectionRange(element, anchorOffset, focusOffset) {
var _element_firstChild;
if (hasOwnSelection(element)) {
return setSelection({
focusNode: element,
anchorOffset,
focusOffset
});
}
/* istanbul ignore else */ if (isContentEditable(element) && ((_element_firstChild = element.firstChild) === null || _element_firstChild === undefined ? undefined : _element_firstChild.nodeType) === 3) {
return setSelection({
focusNode: element.firstChild,
anchorOffset,
focusOffset
});
}
/* istanbul ignore next */ throw new Error('Not implemented. The result of this interaction is unreliable.');
}
export { setSelectionRange };

View File

@@ -0,0 +1,38 @@
import '../../utils/dataTransfer/Clipboard.js';
import { getContentEditable } from '../../utils/edit/isContentEditable.js';
import { hasOwnSelection } from '../../utils/focus/selection.js';
// The browser implementation seems to have changed.
// When focus is inside <input type="text"/>,
// Chrome updates Selection to be collapsed at the position of the input element.
// TODO: update implementation to match that of current browsers
/**
* Reset the Document Selection when moving focus into an element
* with own selection implementation.
*/ function updateSelectionOnFocus(element) {
const selection = element.ownerDocument.getSelection();
/* istanbul ignore if */ if (!(selection === null || selection === undefined ? undefined : selection.focusNode)) {
return;
}
// If the focus moves inside an element with own selection implementation,
// the document selection will be this element.
// But if the focused element is inside a contenteditable,
// 1) a collapsed selection will be retained.
// 2) other selections will be replaced by a cursor
// 2.a) at the start of the first child if it is a text node
// 2.b) at the start of the contenteditable.
if (hasOwnSelection(element)) {
const contenteditable = getContentEditable(selection.focusNode);
if (contenteditable) {
if (!selection.isCollapsed) {
var _contenteditable_firstChild;
const focusNode = ((_contenteditable_firstChild = contenteditable.firstChild) === null || _contenteditable_firstChild === undefined ? undefined : _contenteditable_firstChild.nodeType) === 3 ? contenteditable.firstChild : contenteditable;
selection.setBaseAndExtent(focusNode, 0, focusNode, 0);
}
} else {
selection.setBaseAndExtent(element, 0, element, 0);
}
}
}
export { updateSelectionOnFocus };

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,7 @@
import { getConfig } from '@testing-library/dom';
function wrapEvent(cb, _element) {
return getConfig().eventWrapper(cb);
}
export { wrapEvent };