Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions __tests__/popoverFocusOpens.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2026 Qore Technologies, s.r.o.
import { act, fireEvent, render } from '@testing-library/react';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReqoreContent, ReqoreDropdown, ReqoreInput, ReqoreLayoutContent, ReqoreUIProvider } from '../src';

/**
* A list opened by focus is opened by the field being focused - however it came to be.
*
* The popover listened for `focusin` only, so a field focused as it mounts (an editor that opens with the
* cursor in its field) was focused before the listener was there, and the list of what can go in it (a
* form's templates) did not appear until the user left the field and came back.
*/

const isOpen = () => document.querySelectorAll('.reqore-popover-content').length === 1;

/** An input focused as it mounts, before the popover's effects run. */
const AutoFocusedInput = forwardRef<HTMLInputElement | null, any>((_props, ref) => {
const inner = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inner.current as HTMLInputElement);
useEffect(() => {
inner.current?.focus();
}, []);
return <input ref={inner} className='auto-focused' />;
});

const items = [
{ label: 'qty', value: '$record:{qty}' },
{ label: 'price', value: '$record:{price}' },
];

const Field = ({ autoFocused }: { autoFocused?: boolean }) => (
<ReqoreUIProvider>
<ReqoreLayoutContent>
<ReqoreContent>
<ReqoreDropdown component={autoFocused ? AutoFocusedInput : ReqoreInput} handler='focus' items={items} />
</ReqoreContent>
</ReqoreLayoutContent>
</ReqoreUIProvider>
);

const show = (autoFocused?: boolean) => {
act(() => {
render(<Field autoFocused={autoFocused} />);
});
vi.advanceTimersByTime(1);
};

const settle = () => {
act(() => {
vi.advanceTimersByTime(1);
});
};

describe('a list opened by focus', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it('opens when the field is focused', () => {
show();
expect(isOpen()).toBe(false);
fireEvent.focus(document.querySelector('.reqore-input') as HTMLElement);
settle();
expect(isOpen()).toBe(true);
});

it('opens for a field focused as it mounts', () => {
show(true);
settle();
expect(document.activeElement?.className).toBe('auto-focused');
expect(isOpen()).toBe(true);
});

it('stays closed after Escape while the field keeps the focus', () => {
// the focus check is made once per field: the list's own opening and closing re-run the popover's
// listener effect, and a check on every re-run reopened the list Escape had just closed
show();
const field = document.querySelector('.reqore-input') as HTMLInputElement;
act(() => {
field.focus();
});
settle();
expect(isOpen()).toBe(true);
act(() => {
fireEvent.keyDown(document, { key: 'Escape' });
});
settle();
expect(document.activeElement).toBe(field);
expect(isOpen()).toBe(false);
});

it('stays closed for a field nobody is in', () => {
show();
settle();
expect(document.activeElement).toBe(document.body);
expect(isOpen()).toBe(false);
});
});
78 changes: 78 additions & 0 deletions __tests__/richTextEditorValueSync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright 2026 Qore Technologies, s.r.o.
import { act, render } from '@testing-library/react';
import { createRef, startTransition, useLayoutEffect, useState } from 'react';
import { Editor, Transforms } from 'slate';
import {
ReqoreContent,
ReqoreLayoutContent,
ReqoreRichTextEditor,
ReqoreUIProvider,
TReqoreRichTextEditorRef,
} from '../src';

/**
* A value the editor is given is compared with its document when it is committed, not later.
*
* The comparison ran in a passive effect, which the browser runs after painting - and, under load, after
* the next key. A value that matched the document when it was rendered was compared with a document that
* had a key more by then, read as a value from outside, and put in place of the document: the key was lost
* and the caret went to the end (reqraft's With Markdown Docs typed "arr.for" and got "arr.fr" in CI).
*/
const doc = (text: string) => [{ type: 'paragraph' as const, children: [{ text }] }];
const textOf = (editor: TReqoreRichTextEditorRef) => Editor.string(editor as never, []);

let setValue: (text: string) => void = () => undefined;
/** Runs once the commit that renders a new value is laid out - before its passive effects. */
let onCommit: (() => void) | undefined;

