diff --git a/__tests__/popoverFocusOpens.test.tsx b/__tests__/popoverFocusOpens.test.tsx new file mode 100644 index 00000000..5e13e5cb --- /dev/null +++ b/__tests__/popoverFocusOpens.test.tsx @@ -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((_props, ref) => { + const inner = useRef(null); + useImperativeHandle(ref, () => inner.current as HTMLInputElement); + useEffect(() => { + inner.current?.focus(); + }, []); + return ; +}); + +const items = [ + { label: 'qty', value: '$record:{qty}' }, + { label: 'price', value: '$record:{price}' }, +]; + +const Field = ({ autoFocused }: { autoFocused?: boolean }) => ( + + + + + + + +); + +const show = (autoFocused?: boolean) => { + act(() => { + render(); + }); + 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); + }); +}); diff --git a/__tests__/richTextEditorValueSync.test.tsx b/__tests__/richTextEditorValueSync.test.tsx new file mode 100644 index 00000000..9de526a7 --- /dev/null +++ b/__tests__/richTextEditorValueSync.test.tsx @@ -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 }) => { + const [value, setState] = useState('ab'); + setValue = setState; + return ( + + + + undefined} /> + + + + + ); +}; + +test('a key typed between a value and its passive effects is kept', async () => { + const editorRef = createRef(); + render(); + 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(); + render(); + await act(async () => { + setValue('x == 1'); + }); + expect(textOf(editorRef.current!)).toBe('x == 1'); +}); diff --git a/__tests__/table-drag-to-scroll.test.tsx b/__tests__/table-drag-to-scroll.test.tsx new file mode 100644 index 00000000..f1ee9fb0 --- /dev/null +++ b/__tests__/table-drag-to-scroll.test.tsx @@ -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 = {}) => { + // 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( + + + + + + + + ); + 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); +}); diff --git a/package.json b/package.json index a43a1bd1..1e2fb07d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/COMPONENTS.md b/src/components/COMPONENTS.md index 6e486d7d..57c63461 100644 --- a/src/components/COMPONENTS.md +++ b/src/components/COMPONENTS.md @@ -49,11 +49,11 @@ | **ReqorePaging** | Pagination controls component with page buttons, load more, and scroll-based infinite loading options. | | **ReqorePanel** | Container component with optional header, footer, actions, and resizable panels with breadcrumbs support. **An action's `responsive: false` is honoured by the panel itself as of 0.76.0.** A narrow panel drops its actions' labels and keeps the icons — the right trade for a toolbar, since the label is a verb the icon already carries — and `responsive: false` is how an action says it is not one of those. The prop existed and the panel never read it: only the `ReqoreControlGroup` rendering the actions did, so the panel stripped the label back off and the action rendered as a bare icon anyway. It now reads it where the label is decided, which is why `Layout/Panel → Fluid` finally shows the contrast its fixture was written for ("Visible" keeps its label, "Hidden when small" does not). Use it for an action whose icon cannot carry its meaning alone — a destructive one, or either half of a two-way choice. Optional `raised` adds a subtle 3D inset highlight when paired with `flat`, suppressed whenever the panel actually draws a border — normally that means an `intent` is set, but an `accentPosition` panel draws no border, so `raised` applies there too. Optional `accentPosition` (`'left'` \| `'top'`) + `accentSize` (px or a `TSizes` name via `ACCENT_SIZE_TO_PX`; default `5` = `'normal'`) render the intent as a single edge strip instead of tinting the whole border (the border goes neutral — at rest AND on hover — or disappears on `flat` panels) — mirroring ReqoreCallout's accent API. With `stickyHeader` the wrapper stops clipping, so the strip rounds its own corners to match the panel. `stickyHeader` pins flush to the visible top edge of whatever scrolls, even when that scrollport carries its own top padding — which every default panel/drawer/modal body does, since the panel body puts `padding` and `overflow` on one element. The panel measures that padding at runtime and subtracts it from the sticky `top`; `stickyHeaderOffset` remains a deliberate caller offset applied on top of that corrected zero. Note the scroll ancestor is resolved by walking for `overflow-y: auto | scroll | overlay | hidden` — `hidden` counts, because it is a scroll container and does capture sticky. Header layout is configurable via `iconWithLabel` (default `false`; when `true`, the leading icon renders inside the label+badge row so the description sits beneath both icon and label) and `iconVerticalAlign` (`'top'` \| `'center'` \| `'bottom'` — default `'center'`; only takes effect when `iconWithLabel={false}`). Accepts an optional `radiusSize` (`TSizes`) that overrides the default `size`-derived corner roundness using the pronounced `RADIUS_FROM_RADIUS_SIZE` scale. An action declared `show: 'hover'` is hidden until the panel is hovered — but ONLY where the pointer can hover: the hiding rule sits inside a `@media (hover: hover) and (pointer: fine)` query, so on touch the action stays visible instead of being `display: none` with no route to it. **Media** (new in 0.78.0): `media` (an image URL, or any node — ``, ``, `