const CommitProbe = ({ value }: { value: string }) => {
useLayoutEffect(() => {
const run = onCommit;
onCommit = undefined;
run?.();
}, [value]);
return null;
};

const Host = ({ editorRef }: { editorRef: React.RefObject<TReqoreRichTextEditorRef> }) => {
const [value, setState] = useState('ab');
setValue = setState;
return (
<ReqoreUIProvider>
<ReqoreLayoutContent>
<ReqoreContent>
<ReqoreRichTextEditor ref={editorRef} value={doc(value)} onChange={() => undefined} />
<CommitProbe value={value} />
</ReqoreContent>
</ReqoreLayoutContent>
</ReqoreUIProvider>
);
};

test('a key typed between a value and its passive effects is kept', async () => {
const editorRef = createRef<TReqoreRichTextEditorRef>();
render(<Host editorRef={editorRef} />);
const editor = editorRef.current!;
// the user typed "c": the editor holds "abc", and its host hands that back
act(() => {
Transforms.select(editor as never, Editor.end(editor as never, []));
Transforms.insertText(editor as never, 'c');
});
expect(textOf(editor)).toBe('abc');
// the host's render of "abc" is committed; before its passive effects run, the next key comes in
onCommit = () => Transforms.insertText(editor as never, 'd');
await act(async () => {
startTransition(() => setValue('abc'));
});
// the key is kept: "abc" matched the document when it was committed, and is not applied after
expect(textOf(editor)).toBe('abcd');
});

test('a value from outside still replaces the document', async () => {
const editorRef = createRef<TReqoreRichTextEditorRef>();
render(<Host editorRef={editorRef} />);
await act(async () => {
setValue('x == 1');
});
expect(textOf(editorRef.current!)).toBe('x == 1');
});
140 changes: 140 additions & 0 deletions __tests__/table-drag-to-scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { fireEvent, render } from '@testing-library/react';
import { ReqoreContent, ReqoreLayoutContent, ReqoreTable, ReqoreUIProvider } from '../src';
import tableData from '../src/mock/tableData';

/**
* A table wider than its frame, scrolled by pressing and pulling it - on its body and,
* the part that did not work, on its header.
*
* The header is scrolled WITH the body (the body syncs it) but is not a scroller of its
* own, so neither a finger nor the mouse could move the table from it: a swipe on the
* header did nothing on a phone while the same swipe on the rows scrolled them.
*/

/** jsdom lays nothing out: say that the body overflows. */
const setOverflow = (element: HTMLElement, { scrollWidth = 2000, clientWidth = 500 } = {}) => {
Object.defineProperty(element, 'scrollWidth', { value: scrollWidth, configurable: true });
Object.defineProperty(element, 'clientWidth', { value: clientWidth, configurable: true });
};

/** jsdom has no PointerEvent; these are the fields the gesture branches on. */
const pointer = (
element: Element,
type: 'pointerDown' | 'pointerMove' | 'pointerUp',
{ clientX = 0, pointerType = 'mouse', buttons = type === 'pointerUp' ? 0 : 1 } = {}
) => fireEvent[type](element, { clientX, pointerType, button: 0, buttons, pointerId: 1 });

const renderTable = (props: Record<string, unknown> = {}) => {
// the first column's header reports its clicks
const onHeaderClick = vi.fn();
const columns = tableData.columns.map((column, index) =>
index === 0 ? { ...column, header: { ...column.header, onClick: onHeaderClick } } : column
);
const { container } = render(
<ReqoreUIProvider>
<ReqoreLayoutContent>
<ReqoreContent>
<ReqoreTable {...tableData} columns={columns} height={300} {...props} />
</ReqoreContent>
</ReqoreLayoutContent>
</ReqoreUIProvider>
);
const body = container.querySelector('.reqore-table-body') as HTMLElement;
const header = container.querySelector('.reqore-table-header-wrapper') as HTMLElement;
const headerCell = header.querySelector('.reqore-table-header-cell') as HTMLElement;
setOverflow(body);
return { body, header, headerCell, onHeaderClick };
};

test('a finger swiped sideways on the header scrolls the table', () => {
const { body, headerCell } = renderTable();

pointer(headerCell, 'pointerDown', { clientX: 300, pointerType: 'touch' });
pointer(headerCell, 'pointerMove', { clientX: 180, pointerType: 'touch' });
pointer(headerCell, 'pointerUp', { clientX: 180, pointerType: 'touch' });

expect(body.scrollLeft).toBe(120);
});

test('the header leaves up-and-down to the page and takes sideways swipes', () => {
const { header } = renderTable();

expect(getComputedStyle(header).touchAction).toBe('pan-y');
});

// A header cell's click is its own: it opens the column's options, and the column's own onClick runs.
test('with dragToScroll, the mouse pulls the table by its header, and the press is not a click on it', () => {
const { body, headerCell, onHeaderClick } = renderTable({ dragToScroll: true });

pointer(headerCell, 'pointerDown', { clientX: 400 });
pointer(headerCell, 'pointerMove', { clientX: 250 });
pointer(headerCell, 'pointerUp', { clientX: 250 });
fireEvent.click(headerCell);

expect(body.scrollLeft).toBe(150);
expect(onHeaderClick).not.toHaveBeenCalled();
});

test('with dragToScroll, a press on the header that does not move is still a click', () => {
const { body, headerCell, onHeaderClick } = renderTable({ dragToScroll: true });

pointer(headerCell, 'pointerDown', { clientX: 400 });
pointer(headerCell, 'pointerMove', { clientX: 398 });
pointer(headerCell, 'pointerUp', { clientX: 398 });
fireEvent.click(headerCell);

expect(body.scrollLeft).toBe(0);
expect(onHeaderClick).toHaveBeenCalled();
});

test('with dragToScroll, the mouse pulls the table by its body', () => {
const { body } = renderTable({ dragToScroll: true });
const cell = body.querySelector('.reqore-table-cell') ?? body;

pointer(cell, 'pointerDown', { clientX: 400 });
pointer(cell, 'pointerMove', { clientX: 330 });
pointer(cell, 'pointerUp', { clientX: 330 });

expect(body.scrollLeft).toBe(70);
});

test('without dragToScroll the mouse does not drag the table, as before', () => {
const { body, headerCell } = renderTable();

pointer(headerCell, 'pointerDown', { clientX: 400 });
pointer(headerCell, 'pointerMove', { clientX: 250 });
pointer(headerCell, 'pointerUp', { clientX: 250 });

expect(body.scrollLeft).toBe(0);
});

test('Shift with a plain wheel on the header scrolls the table sideways', () => {
const { body, header } = renderTable();
body.scrollTo = ((options: ScrollToOptions) => {
body.scrollLeft = options.left ?? body.scrollLeft;
}) as typeof body.scrollTo;

fireEvent.wheel(header, { deltaY: 90, shiftKey: true });

expect(body.scrollLeft).toBe(90);
});

test('with dragToScroll, the body says it can be pulled', () => {
const { body } = renderTable({ dragToScroll: true });

expect(body.closest('.reqore-table-wrapper-draggable')).not.toBeNull();
});

test('a finger keeps scrolling when the cell it landed on loses its own capture', () => {
// A touch pointer is captured by the element it lands on; the drag taking the capture for
// the header makes that element report losing it. That is the drag starting, not ending.
const { body, headerCell } = renderTable();

pointer(headerCell, 'pointerDown', { clientX: 300, pointerType: 'touch' });
pointer(headerCell, 'pointerMove', { clientX: 290, pointerType: 'touch' });
fireEvent(headerCell, Object.assign(new Event('lostpointercapture', { bubbles: true }), { pointerId: 1 }));
pointer(headerCell, 'pointerMove', { clientX: 200, pointerType: 'touch' });
pointer(headerCell, 'pointerUp', { clientX: 200, pointerType: 'touch' });

expect(body.scrollLeft).toBe(100);
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@qoretechnologies/reqore",
"version": "0.78.0",
"version": "0.78.3",
"description": "ReQore is a highly theme-able and modular UI library for React",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
Loading
Loading