diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 8182323e..b62f48e8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -184,6 +184,20 @@ release on npm. - **Location:** `src/stories/` (e.g. `Collection.stories.tsx`). - **Pattern:** ArgTypes for props, canvas controls, visual testing via Chromatic. - **Command:** `yarn build-storybook` builds the static site. +- **Touch stories:** a story whose play calls `withTouchScreen` (`src/stories/utils/touchScreen.ts`) + carries `tags: ['touch']`. `yarn test:stories` runs those stories in their own Vitest project + (`storybook-touch`), whose browser context is a phone from the start (Playwright's `isMobile` and + `hasTouch`, 390×844), and before the others; an untagged one fails with the reason. Never turn + Chromium's touch emulation on or off over the DevTools protocol: turning it off leaves the page + with the machine's hover and pointer, not the mouse Playwright launched it with, and a Linux CI + runner has none, so every later story in the page finds `(hover: none)` (it never shows on a Mac, + which always reports a mouse). `.storybook/vitest.setup.ts` checks every story's browser is its + project's: `(hover: hover) and (pointer: fine)`, or on the phone `(hover: none) and (pointer: coarse)`. +- **No layout change inside a ResizeObserver callback:** a story fails when the browser reports + "ResizeObserver loop completed with undelivered notifications" (`.storybook/vitest.setup.ts`). It + means a resize callback changed the layout it observes in the same frame, often by setting state + synchronously (react-popper's `forceUpdate` renders with `flushSync`). Defer that change to the next + frame (`requestAnimationFrame`, once per frame), or measure without changing the layout. ### Verifying a visual fix locally with Qlip — reqore mechanics diff --git a/.eslintrc.js b/.eslintrc.js index 7ef3c1cc..749af058 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,6 +5,11 @@ module.exports = { }, extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', 'plugin:react/jsx-runtime', 'plugin:storybook/recommended'], overrides: [ + { + // Stories are not shipped; the helper wraps styled-components' own `styled`. + files: ['src/stories/**', 'src/helpers/styled.ts'], + rules: { 'no-restricted-imports': 'off' }, + }, { env: { node: true, @@ -37,6 +42,20 @@ module.exports = { // Allow the "omit a key" idiom — `const { dropped, ...rest } = obj` — where // the destructured sibling is intentionally discarded. '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }], + // Reqore's `styled` (src/helpers/styled.ts) keeps styling props that are also HTML attribute + // names (`size`, `disabled`, `checked`, ...) off the elements they are not attributes of. + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'styled-components', + importNames: ['default'], + message: "Import `styled` from 'src/helpers/styled' (it filters DOM props).", + }, + ], + }, + ], }, // Ignore storybook files ignorePatterns: ['**/stories/*', '**/mock/*'], diff --git a/.storybook/reactWarnings.ts b/.storybook/reactWarnings.ts new file mode 100644 index 00000000..6aa6417f --- /dev/null +++ b/.storybook/reactWarnings.ts @@ -0,0 +1,9 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { watchReactWarnings } from '../src/stories/utils/reactWarningGate'; + +/** + * The console's React warnings, watched from before any of reqore's modules load: `vitest.setup.ts` imports this + * module first, so a warning a module gives as it loads (a library calling a deprecated React API at import) is kept + * too, and fails the story file's first story (`src/stories/utils/reactWarningGate.ts`). + */ +export const reactWarnings = watchReactWarnings(); diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts index 541f76ba..5890a21b 100644 --- a/.storybook/vitest.setup.ts +++ b/.storybook/vitest.setup.ts @@ -1,7 +1,21 @@ +// first: it watches the console for React's warnings before any other module loads (`reactWarnings.ts`) +import { reactWarnings } from './reactWarnings'; import { setProjectAnnotations } from '@storybook/react-vite'; import { createElement } from 'react'; -import { beforeAll } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, expect } from 'vitest'; +import { server } from 'vitest/browser'; import * as previewAnnotations from './preview'; +import { reactWarningsFailure, type IReactWarning } from '../src/stories/utils/reactWarningGate'; +import { TOUCH_STORY_PROJECT } from '../src/stories/utils/touchScreen'; + +/** Whether this run's browser is the phone the stories tagged `touch` run on (vitest.config.ts). */ +const onThePhone = (): boolean => { + // a project's browser runs as a project of its own, named after both: + // "storybook-touch (chromium)" + const project = server.config.name ?? ''; + + return project === TOUCH_STORY_PROJECT || project.startsWith(`${TOUCH_STORY_PROJECT} (`); +}; // Registers the React renderer (renderToCanvas) plus the project's global // decorators/parameters from .storybook/preview for the Storybook Vitest project. @@ -17,6 +31,21 @@ const annotations = setProjectAnnotations([ // the story test times out. A 100vh frame resolves regardless of parent. (Story: any) => createElement('div', { style: { height: '100vh' } }, createElement(Story)), ], + /* A story tagged `touch` runs on the phone. The two story projects tell those stories from the + others by the tags Storybook reads from the story file before it runs it (vitest.config.ts); + a tag the story only has once it runs - spread into it from a shared object - is not one of + them, and the story would run here, on the desktop, with a mouse. It fails instead, saying + how to tag it. */ + beforeEach: ({ tags, title, name }) => { + if (tags.includes('touch') && !onThePhone()) { + throw new Error( + `"${title} / ${name}" is tagged 'touch' only once it runs, so it ran in the ` + + `"${server.config.name}" project, whose browser has a mouse. Write the tag in the ` + + `story file, as a string in the story's or its meta's own \`tags\` ` + + `(tags: ['touch']), so that it runs on the phone.` + ); + } + }, }, ]); @@ -31,3 +60,74 @@ style.textContent = ` } `; document.head.appendChild(style); + +/* A story fails when a ResizeObserver callback changes the layout it observes in the same frame: the + browser leaves the change unreported until the next one and says so with an error event whose message + is "ResizeObserver loop completed with undelivered notifications" (qorus#646). Vitest only logs that + event (it carries no error object), so it is caught here. The listener goes on through EventTarget, not + `window.addEventListener`: Vitest counts the error listeners a page adds that way and stops reporting + any unhandled error once there is one. */ +const resizeLoops: string[] = []; + +EventTarget.prototype.addEventListener.call(window, 'error', (event: Event) => { + const { message } = event as ErrorEvent; + + if (typeof message === 'string' && message.includes('ResizeObserver loop')) { + resizeLoops.push(message); + } +}); + +beforeEach(() => { + resizeLoops.length = 0; +}); + +afterEach(async () => { + // the browser reports it after the frame's layout: let the frames after the story's last change pass + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ); + expect( + resizeLoops, + 'a ResizeObserver callback changed the layout it observes in the same frame - defer that change to the next frame' + ).toEqual([]); +}); + +/* Each story's browser is the one its project promises (vitest.config.ts): a desktop with a mouse, or, for + the stories tagged `touch`, a phone. The phone's stories run first, so every desktop story checks, after + them, that it still has its mouse: a touch screen that left a desktop page without one (qorus#646) fails + the next story here. */ +beforeEach(() => { + const project = server.config.name ?? ''; + const promised = onThePhone() + ? '(hover: none) and (pointer: coarse)' + : '(hover: hover) and (pointer: fine)'; + + expect(matchMedia(promised).matches, `the "${project}" project's browser matches ${promised}`).toBe(true); +}); + +// No story makes React warn (`src/stories/utils/reactWarningGate.ts`): each warning fails the story it was given in. +// One given before the story started - as its file loaded, or as the story before it unmounted - fails it too, saying +// so; one given after a file's last story fails the file. +let reactWarningsBeforeStory: IReactWarning[] = []; + +beforeEach(() => { + reactWarningsBeforeStory = reactWarnings.take(); +}); + +afterEach(() => { + const failure = reactWarningsFailure({ + before: reactWarningsBeforeStory, + during: reactWarnings.take(), + }); + reactWarningsBeforeStory = []; + if (failure) { + throw failure; + } +}); + +afterAll(() => { + const failure = reactWarningsFailure({ after: reactWarnings.take() }); + if (failure) { + throw failure; + } +}); diff --git a/__tests__/EntityRow.test.tsx b/__tests__/EntityRow.test.tsx index c6ca914c..935deea5 100644 --- a/__tests__/EntityRow.test.tsx +++ b/__tests__/EntityRow.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from '@testing-library/react'; +import { cleanup, fireEvent, render } from '@testing-library/react'; import { ReqoreContent, ReqoreEntityRow, @@ -609,3 +609,103 @@ test('Renders without a ResizeObserver implementation', () => { globalThis.ResizeObserver = original; } }); + +/* + * Narrow, two or more actions are one menu button (qorus#646): the label keeps the row's width. + */ + +const menuButtons = () => document.querySelectorAll('.reqore-row-actions-menu-button'); + +test('Folds two or more actions into one menu button in a narrow container', () => { + withResizeObserver(NarrowResizeObserver, () => { + NarrowResizeObserver.width = 400; + renderRowWithActions({ actions: [{ label: 'Run' }, { icon: 'DeleteBinLine', tooltip: 'Remove' }] }); + + const row = document.querySelector('.reqore-entity-row')!; + expect(row.getAttribute('data-actions')).toBe('menu'); + expect(menuButtons()).toHaveLength(1); + expect(menuButtons()[0].getAttribute('aria-label')).toBe('Actions'); + expect(row.querySelectorAll('.reqore-entity-row-actions .reqore-button')).toHaveLength(1); + }); +}); + +test('Names the menu button with actionsMenuLabel', () => { + withResizeObserver(NarrowResizeObserver, () => { + NarrowResizeObserver.width = 400; + renderRowWithActions({ + actionsMenuLabel: 'Akce', + actions: [{ label: 'Run' }, { label: 'Stop' }], + }); + + expect(menuButtons()[0].getAttribute('aria-label')).toBe('Akce'); + }); +}); + +test('Keeps the actions in sight, stacked, with narrowActions="stack"', () => { + withResizeObserver(NarrowResizeObserver, () => { + NarrowResizeObserver.width = 400; + renderRowWithActions({ narrowActions: 'stack', actions: [{ label: 'Run' }, { label: 'Stop' }] }); + + const row = document.querySelector('.reqore-entity-row')!; + expect(row.getAttribute('data-narrow')).toBe('true'); + expect(row.getAttribute('data-actions')).toBeNull(); + expect(menuButtons()).toHaveLength(0); + expect(row.querySelectorAll('.reqore-entity-row-actions .reqore-button')).toHaveLength(2); + }); +}); + +test('Stacked, labels an icon-only action that is a menu with its tooltip', () => { + withResizeObserver(NarrowResizeObserver, () => { + NarrowResizeObserver.width = 400; + renderRowWithActions({ + narrowActions: 'stack', + actions: [ + { label: 'Run' }, + { icon: 'More2Line', tooltip: 'More actions', actions: [{ label: 'Show in chat' }] }, + ], + }); + + const [, more] = Array.from( + document.querySelector('.reqore-entity-row-actions')!.children + ) as HTMLElement[]; + expect(more.textContent).toContain('More actions'); + }); +}); + +test('Keeps a single action a button when narrow, and every action inline when wide', () => { + withResizeObserver(NarrowResizeObserver, () => { + NarrowResizeObserver.width = 400; + renderRowWithActions({ actions: [{ label: 'Run' }] }); + expect(menuButtons()).toHaveLength(0); + cleanup(); + + NarrowResizeObserver.width = 900; + renderRowWithActions({ actions: [{ label: 'Run' }, { label: 'Stop' }] }); + expect(menuButtons()).toHaveLength(0); + expect(document.querySelectorAll('.reqore-entity-row-actions .reqore-button')).toHaveLength(2); + }); +}); + +test('Stacked, labels an action whose label is empty with its tooltip, a menu too', () => { + withResizeObserver(NarrowResizeObserver, () => { + NarrowResizeObserver.width = 400; + renderRowWithActions({ + narrowActions: 'stack', + actions: [ + { label: '', icon: 'DeleteBinLine', tooltip: 'Remove' }, + { + label: '', + icon: 'More2Line', + tooltip: 'More actions', + actions: [{ label: 'Show in chat' }], + }, + ], + }); + + const [remove, more] = Array.from( + document.querySelector('.reqore-entity-row-actions')!.children + ) as HTMLElement[]; + expect(remove.textContent).toContain('Remove'); + expect(more.textContent).toContain('More actions'); + }); +}); diff --git a/__tests__/NavRail.test.tsx b/__tests__/NavRail.test.tsx index e148a94c..7d2bdcbb 100644 --- a/__tests__/NavRail.test.tsx +++ b/__tests__/NavRail.test.tsx @@ -433,6 +433,34 @@ describe("showLabels='hover'", () => { expect(dashboard().textContent).toBe(''); }); + test("a finger's tap dwells no labels open: the mouseenter emulated after it is no hover", () => { + renderRail(); + const nav = screen.getByRole('navigation'); + + fireEvent.pointerEnter(nav, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerDown(nav, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerUp(nav, { pointerType: 'touch', buttons: 0 }); + fireEvent.mouseEnter(nav); + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(dashboard().textContent).toBe(''); + }); + + test('a mouse dwells them open, its pointer event before its mouseenter', () => { + renderRail(); + const nav = screen.getByRole('navigation'); + + fireEvent.pointerEnter(nav, { pointerType: 'mouse', buttons: 0 }); + fireEvent.mouseEnter(nav); + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect(dashboard()).toHaveTextContent('Dashboard'); + }); + test('showLabelsDelay sets the dwell', () => { renderRail( { expect(document.querySelectorAll('.reqore-rating-item').length).toBe(5); expect(document.querySelectorAll('.reqore-icon').length).toBe(5); }); + +/** A rating that holds the value it is given, and says what it shows. */ +const HeldRating = () => { + const [value, setValue] = useState(3); + return ( + + + + + + + + ); +}; + +test("A finger's tap previews nothing: a value the tap cleared reads as cleared", () => { + render(); + const third = document.querySelectorAll('.reqore-rating-item')[2]; + + // a tap, as a browser sends it: the finger's pointer events, then the mouse events it emulates + fireEvent.pointerOver(third, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerDown(third, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerUp(third, { pointerType: 'touch', buttons: 0 }); + fireEvent.mouseMove(third); + fireEvent.click(third); + + expect(screen.getByText('0 / 5')).toBeTruthy(); +}); + +test('A mouse resting on a star previews it', () => { + render(); + const second = document.querySelectorAll('.reqore-rating-item')[1]; + + fireEvent.pointerMove(second, { pointerType: 'mouse', buttons: 0 }); + fireEvent.mouseMove(second); + + expect(screen.getByText('2 / 5')).toBeTruthy(); +}); diff --git a/__tests__/SeverityRow.test.tsx b/__tests__/SeverityRow.test.tsx index 209dc596..ba808d98 100644 --- a/__tests__/SeverityRow.test.tsx +++ b/__tests__/SeverityRow.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from '@testing-library/react'; +import { cleanup, fireEvent, render } from '@testing-library/react'; import { ReqoreContent, ReqoreLayoutContent, @@ -465,3 +465,84 @@ test('Renders with custom paddingSize', () => { expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); }); + +/* + * Narrow (below 640px of its own container), two or more actions are one menu button (qorus#646). + */ + +class RowResizeObserver { + static width = 400; + readonly disconnect = vi.fn(); + constructor(readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + this.callback( + [{ target, contentRect: { width: RowResizeObserver.width } } as ResizeObserverEntry], + this as unknown as ResizeObserver + ); + } +} + +const renderMeasured = (width: number, props: Record) => { + const original = globalThis.ResizeObserver; + RowResizeObserver.width = width; + (globalThis as Record).ResizeObserver = RowResizeObserver; + try { + render( + + + + + + + + ); + } finally { + globalThis.ResizeObserver = original; + } +}; + +const TWO_ACTIONS = [{ label: 'Investigate' }, { icon: 'CloseLine', tooltip: 'Dismiss' }]; + +test('Folds two or more actions into one menu button below 640px of its container, with narrowActions="menu"', () => { + renderMeasured(600, { actions: TWO_ACTIONS, narrowActions: 'menu' }); + + const row = document.querySelector('.reqore-severity-row')!; + expect(row.getAttribute('data-narrow')).toBe('true'); + expect(row.getAttribute('data-actions')).toBe('menu'); + const buttons = row.querySelectorAll('.reqore-severity-row-actions .reqore-button'); + expect(buttons).toHaveLength(1); + expect(buttons[0].classList.contains('reqore-row-actions-menu-button')).toBe(true); + expect(buttons[0].getAttribute('aria-label')).toBe('Actions'); +}); + +test('Keeps a callout\'s actions in sight, stacked, by default and with narrowActions="stack"', () => { + for (const props of [{}, { narrowActions: 'stack' as const }]) { + renderMeasured(600, { actions: TWO_ACTIONS, ...props }); + + const row = document.querySelector('.reqore-severity-row')!; + expect(row.getAttribute('data-actions')).toBeNull(); + expect(document.querySelectorAll('.reqore-row-actions-menu-button')).toHaveLength(0); + expect(row.querySelectorAll('.reqore-severity-row-actions .reqore-button')).toHaveLength(2); + cleanup(); + } +}); + +test('Keeps every action inline in a wide container, and a single one a button when narrow', () => { + renderMeasured(800, { actions: TWO_ACTIONS }); + expect(document.querySelectorAll('.reqore-row-actions-menu-button')).toHaveLength(0); + expect(document.querySelectorAll('.reqore-severity-row-actions .reqore-button')).toHaveLength(2); + cleanup(); + + renderMeasured(400, { actions: [{ label: 'Investigate' }] }); + expect(document.querySelectorAll('.reqore-row-actions-menu-button')).toHaveLength(0); +}); + +test('Stacked, labels an action whose label is empty with its tooltip', () => { + renderMeasured(400, { + actions: [{ label: 'Investigate' }, { label: '', icon: 'CloseLine', tooltip: 'Dismiss' }], + }); + + const buttons = document.querySelectorAll('.reqore-severity-row-actions .reqore-button'); + expect(buttons).toHaveLength(2); + expect(buttons[1].textContent).toContain('Dismiss'); +}); diff --git a/__tests__/blockDescriptions.test.tsx b/__tests__/blockDescriptions.test.tsx new file mode 100644 index 00000000..098f7a7f --- /dev/null +++ b/__tests__/blockDescriptions.test.tsx @@ -0,0 +1,219 @@ +import { render } from '@testing-library/react'; +import { ReactElement, ReactNode } from 'react'; +import { + ReqoreButton, + ReqoreCallout, + ReqoreContent, + ReqoreControlGroup, + ReqoreEntityRow, + ReqoreFeatureCard, + ReqoreLayoutContent, + ReqoreP, + ReqoreSeverityRow, + ReqoreTestimonial, + ReqoreTier, + ReqoreUIProvider, +} from '../src'; + +/** + * A `description` (and a testimonial's `quote`) takes block content. + * + * Consumers pass paragraphs and control groups there. Drawn inside a `

`, that is invalid HTML: + * the browser's parser closes the paragraph at the first block, moving the content out of it and + * out of its styling, and React warns ("validateDOMNesting:

cannot appear as a descendant + * of

"). Content that is not text is drawn in a `div` with the same typography instead, and + * the component around it is a block too — a `span` may not hold one either. Plain text stays a + * `

`, exactly as before, so consumer CSS aimed at `p` still matches it. + * + * React warns about a nesting pair once per page, so the structure is checked as well. + */ + +/** Block content, as consumers pass it. */ +const BLOCK_CONTENT = ( + <> + First paragraph + + Action + + +); + +const PLAIN_TEXT = 'Plain text description'; + +/** Each component, given a description, and the class of the element that draws it. */ +const COMPONENTS: [string, string, (description: ReactNode) => ReactElement][] = [ + [ + 'ReqoreEntityRow', + 'reqore-entity-row-description', + (description) => , + ], + [ + 'ReqoreSeverityRow', + 'reqore-severity-row-description', + (description) => , + ], + [ + 'ReqoreCallout with a label', + 'reqore-callout-description', + (description) => , + ], + [ + 'ReqoreCallout with a label and children', + 'reqore-callout-description', + (description) => ( + + Body + + ), + ], + [ + 'ReqoreFeatureCard', + 'reqore-feature-card-description', + (description) => , + ], + [ + 'ReqoreTier', + 'reqore-tier-description', + (description) => , + ], + [ + 'ReqoreTier, modern', + 'reqore-tier-description', + (description) => ( + + ), + ], + [ + 'ReqoreTestimonial', + 'reqore-testimonial-quote-text', + (quote) => , + ], +]; + +const renderInProvider = (element: ReactElement) => + render( + + + {element} + + + ); + +const getNestingWarnings = (): string[] => + vi + .mocked(console.error) + .mock.calls.map((args) => args.map(String).join(' ')) + .filter((message) => message.includes('validateDOMNesting')); + +/** Block elements the parser would not leave where they are: in a `

`, or in a ``. */ +const findMisplacedBlocks = (root: Element): string[] => + Array.from(root.querySelectorAll('p div, p p, p ul, p ol, span div, span p')) + .filter((element) => root.contains(element)) + .map((element) => { + const parent = element.parentElement.closest('p, span'); + + return `<${element.tagName.toLowerCase()}> in <${parent.tagName.toLowerCase()}${ + parent.className ? `.${Array.from(parent.classList).join('.')}` : '' + }>`; + }); + +describe('a description given block content', () => { + beforeEach(() => { + vi.mocked(console.error).mockClear(); + }); + + test.each(COMPONENTS)('%s draws it in a block, not a paragraph', (_name, className, build) => { + const { container } = renderInProvider(build(BLOCK_CONTENT)); + const description = container.querySelector(`.${className}`); + + expect(description).not.toBeNull(); + expect(description.tagName).toBe('DIV'); + // The content is where it was put, inside the description. + expect(description.querySelector('.reqore-probe-paragraph')).toHaveTextContent( + 'First paragraph' + ); + expect(description.querySelector('.reqore-probe-group')).not.toBeNull(); + expect(findMisplacedBlocks(container)).toEqual([]); + expect(getNestingWarnings()).toEqual([]); + }); +}); + +describe('a plain-text description', () => { + test.each(COMPONENTS)( + '%s draws the text alone in a paragraph, as before', + (_name, className, build) => { + const { container } = renderInProvider(build(PLAIN_TEXT)); + const description = container.querySelector(`.${className}`); + + expect(description.tagName).toBe('P'); + expect(description).toHaveTextContent(PLAIN_TEXT); + // The text is the container's only content: no wrapper was added around it. + expect(description.childNodes).toHaveLength(1); + expect(description.firstChild.nodeType).toBe(Node.TEXT_NODE); + expect(findMisplacedBlocks(container)).toEqual([]); + } + ); + + test('is drawn with the same styles as a paragraph', () => { + // styled-components names a class after the CSS it generates, so the same classes mean the + // same styles: rendering the paragraph as a `div` changed the element and nothing else. + const { container } = renderInProvider( + <> + + {PLAIN_TEXT} + + + {PLAIN_TEXT} + + + ); + const [paragraph, block] = Array.from(container.querySelectorAll('.reqore-probe-p')); + + expect(paragraph.tagName).toBe('P'); + expect(block.tagName).toBe('DIV'); + expect(block.className).toBe(paragraph.className); + }); +}); + +describe("a callout's children", () => { + test.each([ + ['under a label', { label: 'Label', description: 'Description' }], + ['on their own', {}], + ])('take block content %s', (_name, props) => { + vi.mocked(console.error).mockClear(); + const { container } = renderInProvider( + {BLOCK_CONTENT} + ); + const content = container.querySelector('.reqore-callout-content'); + + expect(content.tagName).toBe('DIV'); + expect(content.querySelector('.reqore-probe-group')).not.toBeNull(); + expect(findMisplacedBlocks(container)).toEqual([]); + expect(getNestingWarnings()).toEqual([]); + }); +}); + +describe('the nesting detector', () => { + test('finds a block in a paragraph and in a span', () => { + const { container } = renderInProvider( + <> +

+ + Inline is fine + +

+ +
Block
+
+ + ); + + expect(findMisplacedBlocks(container)).toEqual(['
in ']); + }); +}); diff --git a/__tests__/breadcrumbs.test.tsx b/__tests__/breadcrumbs.test.tsx index e853a401..71a15a75 100644 --- a/__tests__/breadcrumbs.test.tsx +++ b/__tests__/breadcrumbs.test.tsx @@ -1,14 +1,93 @@ import { act, fireEvent, render } from '@testing-library/react'; +import { Profiler } from 'react'; import { ReqoreBreadcrumbs, ReqoreLayoutContent, ReqoreUIProvider } from '../src'; +import { getTabsLength } from '../src/components/Tabs/list'; -// jsdom has no layout, so `OverflowList` measures a width of 0 and collapses the -// WHOLE trail (minVisibleItems=0). That fully-collapsed shape — ONE dropdown -// LABELLED with the current page, every crumb in its menu, never an unreadable -// stub and never a bare "…" — is exactly what these tests pin down. The -// width-driven intermediate states (full trail, "… > current page") and the -// no-overlap invariant next to a right element are covered by the Breadcrumbs -// stories, which run in a real browser. The static (`responsive={false}`) trail -// is used where a test needs every crumb rendered (e.g. tooltips). +/* + * jsdom lays nothing out, so these tests lay the trail out themselves: its row is `layout.row` wide + * from 0 (or the bar's `maxWidth`, when it is narrower), and its parts sit side by side from the + * row's start - a crumb `layout.crumb` wide, an arrow 20px, the "…" menu 40px, the dropdown that + * stands in for the current page as wide as the row (it fills it) but never narrower than its 5em + * floor, `STAND_IN_FLOOR`, and the crumb holding tabs at the end of the trail as wide as the room + * the other parts leave it. Every box is scaled by `layout.scale`, as a transform on an ancestor + * scales what `getBoundingClientRect` gives. The trail folds crumbs from its start until no part + * reaches past the row's end. The same, in a real browser and before the browser paints, is covered + * by the Breadcrumbs stories. + */ +const layout = { row: 1000, crumb: 100, scale: 1 }; + +const STAND_IN_FLOOR = 70; + +/** The row's width: the trail's, or the bar's `maxWidth` when that is less. */ +const rowWidth = (row: Element): number => { + const bar = row.closest('.reqore-breadcrumbs-wrapper'); + const maxWidth = parseFloat(bar?.style.maxWidth ?? ''); + + return Number.isNaN(maxWidth) ? layout.row : Math.min(layout.row, maxWidth); +}; + +/** A part's width, `left` from the start of `row`. */ +const partWidth = (part: Element, row: Element, left: number): number => { + if (part.classList.contains('reqore-breadcrumbs-overflow-current')) { + return Math.max(rowWidth(row), STAND_IN_FLOOR); + } + if (part.classList.contains('reqore-breadcrumbs-tabs')) { + return Math.max(0, rowWidth(row) - left); + } + if (part.classList.contains('reqore-breadcrumbs-item')) { + return layout.crumb; + } + return part.classList.contains('reqore-dropdown-control') ? 40 : 20; +}; + +const box = (left: number, width: number) => + ({ + left: left * layout.scale, + right: (left + width) * layout.scale, + width: width * layout.scale, + top: 0, + bottom: 20, + height: 20, + x: left * layout.scale, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + +const realRect = Element.prototype.getBoundingClientRect; + +beforeEach(() => { + layout.row = 1000; + layout.crumb = 100; + layout.scale = 1; + Element.prototype.getBoundingClientRect = function (this: Element) { + if (this.classList.contains('reqore-breadcrumbs-overflow-list')) { + return box(0, rowWidth(this)); + } + const row = this.parentElement; + if (row?.classList.contains('reqore-breadcrumbs-overflow-list')) { + let left = 0; + for (const part of Array.from(row.children)) { + if (part === this) { + break; + } + left += partWidth(part, row, left); + } + return box(left, partWidth(this, row, left)); + } + return box(0, 0); + } as typeof realRect; +}); + +afterEach(() => { + Element.prototype.getBoundingClientRect = realRect; + // React says nothing either: no update while rendering another component, no flushSync from an effect, + // no update loop (Testing Library's own notice that it still uses react-dom's `act` aside) + const errors = vi + .mocked(console.error) + .mock.calls.filter(([message]) => !String(message).includes('ReactDOMTestUtils.act')); + vi.mocked(console.error).mockClear(); + expect(errors).toEqual([]); +}); const PAGES = [ { label: 'Page 1', icon: 'Home3Line' as const }, @@ -18,50 +97,155 @@ const PAGES = [ { label: 'Page 5', icon: 'Home3Line' as const }, ]; -test('Collapses the whole trail into one dropdown labelled with the current page', () => { +const drawnCrumbs = () => + Array.from(document.querySelectorAll('.reqore-breadcrumbs-item')).map( + (crumb) => crumb.querySelector('.reqore-button-text-content')?.textContent + ); + +const renderTrail = (items = PAGES) => + render( + + + + + + ); + +test('Draws every crumb when the trail has room for them', () => { act(() => { - render( -
- - - - - -
- ); + renderTrail(); + }); + + expect(drawnCrumbs()).toEqual(['Page 1', 'Page 2', 'Page 3', 'Page 4', 'Page 5']); + expect(document.querySelector('.reqore-dropdown-control')).toBeNull(); +}); + +test('Folds crumbs from the start into a "…" menu until the rest fits', () => { + // "…" 40 + arrow 20 + Page 4 100 + arrow 20 + Page 5 100 = 280: Page 3 as well would take 400 + layout.row = 300; + act(() => { + renderTrail(); + }); + + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); + expect(document.querySelectorAll('.reqore-dropdown-control')).toHaveLength(1); + expect(document.querySelector('.reqore-breadcrumbs-overflow-current')).toBeNull(); +}); + +test('Folds the current page too, into one dropdown labelled with it, when even it does not fit', () => { + // "…" 40 + arrow 20 + Page 5 100 = 160 + layout.row = 150; + act(() => { + renderTrail(); }); - expect(document.querySelectorAll('.reqore-breadcrumbs-wrapper').length).toBe(1); // Nothing is left as a bare crumb — the trail folded completely. - expect(document.querySelectorAll('.reqore-breadcrumbs-item').length).toBe(0); + expect(drawnCrumbs()).toEqual([]); // Exactly one dropdown, and it is the current-page-labelled one (not a "…"). - expect(document.querySelectorAll('.reqore-dropdown-control').length).toBe(1); - expect(document.querySelector('.reqore-breadcrumbs-overflow-current')).toBeTruthy(); - // Labelled with the current page — always legible, never just "…". - expect(document.body.textContent).toContain('Page 5'); + expect(document.querySelectorAll('.reqore-dropdown-control')).toHaveLength(1); + expect(document.querySelector('.reqore-breadcrumbs-overflow-current')?.textContent).toContain('Page 5'); }); test('Never drops the current page, however much has to collapse', () => { + layout.row = 150; act(() => { - render( + renderTrail([ + { label: 'Ancestor 1', icon: 'Home3Line' }, + { label: 'Ancestor 2', icon: 'Home3Line' }, + { label: 'The Current Page', icon: 'Home3Line' }, + ]); + }); + + // The current page becomes the label of the single collapsed dropdown — it is + // never the thing that gets hidden. + expect(document.querySelector('.reqore-breadcrumbs-overflow-current')).toBeTruthy(); + expect(document.body.textContent).toContain('The Current Page'); +}); + +// The folded dropdown stands in for the current page, whose label it may cut with an ellipsis to fit: it is +// named in full by its accessible name - the leaf's own when it gives one, else its label, else the +// label its menu row has (an icon-only leaf). It takes no title of its own: hovering it opens its +// menu, which names every crumb in full, and a browser's tooltip would cover that menu. +test("Names the folded current page in full: the leaf's own name, its label or menu label", () => { + layout.row = 150; + act(() => { + renderTrail(); + }); + const byLabel = document.querySelector('.reqore-breadcrumbs-overflow-current')!; + expect(byLabel.getAttribute('aria-label')).toBe('Page 5'); + expect(byLabel.hasAttribute('title')).toBe(false); + + document.body.innerHTML = ''; + act(() => { + renderTrail([ + ...PAGES.slice(0, 4), + { + label: 'Job 4711', + 'aria-label': 'Daily Payment Reconciliation, job 4711', + title: 'Daily Payment Reconciliation', + }, + ] as typeof PAGES); + }); + const byLeaf = document.querySelector('.reqore-breadcrumbs-overflow-current')!; + expect(byLeaf.getAttribute('aria-label')).toBe('Daily Payment Reconciliation, job 4711'); + // a title the leaf gives itself is the leaf's, as on its crumb + expect(byLeaf.getAttribute('title')).toBe('Daily Payment Reconciliation'); + + document.body.innerHTML = ''; + act(() => { + renderTrail([...PAGES.slice(0, 4), { icon: 'Home3Line', menuLabel: 'Home' }] as typeof PAGES); + }); + const byMenuLabel = document.querySelector('.reqore-breadcrumbs-overflow-current')!; + expect(byMenuLabel.getAttribute('aria-label')).toBe('Home'); + expect(byMenuLabel.hasAttribute('title')).toBe(false); +}); + +test('A new trail is folded as it is rendered, from the crumbs folded before, unfolding while they fit', () => { + layout.row = 300; + const { rerender } = renderTrail(); + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); + + const shorter = [ + { label: 'Interfaces', icon: 'Home3Line' as const }, + { label: 'New chat', icon: 'Home3Line' as const }, + ]; + act(() => { + rerender( - + ); }); - // The current page becomes the label of the single collapsed dropdown — it is - // never the thing that gets hidden. - expect(document.querySelector('.reqore-breadcrumbs-overflow-current')).toBeTruthy(); - expect(document.body.textContent).toContain('The Current Page'); + // Interfaces 100 + arrow 20 + New chat 100 = 220: nothing folded + expect(drawnCrumbs()).toEqual(['Interfaces', 'New chat']); + expect(document.querySelector('.reqore-dropdown-control')).toBeNull(); +}); + +test('A trail drawn alike, with new handlers, is not measured again', () => { + layout.row = 300; + const withHandlers = () => PAGES.map((page) => ({ ...page, onClick: () => undefined })); + const { rerender } = renderTrail(withHandlers()); + const row = document.querySelector('.reqore-breadcrumbs-overflow-list')!; + const changes = new MutationObserver(() => undefined); + changes.observe(row, { childList: true, subtree: true }); + + act(() => { + rerender( + + + + + + ); + }); + + // measuring again would draw the folded crumbs to try them + expect(changes.takeRecords().filter((record) => record.addedNodes.length)).toEqual([]); + changes.disconnect(); + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); }); test('Renders the right element in its own region, separate from the trail', () => { @@ -82,7 +266,7 @@ test('Renders the right element in its own region, separate from the trail', () expect(document.body.textContent).toContain('Page 5'); }); -test('Static (non-responsive) trail renders every crumb, with working tooltips', () => { +test('Static (non-responsive) trail renders every crumb, with working tooltips', async () => { vi.useFakeTimers(); render( @@ -103,7 +287,387 @@ test('Static (non-responsive) trail renders every crumb, with working tooltips', expect(document.querySelectorAll('.reqore-popover-content').length).toBe(0); fireEvent.mouseEnter(document.querySelectorAll('.reqore-breadcrumbs-item')[2]); - vi.advanceTimersByTime(1); + // the tooltip opens on a timer, an update React is told about + act(() => { + vi.advanceTimersByTime(1); + }); expect(document.querySelectorAll('.reqore-popover-content').length).toBe(1); + // ...and Popper places it in a promise, which settles inside `act` too + await act(async () => undefined); + vi.useRealTimers(); +}); + +test('Folds another crumb rather than squeeze the crumb holding tabs, until that crumb is the first shown', () => { + // the crumb holding tabs fits by its box, but clips its tabs: 300px of them in 200px + const { scrollWidth, clientWidth } = Object.getOwnPropertyDescriptors(Element.prototype); + const tabsSqueezed = (element: Element) => element.classList.contains('reqore-breadcrumbs-tabs'); + Object.defineProperty(Element.prototype, 'scrollWidth', { + configurable: true, + get(this: Element) { + return tabsSqueezed(this) ? 300 : scrollWidth.get!.call(this); + }, + }); + Object.defineProperty(Element.prototype, 'clientWidth', { + configurable: true, + get(this: Element) { + return tabsSqueezed(this) ? 200 : clientWidth.get!.call(this); + }, + }); + + try { + act(() => { + renderTrail([ + ...PAGES.slice(0, 3), + { + withTabs: { + tabs: [ + { label: 'Overview', id: 'overview' }, + { label: 'Code', id: 'code' }, + ], + activeTab: 'overview', + onTabChange: () => undefined, + }, + }, + ] as typeof PAGES); + }); + + // every crumb before the tabs folded into the "…" menu; the tabs are the first crumb shown, and stay + const tabs = document.querySelector('.reqore-breadcrumbs-tabs'); + expect(tabs?.dataset.position).toBe('0'); + expect(drawnCrumbs()).toEqual([]); + expect(document.querySelectorAll('.reqore-dropdown-control')).toHaveLength(1); + } finally { + Object.defineProperty(Element.prototype, 'scrollWidth', scrollWidth); + Object.defineProperty(Element.prototype, 'clientWidth', clientWidth); + } +}); + +/* + * Widths that change outside React - the trail's own, a part's (a badge arriving, padding animating) - come + * from a ResizeObserver, which folds the trail again before the frame is painted. It lets go of every part + * first and takes them up again in the next frame, so a size it changed is never left unreported. + */ +class RecordingResizeObserver { + static instances: RecordingResizeObserver[] = []; + readonly observed: Array<{ target: Element; box?: string }> = []; + readonly unobserved: Element[] = []; + disconnects = 0; + constructor(readonly callback: ResizeObserverCallback) { + RecordingResizeObserver.instances.push(this); + } + observe(target: Element, options?: ResizeObserverOptions) { + this.observed.push({ target, box: options?.box }); + } + unobserve(target: Element) { + this.unobserved.push(target); + } + disconnect() { + this.disconnects++; + } + /** A report of `target`'s border box: its layout size, which a transform does not scale. */ + report(target: Element, width: number, height = 20) { + this.callback( + [ + { + target, + borderBoxSize: [{ inlineSize: width, blockSize: height }], + contentRect: { width, height }, + } as unknown as ResizeObserverEntry, + ], + this as unknown as ResizeObserver + ); + } +} + +const withRecordingResizeObserver = (run: (frames: FrameRequestCallback[]) => void) => { + const original = globalThis.ResizeObserver; + const { requestAnimationFrame, cancelAnimationFrame } = window; + const frames: FrameRequestCallback[] = []; + RecordingResizeObserver.instances = []; + (globalThis as Record).ResizeObserver = RecordingResizeObserver; + window.requestAnimationFrame = (callback) => frames.push(callback); + window.cancelAnimationFrame = () => undefined; + try { + run(frames); + } finally { + globalThis.ResizeObserver = original; + window.requestAnimationFrame = requestAnimationFrame; + window.cancelAnimationFrame = cancelAnimationFrame; + } +}; + +/** The row and the parts it draws. */ +const rowParts = (row: Element) => [row, ...Array.from(row.children)]; + +const trailObserver = () => { + const observers = RecordingResizeObserver.instances.filter((observer) => + observer.observed.some(({ target }) => target.classList.contains('reqore-breadcrumbs-overflow-list')) + ); + expect(observers).toHaveLength(1); + return observers[0]; +}; + +test('Observes the row and every part it draws, by its border box, and lets go of a part it folds', () => { + withRecordingResizeObserver(() => { + layout.row = 300; + const { unmount } = renderTrail(); + const observer = trailObserver(); + const row = document.querySelector('.reqore-breadcrumbs-overflow-list')!; + const observed = observer.observed.map(({ target }) => target); + + for (const part of rowParts(row)) { + expect(observed).toContain(part); + } + // the box a crumb grows by when its padding grows + expect(observer.observed.every(({ box }) => box === 'border-box')).toBe(true); + // crumbs drawn while the trail was folding, and folded since, are no longer observed + const gone = observed.filter((target) => !target.isConnected); + expect(gone.length).toBeGreaterThan(0); + for (const target of gone) { + expect(observer.unobserved).toContain(target); + } + + unmount(); + expect(observer.disconnects).toBeGreaterThan(0); + }); +}); + +test('A static trail observes nothing', () => { + withRecordingResizeObserver(() => { + render( + + + + ); + + expect(RecordingResizeObserver.instances.every((observer) => observer.observed.length === 0 || + !observer.observed.some(({ target }) => target.closest('.reqore-breadcrumbs-wrapper')))).toBe(true); + }); +}); + +test('A part that grows past the trail has another crumb folded before the frame is painted', () => { + withRecordingResizeObserver((frames) => { + layout.row = 300; + renderTrail(); + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); + const observer = trailObserver(); + const crumb = document.querySelector('.reqore-breadcrumbs-item')!; + const watched = observer.observed.length; + + // the crumbs grow: "…" 40 + arrow 20 + 120 + arrow 20 + 120 = 320, past the row's 300 + layout.crumb = 120; + act(() => observer.report(crumb, 120)); + + // folded there and then, inside the observer's callback + expect(drawnCrumbs()).toEqual(['Page 5']); + // having let go of every part, so no size it changed is left unreported + expect(observer.disconnects).toBe(1); + expect(observer.observed).toHaveLength(watched); + // ...and taking them up again in the next frame + expect(frames).toHaveLength(1); + act(() => frames[0](0)); + const row = document.querySelector('.reqore-breadcrumbs-overflow-list')!; + const observedAgain = observer.observed.slice(watched).map(({ target }) => target); + expect(observedAgain).toEqual(rowParts(row)); + }); +}); + +test('A trail that gets wider unfolds the crumbs that fit again', () => { + withRecordingResizeObserver(() => { + layout.row = 300; + renderTrail(); + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); + const observer = trailObserver(); + + layout.row = 1000; + act(() => observer.report(document.querySelector('.reqore-breadcrumbs-overflow-list')!, 1000)); + + expect(drawnCrumbs()).toEqual(['Page 1', 'Page 2', 'Page 3', 'Page 4', 'Page 5']); + }); +}); + +test('A width that changed by less than half a pixel, or a part that grew and still fits, changes nothing', () => { + withRecordingResizeObserver((frames) => { + layout.row = 300; + renderTrail(); + const observer = trailObserver(); + const row = document.querySelector('.reqore-breadcrumbs-overflow-list')!; + + layout.row = 300.4; + act(() => observer.report(row, 300.4)); + // "…" 40 + arrow 20 + 110 + arrow 20 + 110 = 300: still inside + layout.crumb = 110; + act(() => observer.report(document.querySelector('.reqore-breadcrumbs-item')!, 110)); + + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); + expect(observer.disconnects).toBe(0); + expect(frames).toHaveLength(0); + }); +}); + +const trailOf = (items: typeof PAGES, props: Record = {}) => ( + + + + + +); + +const trailRow = () => document.querySelector('.reqore-breadcrumbs-overflow-list')!; + +// The bar made wider by its own props - a right element that shrinks or goes, a `maxWidth` lifted +// (the IDE's page going full screen) - draws the trail wider in the same render; the observer +// reports it after that render, and the trail unfolds what fits, as it does when it gets wider any +// other way. +test('A trail its own props make wider unfolds the crumbs that fit again', () => { + withRecordingResizeObserver(() => { + const { rerender } = render(trailOf(PAGES, { style: { maxWidth: '300px' } })); + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); + + act(() => { + rerender(trailOf(PAGES, { style: { maxWidth: 'none' } })); + }); + act(() => trailObserver().report(trailRow(), 1000)); + + expect(drawnCrumbs()).toEqual(['Page 1', 'Page 2', 'Page 3', 'Page 4', 'Page 5']); + }); +}); + +// A transform on an ancestor (an app that scales its page, a scale-in animation) scales what the +// trail measures but not the boxes the observer reports, so the two are compared as the trail +// measures them: a report of the size the trail was drawn at changes nothing, rather than folding +// again on every frame. +test('Under a scaled ancestor, a report of the size the trail was drawn at changes nothing', () => { + withRecordingResizeObserver((frames) => { + layout.row = 300; + layout.scale = 0.5; + renderTrail(); + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); + const observer = trailObserver(); + + // the layout's own sizes: twice what the trail measures at half the scale + act(() => observer.report(trailRow(), 300)); + act(() => observer.report(document.querySelector('.reqore-breadcrumbs-item')!, 100)); + + expect(drawnCrumbs()).toEqual(['Page 4', 'Page 5']); + expect(observer.disconnects).toBe(0); + expect(frames).toHaveLength(0); + }); +}); + +// With every crumb folded, the dropdown standing in for the current page keeps 5em of it, so in a +// trail narrower than that it reaches past the end for good: there is nothing left to fold, and the +// observer leaves it so, rather than render the trail again on every frame. +test("A trail narrower than the current page's dropdown is not rendered on every frame", () => { + withRecordingResizeObserver((frames) => { + layout.row = 50; + const renders = vi.fn(); + render({trailOf(PAGES)}); + expect(document.querySelector('.reqore-breadcrumbs-overflow-current')).toBeTruthy(); + const observer = trailObserver(); + renders.mockClear(); + + // ten frames, each reporting the trail as a part taken up again is reported + for (let frame = 0; frame < 10; frame++) { + act(() => observer.report(trailRow(), 50)); + for (const next of frames.splice(0)) { + act(() => next(0)); + } + } + + expect(renders).not.toHaveBeenCalled(); + expect(observer.disconnects).toBe(0); + }); +}); + +const TABS = { + tabs: [ + { label: 'Overview', id: 'overview' }, + { label: 'Code', id: 'code' }, + ], + activeTab: 'overview', + onTabChange: () => undefined, +}; + +/** The room the tabs list has: the crumb holding it, less the arrow before it. */ +const withTabsRoom = (run: () => void) => { + const computed = window.getComputedStyle.bind(window); + const spy = vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudo) => { + const style = computed(element, pseudo); + const crumb = element.closest('.reqore-breadcrumbs-tabs'); + if (crumb && element.classList.contains('reqore-tabs-list')) { + const arrow = crumb.dataset.position === '0' ? 0 : 20; + const room = crumb.getBoundingClientRect().width / layout.scale - arrow; + Object.defineProperty(style, 'width', { value: `${room}px` }); + } + return style; + }); + try { + run(); + } finally { + spy.mockRestore(); + } +}; + +// At the end of the trail, the crumb holding tabs takes the room the other parts leave, and a crumb +// before it is shown only while that room is what all its tabs need - by the tabs list's own +// reckoning, not by the tabs it draws now, which are fewer once some are in "More". A trail made +// wider again unfolds its crumbs only once the tabs have their room back, as a trail first drawn at +// that width shows them. +test('A trail made wider unfolds a crumb only while its tabs keep the room they all need', () => { + const need = getTabsLength(TABS.tabs, 'width', TABS.activeTab, 'normal'); + const items = [...PAGES.slice(0, 3), { withTabs: TABS }] as typeof PAGES; + + withTabsRoom(() => + withRecordingResizeObserver(() => { + // "…" 40 + arrow 20, then the crumb holding the tabs, first shown: 10px less than they need + layout.row = 60 + need - 10; + renderTrail(items); + const tabs = () => document.querySelector('.reqore-breadcrumbs-tabs')!; + expect(tabs().dataset.position).toBe('0'); + expect(drawnCrumbs()).toEqual([]); + const observer = trailObserver(); + + // room for one crumb: "…" 40 + arrow 20 + Page 3 100, the tabs' arrow 20, and 10px spare + layout.row = 180 + need + 10; + act(() => observer.report(trailRow(), layout.row)); + + expect(drawnCrumbs()).toEqual(['Page 3']); + expect(tabs().dataset.position).toBe('1'); + + // narrower with the trail, the crumb holding the tabs leaves no room for anything else + const disconnects = observer.disconnects; + layout.row -= 5; + act(() => observer.report(tabs(), 0)); + expect(observer.disconnects).toBe(disconnects); + expect(drawnCrumbs()).toEqual(['Page 3']); + }) + ); +}); + +// A fold made inside the observer's callback changes the trail's layout there and then. Its width +// does not change (the trail takes the room the bar gives it), but its height can: a crumb with a +// description is taller than the others. A height changed there would change the size of what holds +// the trail - watched, perhaps, by another observer, which the browser would leave unreported and +// report as an error - so the trail keeps the height the observer last reported until the next +// frame. +test('A fold made inside the observer keeps the trail\'s height until the next frame', () => { + withRecordingResizeObserver((frames) => { + layout.row = 300; + renderTrail(); + const observer = trailObserver(); + const row = trailRow(); + + act(() => observer.report(row, 300, 46)); + expect(row.style.height).toBe(''); + + layout.crumb = 120; + act(() => observer.report(document.querySelector('.reqore-breadcrumbs-item')!, 120)); + + expect(drawnCrumbs()).toEqual(['Page 5']); + expect(row.style.height).toBe('46px'); + act(() => frames[0](0)); + expect(row.style.height).toBe(''); + }); }); diff --git a/__tests__/callbackOncePerChange.test.tsx b/__tests__/callbackOncePerChange.test.tsx new file mode 100644 index 00000000..8db15d53 --- /dev/null +++ b/__tests__/callbackOncePerChange.test.tsx @@ -0,0 +1,87 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { fireEvent, render } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { + ReqoreContent, + ReqoreDataView, + ReqoreLayoutContent, + ReqoreTimeline, + ReqoreUIProvider, +} from '../src'; + +/** + * qorus#646 (found while fixing the modal's Escape, which called `onClose` inside a state updater): a data view's + * section and a timeline's collapsible item called the host's `onToggle` / `onCollapseChange` inside the state + * updater that flipped them. An updater is React's to run: in StrictMode React runs it twice, so the host heard + * of one click twice, and an updater run while React renders sets the host's state during that render. The new + * state is worked out first, and the host is told once, from the click. + */ + +const wrap = (children: React.ReactNode) => ( + + + + {children} + + + +); + +test("a data view's section tells the host once per click", () => { + const onSectionToggle = vi.fn(); + render( + wrap( + + ) + ); + const summary = Array.from(document.querySelectorAll('summary')).find((s) => + s.textContent?.includes('Object') + )!; + const wasOpen = summary.parentElement!.hasAttribute('open'); + fireEvent.click(summary); + expect(onSectionToggle).toHaveBeenCalledTimes(1); + expect(onSectionToggle.mock.calls[0][0]).toBe(!wasOpen); + expect(summary.parentElement!.hasAttribute('open')).toBe(!wasOpen); + // React works out a first update at once, but runs the next one's updater as it renders - twice in StrictMode + fireEvent.click(summary); + expect(onSectionToggle).toHaveBeenCalledTimes(2); + expect(onSectionToggle.mock.calls[1][0]).toBe(wasOpen); +}); + +test("a timeline's collapsible item tells the host once per click", () => { + const onCollapseChange = vi.fn(); + render( + wrap( + + ) + ); + fireEvent.click(document.querySelector('.reqore-timeline-collapse')!); + expect(onCollapseChange).toHaveBeenCalledTimes(1); + expect(onCollapseChange).toHaveBeenCalledWith(0, true); + fireEvent.click(document.querySelector('.reqore-timeline-collapse')!); + expect(onCollapseChange).toHaveBeenCalledTimes(2); + expect(onCollapseChange).toHaveBeenLastCalledWith(0, false); +}); + +test("a timeline's item that starts collapsed opens on the first click", () => { + const onCollapseChange = vi.fn(); + render( + wrap( + + ) + ); + fireEvent.click(document.querySelector('.reqore-timeline-collapse')!); + expect(onCollapseChange).toHaveBeenCalledTimes(1); + expect(onCollapseChange).toHaveBeenCalledWith(0, false); +}); diff --git a/__tests__/cloneThroughFragmentsDuplicateKeys.test.tsx b/__tests__/cloneThroughFragmentsDuplicateKeys.test.tsx new file mode 100644 index 00000000..6b6bff34 --- /dev/null +++ b/__tests__/cloneThroughFragmentsDuplicateKeys.test.tsx @@ -0,0 +1,107 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { act, fireEvent, render } from '@testing-library/react'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ReqoreButton, + ReqoreContent, + ReqoreControlGroup, + ReqoreLayoutContent, + ReqoreMenu, + ReqoreMenuItem, + ReqoreUIProvider, +} from '../src'; + +/** + * Children a control group or a menu is given keep their own keys, and a key given twice is made + * unique (`useCloneThroughFragments`). + * + * A control group joined each child's place to its key, which hid two children with one key; once + * a child kept its own key (so it keeps its state when one is added before it), children keyed by + * what they say - a label that repeats - became one child to React: it warned, and on an update + * dropped or repeated one of them. A menu never joined the place, so its rows always collided. + */ + +const wrap = (node: React.ReactNode) => ( + + + {node} + + +); + +const keyWarnings = () => + vi + .mocked(console.error) + .mock.calls.filter((args) => String(args[0]).includes('two children with the same key')); + +const Counter = () => { + const [count, setCount] = useState(0); + return ( + + ); +}; + +const Group = ({ labels, leading }: { labels: string[]; leading?: boolean }) => ( + + {leading ? : null} + {labels.map((label) => ( + + ))} + + +); + +const Menu = ({ labels }: { labels: string[] }) => ( + + {labels.map((label) => ( + + ))} + +); + +const drawn = () => document.querySelectorAll('.labelled').length; + +describe('children keyed by what they say', () => { + beforeEach(() => { + vi.mocked(console.error).mockClear(); + }); + + it('are told apart in a control group, drawn and redrawn once each', () => { + const { rerender } = render(wrap()); + + expect(drawn()).toBe(3); + + rerender(wrap()); + + expect(drawn()).toBe(4); + expect(keyWarnings()).toEqual([]); + }); + + it('are told apart in a menu', () => { + const { rerender } = render(wrap()); + + expect(drawn()).toBe(2); + + rerender(wrap()); + + expect(drawn()).toBe(3); + expect(keyWarnings()).toEqual([]); + }); + + it('leave a child with a key of its own its state when one is added before it', () => { + const { rerender } = render(wrap()); + + act(() => { + fireEvent.click(document.querySelector('.counter') as HTMLElement); + }); + expect(document.querySelector('.counter')?.textContent).toBe('1'); + + rerender(wrap()); + + expect(document.querySelector('.leading')).toBeTruthy(); + expect(document.querySelector('.counter')?.textContent).toBe('1'); + }); +}); diff --git a/__tests__/domPropLeaks.test.tsx b/__tests__/domPropLeaks.test.tsx index 13a6162b..9a9daf30 100644 --- a/__tests__/domPropLeaks.test.tsx +++ b/__tests__/domPropLeaks.test.tsx @@ -3,12 +3,21 @@ import { render } from '@testing-library/react'; import { ReactElement } from 'react'; import { DatePicker, + ReqoreAccordion, + ReqoreBreadcrumbsItem, ReqoreButton, + ReqoreCallout, ReqoreCheckbox, ReqoreCollection, ReqoreContent, ReqoreControlGroup, + ReqoreEntityRow, + ReqoreFeatureCard, + ReqoreSeverityRow, + ReqoreTestimonial, + ReqoreDropdown, ReqoreHeader, + ReqoreIcon, ReqoreInput, ReqoreKeyValueTable, ReqoreLayoutContent, @@ -21,15 +30,23 @@ import { ReqoreP, ReqorePanel, ReqorePopover, + ReqoreRating, ReqoreSlider, ReqoreSpacer, ReqoreTabs, ReqoreTabsContent, + ReqoreTabsListItem, ReqoreTag, + ReqoreTextarea, ReqoreTimeline, ReqoreTree, ReqoreUIProvider, } from '../src'; +import { animated } from '@react-spring/web'; +import { REQORE_BUTTON_STYLE_PROPS } from '../src/components/Button'; +import { REQORE_PANEL_STYLE_PROPS } from '../src/components/Panel'; +import { REQORE_TAG_STYLE_PROPS } from '../src/components/Tag'; +import { REQORE_TEXTAREA_STYLE_PROPS } from '../src/components/Textarea'; import { REQORE_CONTROL_GROUP_CHILD_PROPS } from '../src/helpers/styled'; /** @@ -58,6 +75,14 @@ const NOT_ON_HTML: Record = { spacing: [], // Legacy `` only. color: [], + // A Reqore `size` is a size name; only form controls have a `size` attribute. + size: ['input', 'select'], + disabled: ['button', 'input', 'select', 'textarea', 'option', 'optgroup', 'fieldset'], + checked: ['input'], + selected: ['option'], + placeholder: ['input', 'textarea'], + readOnly: ['input', 'textarea'], + label: ['track', 'option', 'optgroup'], wrap: ['textarea'], width: ['img', 'canvas', 'video', 'iframe', 'input', 'embed', 'object', 'col', 'colgroup'], height: ['img', 'canvas', 'video', 'iframe', 'input', 'embed', 'object'], @@ -89,6 +114,16 @@ const NOT_ON_HTML: Record = { ], }; +/** + * Attributes of some element that a Reqore component uses for its own meaning (`size='small'`). + * Checked on the element a component target renders — the stubs below mark theirs — because + * that element received the props a component was handed, and none of these were the caller's. + */ +const NOT_ON_A_COMPONENT_TARGET = ['size']; + +/** Set by the stub components below on the element they render. */ +const COMPONENT_TARGET_ATTRIBUTE = 'data-component-target'; + const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; const getReactProps = (element: Element): Record | undefined => { @@ -115,6 +150,7 @@ const findLeakedProps = (): string[] => { } const tag = element.tagName.toLowerCase(); + const isComponentTarget = element.hasAttribute(COMPONENT_TARGET_ATTRIBUTE); Object.entries(props).forEach(([prop, value]) => { // React renders nothing for an absent value, and says nothing about it either. @@ -128,7 +164,11 @@ const findLeakedProps = (): string[] => { const allowedOn = NOT_ON_HTML[prop]; - if (!isPropValid(prop) || (allowedOn && !allowedOn.includes(tag))) { + if ( + !isPropValid(prop) || + (allowedOn && !allowedOn.includes(tag)) || + (isComponentTarget && NOT_ON_A_COMPONENT_TARGET.includes(prop)) + ) { leaks.push(`${describeElement(element)} ${prop}`); } }); @@ -170,11 +210,49 @@ const renderInProvider = (element: ReactElement) => /** A router's link: it writes every prop it does not know onto its ``. */ const RouterLinkStub = ({ to, children, ...rest }: any) => ( - + {children} ); +// A router link's `to`. Typed loosely: the components accept a component's own props through +// `as`, which their prop types cannot name. +const ROUTER_LINK_TO = { to: '/issues' } as Record; + +const AS_ROUTER_LINK = { as: RouterLinkStub, to: '/issues' } as any; + +/** The standard surface props the card and row components share, every one of them set. */ +const SURFACE_PROPS = { + intent: 'info', + flat: false, + fluid: true, + fixed: true, + rounded: true, + raised: true, + transparent: true, + disabled: true, + padded: true, + paddingSize: 'big', + radiusSize: 'big', + customTheme: { main: '#ff0000' }, + inheritCustomTheme: false, + tooltip: 'Tooltip', + effect: { gradient: { colors: 'info' } }, + onClick: vi.fn(), +} as any; + +/** An editor surface (Slate's `Editable`): it too writes the props it does not know onto its div. */ +const EditorStub = ({ value, onChange: _onChange, ...rest }: any) => ( +
+ {value} +
+); + // `fill` is not a checkbox prop, but a containing control group hands it to every child, and // consumers pass it the same way. const FILL = { fill: true } as any; @@ -358,6 +436,283 @@ const CASES: [string, () => ReactElement][] = [ ), ], ['a KeyValueTable', () => ], + [ + 'a Button rendered as a router link in a vertical stack group', + () => ( + + + Go to issues + + + X + + + ), + ], + [ + 'a Breadcrumbs item rendered as a router link', + () => ( + + ), + ], + [ + 'a Menu item rendered as a router link', + () => ( + + + + ), + ], + [ + 'a Tag rendered as a router link in a control group', + () => ( + + + + ), + ], + [ + 'a Paragraph rendered as a router link in a control group', + () => ( + + + Paragraph + + + ), + ], + [ + 'a transparent Panel rendered as a router link', + () => ( + + Content + + ), + ], + [ + 'a resizable, transparent Panel', + () => ( + + Content + + ), + ], + [ + 'a resizable Panel in a stack group', + () => ( + + + One + + + Two + + + ), + ], + [ + 'a Tabs list item rendered as a router link', + () => ( + + ), + ], + [ + 'a Textarea rendered as an editor component', + () => ( + + ), + ], + [ + 'an animated Icon in a control group', + () => ( + + + + + ), + ], + [ + 'a checked, read-only, disabled Checkbox and Switch', + () => ( + <> + + + + ), + ], + ['a read-only Rating', () => ], + [ + 'an Accordion with a disabled item', + () => ( + + ), + ], + [ + 'a Dropdown with a placeholder', + () => , + ], + [ + 'a Collection with a selected item', + () => ( + + ), + ], + [ + 'a Textarea in a filling stack group, and one rendered as an editor component', + () => ( + + + + + ), + ], + [ + 'the description components in a filling stack group', + () => ( + + + + + + + + ), + ], + [ + 'the description components rendered as a router link in a filling group', + () => ( + + + + + + + + ), + ], ['a Spacer', () => ], ]; @@ -389,6 +744,43 @@ describe('no styling prop reaches the DOM', () => { }); }); +describe('a component rendered as another DOM tag', () => { + // A component's omit list is built against its default element; a state attribute it names + // there is the new element's own when that element has it. + test('a Panel as a fieldset is a disabled fieldset', () => { + renderInProvider( + + + + ); + + expect(document.querySelector('fieldset.reqore-panel')).toBeDisabled(); + expect(document.querySelector('.reqore-probe-input')).toBeDisabled(); + expect(findLeakedProps()).toEqual([]); + }); + + test('a Tag as a button is a disabled button', () => { + renderInProvider( + + ); + + expect(document.querySelector('button.reqore-tag')).toBeDisabled(); + expect(findLeakedProps()).toEqual([]); + }); + + test('a disabled Panel and Tag keep `disabled` off the div and span they render by default', () => { + renderInProvider( + <> + + + + ); + + expect(document.querySelector('.reqore-panel')).not.toHaveAttribute('disabled'); + expect(document.querySelector('.reqore-tag')).not.toHaveAttribute('disabled'); + }); +}); + describe('the leak detector', () => { // Without these, a detector that found nothing would pass every case above. test('finds a styling flag a DOM element was given', () => { @@ -403,6 +795,17 @@ describe('the leak detector', () => { ]); }); + test("finds a Reqore `size` a component target's element was given", () => { + renderInProvider( + <> + + + + ); + + expect(findLeakedProps()).toEqual([' size']); + }); + test('leaves real attributes, data and aria attributes, and SVG presentation alone', () => { renderInProvider(
@@ -454,3 +857,34 @@ test('REQORE_CONTROL_GROUP_CHILD_PROPS names every prop a control group hands it expect([...handedOut].sort()).toEqual([...REQORE_CONTROL_GROUP_CHILD_PROPS].sort()); }); + +describe('the style prop lists', () => { + // The compiler checks each list against its component's prop interface; these check that no + // list holds a prop the rendered element or component is meant to receive. + test.each([ + [ + 'ReqoreButton', + REQORE_BUTTON_STYLE_PROPS, + ['disabled', 'href', 'type', 'onClick', 'children'], + ], + ['ReqoreTag', REQORE_TAG_STYLE_PROPS, ['onClick', 'children', 'className', 'style']], + [ + 'ReqoreTextarea', + REQORE_TEXTAREA_STYLE_PROPS, + ['value', 'readOnly', 'disabled', 'rows', 'wrap', 'placeholder', 'onChange'], + ], + // re-resizable's own props: a `Resizable` panel needs them. + ['ReqorePanel', REQORE_PANEL_STYLE_PROPS, ['size', 'enable', 'minWidth', 'style', 'children']], + ])('%s forwards what its element needs', (_name, list: string[], forwarded) => { + forwarded.forEach((prop) => expect(list).not.toContain(prop)); + }); + + test.each([ + ['ReqoreButton', REQORE_BUTTON_STYLE_PROPS, ['fluid', 'compact', 'maxWidth', 'alignSelf']], + ['ReqorePanel', REQORE_PANEL_STYLE_PROPS, ['transparent', 'flat', 'opacity', 'disabled']], + ['ReqoreTag', REQORE_TAG_STYLE_PROPS, ['asBadge', 'labelKey', 'width', 'size']], + ['ReqoreTextarea', REQORE_TEXTAREA_STYLE_PROPS, ['flat', 'minimal', 'rounded', 'width']], + ])('%s keeps back its styling props', (_name, list: string[], kept) => { + kept.forEach((prop) => expect(list).toContain(prop)); + }); +}); diff --git a/__tests__/drawerSheetOffsets.test.tsx b/__tests__/drawerSheetOffsets.test.tsx new file mode 100644 index 00000000..e8215c8d --- /dev/null +++ b/__tests__/drawerSheetOffsets.test.tsx @@ -0,0 +1,75 @@ +/** + * A drawer that becomes a sheet after it has entered takes the sheet's offsets. + * + * A `floating` drawer sits 10px off its edge. That offset used to be animated by the enter spring, + * so it was fixed at the values of the render the drawer entered in: a floating side panel that + * became a phone sheet - the window narrowed, or the media query answered a render late - kept + * `right: 10px`, and the sheet ended 10px short of the screen's edge. The offset is the box's own + * style now, worked out on every render from the edge the drawer is on. + * + * jsdom has no `matchMedia`, so the breakpoints are mocked: every query answers `narrow`. + */ +import { render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +let narrow = false; + +vi.mock('../src/hooks/useReqoreMedia', () => ({ + CAN_QUERY_MEDIA: true, + useReqoreMedia: () => narrow, +})); + +const { ReqoreContent, ReqoreDrawer, ReqoreLayoutContent, ReqoreUIProvider } = await import( + '../src' +); + +const show = () => ( + + + + + Content + + + + +); + +const box = () => document.querySelector('.reqore-drawer-resizable') as HTMLElement; + +describe('a floating drawer with `responsiveLayout`', () => { + afterEach(() => { + narrow = false; + }); + + it('is a floating side panel on a wide screen', () => { + render(show()); + + expect(box().classList.contains('reqore-drawer-sheet')).toBe(false); + expect(box().style.right).toBe('10px'); + expect(box().style.top).toBe('10px'); + }); + + it('becomes a sheet flush with the screen when the screen narrows after it entered', () => { + const { rerender } = render(show()); + + expect(box().style.right).toBe('10px'); + + narrow = true; + rerender(show()); + + expect(box().classList.contains('reqore-drawer-sheet')).toBe(true); + expect(box().style.left).toBe('0px'); + expect(box().style.right).toBe('0px'); + expect(box().style.bottom).toBe('0px'); + }); + + it('is a sheet flush with the screen when it enters on a narrow one', () => { + narrow = true; + render(show()); + + expect(box().classList.contains('reqore-drawer-sheet')).toBe(true); + expect(box().style.left).toBe('0px'); + expect(box().style.right).toBe('0px'); + }); +}); diff --git a/__tests__/dropdownCustomElementsKeepState.test.tsx b/__tests__/dropdownCustomElementsKeepState.test.tsx new file mode 100644 index 00000000..9f4e01d2 --- /dev/null +++ b/__tests__/dropdownCustomElementsKeepState.test.tsx @@ -0,0 +1,90 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { act, fireEvent, render } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { ReqoreContent, ReqoreDropdown, ReqoreLayoutContent, ReqoreUIProvider } from '../src'; + +/** + * The custom elements a dropdown's list draws keep the keys their caller gave them. + * + * The list keyed each element by its position, over the caller's own key: an element added before + * another moved it to a new position and a new key, so React remounted it and it lost its state + * (and its focus). A single node, which is not an array, threw. Elements given without keys are + * keyed by the list, so they draw no warning. + */ + +const Counter = () => { + const [count, setCount] = useState(0); + return ( + + ); +}; + +const wrap = (node: React.ReactNode) => ( + + + {node} + + +); + +const List = ({ leading }: { leading?: boolean }) => ( + Leading] : []), + , + ]} + /> +); + +describe("a dropdown's custom elements", () => { + /* The list's control group keeps a child's own key too (it used to join its position to it), so + the element keeps its key, and its state, when another is added before it. */ + it('keep their state when an element is added before them', () => { + const { rerender } = render(wrap()); + act(() => { + fireEvent.click(document.querySelector('.counter') as HTMLElement); + }); + expect(document.querySelector('.counter')?.textContent).toBe('1'); + + rerender(wrap()); + expect(document.querySelector('.leading')).not.toBeNull(); + expect(document.querySelector('.counter')?.textContent).toBe('1'); + }); + + it('draw no key warning when given without keys', () => { + vi.mocked(console.error).mockClear(); + render( + wrap( + One, Two]} + /> + ) + ); + expect(document.querySelector('.two')).not.toBeNull(); + const warnings = vi + .mocked(console.error) + .mock.calls.filter((args) => String(args[0]).includes('unique "key"')); + expect(warnings).toEqual([]); + }); + + it('can be a single node', () => { + render( + wrap( + Single) as never} + /> + ) + ); + expect(document.querySelector('.single')?.textContent).toBe('Single'); + }); +}); diff --git a/__tests__/dropdownItemKeysAreUnique.test.tsx b/__tests__/dropdownItemKeysAreUnique.test.tsx new file mode 100644 index 00000000..c0928968 --- /dev/null +++ b/__tests__/dropdownItemKeysAreUnique.test.tsx @@ -0,0 +1,112 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ReqoreContent, ReqoreDropdown, ReqoreLayoutContent, ReqoreUIProvider } from '../src'; +import { IReqoreDropdownItem } from '../src/components/Dropdown/list'; + +/** + * Every row a dropdown's list draws has a key of its own. + * + * The list keyed an item by its label or value, and a divider by its place. The control group the + * rows are drawn in joined each row's place to that key, so two items that said the same thing, or + * an item whose value was a divider's place, still had keys apart. The control group now keeps a + * row's own key (so a row keeps its state when one is added before it), and those keys met: React + * warned, and may drop or repeat a row whose key another row has. + */ + +const wrap = (node: React.ReactNode) => ( + + + {node} + + +); + +const keyWarnings = () => + vi + .mocked(console.error) + .mock.calls.filter((args) => String(args[0]).includes('two children with the same key')) + .map((args) => String(args[1] ?? args[0])); + +const rows = () => Array.from(document.querySelectorAll('.reqore-menu-item')); + +// A button draws its label more than once (for its hover animation); the first copy is its text. +const rowText = (row: Element) => row.querySelector('.reqore-button-text-content')?.textContent; + +const rowTexts = () => rows().map(rowText); + +const List = ({ items }: { items: IReqoreDropdownItem[] }) => ( + +); + +describe("a dropdown list's rows", () => { + beforeEach(() => { + vi.mocked(console.error).mockClear(); + }); + + it('are told apart when two items say the same thing', () => { + render( + wrap( + + ) + ); + + expect(keyWarnings()).toEqual([]); + expect(rowTexts()).toEqual(['Hello', 'How are ya', 'Hello', 'Same label', 'Same label']); + }); + + it("are told apart when an item's value is a divider's place", () => { + render( + wrap( + + ) + ); + + expect(keyWarnings()).toEqual([]); + expect(rowTexts()).toEqual(['Zero', 'Two', '1']); + }); + + it('keep the row an item had when an item is added before it', () => { + const { rerender } = render(wrap()); + const second = rows().find((row) => rowText(row) === 'Second'); + expect(second).toBeDefined(); + + rerender(wrap()); + + const secondAfter = rows().find((row) => rowText(row) === 'Second'); + expect(rowTexts()).toEqual(['New', 'First', 'Second']); + // The same DOM node: the item kept its key, so React kept its row rather than redrawing it. + expect(secondAfter).toBe(second); + expect(keyWarnings()).toEqual([]); + }); + + it('keep the rows of repeated items when a different item is added before them', () => { + const { rerender } = render(wrap()); + const before = rows(); + expect(before).toHaveLength(2); + + rerender(wrap()); + + const after = rows(); + expect(rowTexts()).toEqual(['Other', 'Same', 'Same']); + expect(after[1]).toBe(before[0]); + expect(after[2]).toBe(before[1]); + expect(keyWarnings()).toEqual([]); + }); +}); diff --git a/__tests__/fadeScroller-drag.test.tsx b/__tests__/fadeScroller-drag.test.tsx index ab2bda9a..92abf10e 100644 --- a/__tests__/fadeScroller-drag.test.tsx +++ b/__tests__/fadeScroller-drag.test.tsx @@ -137,6 +137,26 @@ test('does not drag from a text input, where press-and-move already means select expect(scroller.scrollLeft).toBe(0); }); +test('does not drag from an element marked reqore-no-drag-scroll, a grab handle of its own', () => { + const { container } = render( + + + + Handle + + + + ); + const scroller = container.querySelector('.reqore-fade-scroller-content') as HTMLElement; + setOverflow(scroller); + const handle = container.querySelector('.probe-handle') as HTMLElement; + + pointer(handle, 'pointerDown', { clientX: 400 }); + pointer(handle, 'pointerMove', { clientX: 300 }); + + expect(scroller.scrollLeft).toBe(0); +}); + test('does nothing when the row already fits', () => { // No overflow means no drag at all — a click on a row that fits must behave // exactly as it did before the feature existed. diff --git a/__tests__/helpers-styled.test.tsx b/__tests__/helpers-styled.test.tsx index 8b6f807c..dda57e03 100644 --- a/__tests__/helpers-styled.test.tsx +++ b/__tests__/helpers-styled.test.tsx @@ -1,4 +1,9 @@ -import { omitStyleProps } from '../src/helpers/styled'; +import { render } from '@testing-library/react'; +import styled, { + isReqoreElementAttribute, + omitStyleProps, + REQORE_AMBIGUOUS_PROP_ELEMENTS, +} from '../src/helpers/styled'; const Component = () => null; @@ -18,18 +23,19 @@ test('omitStyleProps blocks the named props and defers everything else to the de test('omitStyleProps blocks nothing when given no props', () => { const shouldForwardProp = omitStyleProps(); - expect(shouldForwardProp('fill', () => true, 'div')).toBe(true); - expect(shouldForwardProp('wrap', () => true, 'div')).toBe(true); + expect(shouldForwardProp('title', () => true, 'div')).toBe(true); + expect(shouldForwardProp('role', () => true, 'div')).toBe(true); + expect(shouldForwardProp('fill', () => true, Component)).toBe(true); }); test('omitStyleProps returns independent predicates per call', () => { const omitsFill = omitStyleProps('fill'); const omitsWrap = omitStyleProps('wrap'); - expect(omitsFill('fill', () => true, 'div')).toBe(false); - expect(omitsFill('wrap', () => true, 'div')).toBe(true); - expect(omitsWrap('wrap', () => true, 'div')).toBe(false); - expect(omitsWrap('fill', () => true, 'div')).toBe(true); + expect(omitsFill('fill', () => true, 'textarea')).toBe(false); + expect(omitsFill('wrap', () => true, 'textarea')).toBe(true); + expect(omitsWrap('wrap', () => true, 'textarea')).toBe(false); + expect(omitsWrap('fill', () => true, Component)).toBe(true); }); test('omitStyleProps forwards a component target its own props, which are not HTML attributes', () => { @@ -59,3 +65,108 @@ test('omitStyleProps applies the DOM validator only to tag targets', () => { expect(shouldForwardProp('className', isValidAttribute, 'div')).toBe(true); expect(shouldForwardProp('className', isValidAttribute, Component)).toBe(true); }); + +describe('a Reqore prop name that is an attribute of some elements only', () => { + test.each([ + ['size', 'div', false], + ['size', 'span', false], + ['size', 'input', true], + ['size', 'select', true], + ['disabled', 'div', false], + ['disabled', 'button', true], + ['checked', 'div', false], + ['checked', 'input', true], + ['selected', 'div', false], + ['selected', 'option', true], + ['readOnly', 'div', false], + ['readOnly', 'textarea', true], + ['placeholder', 'button', false], + ['placeholder', 'input', true], + ['width', 'div', false], + ['width', 'img', true], + ['wrap', 'span', false], + ['wrap', 'textarea', true], + ['color', 'span', false], + ['type', 'div', false], + ['type', 'button', true], + ['value', 'div', false], + ['value', 'li', true], + // SVG presentation attributes are real on SVG elements. + ['fill', 'path', true], + ['width', 'svg', true], + // Names Reqore does not use for its own meaning are not this filter's business. + ['title', 'div', true], + ['href', 'a', true], + ])('`%s` on a `%s` is forwarded: %s', (prop, tag, forwarded) => { + expect(isReqoreElementAttribute(prop, tag)).toBe(forwarded); + expect(omitStyleProps()(prop, () => true, tag)).toBe(forwarded); + }); + + test('every listed element is a real element name', () => { + // The table is typed against React's intrinsic elements; this guards the runtime copy. + Object.values(REQORE_AMBIGUOUS_PROP_ELEMENTS) + .flat() + .forEach((tag) => expect(document.createElement(tag)).not.toBeInstanceOf(HTMLUnknownElement)); + }); +}); + +describe("Reqore's `styled`", () => { + const getReactProps = (element: Element): Record => { + const key = Object.keys(element).find((name) => name.startsWith('__reactProps$')); + + return (element as unknown as Record>)[key]; + }; + + test('keeps a styling prop off an element it is not an attribute of', () => { + const StyledBox = styled.div<{ size?: string; disabled?: boolean }>` + font-size: ${({ size }) => (size === 'small' ? '10px' : '14px')}; + `; + const StyledInput = styled.input``; + const { container } = render( + <> + + + + ); + const box = container.querySelector('.probe-box'); + const input = container.querySelector('.probe-input'); + + expect(box).not.toHaveAttribute('size'); + expect(box).not.toHaveAttribute('disabled'); + expect(box).toHaveAttribute('title', 'Title'); + expect(getReactProps(box)).not.toHaveProperty('size'); + // The styles still read it. + expect(getComputedStyle(box).fontSize).toBe('10px'); + expect(input).toHaveAttribute('size', '10'); + expect(input).toBeDisabled(); + expect(input).toHaveAttribute('readonly'); + }); + + test('uses the element a styled component is rendered `as`', () => { + const StyledControl = styled.div``; + const { container } = render(); + + expect(container.querySelector('.probe')).toBeDisabled(); + }); + + test("forwards everything to a component, and a styled component's own filter wins", () => { + const received: string[] = []; + const Target = (props: Record) => { + received.push(...Object.keys(props)); + + return null; + }; + const StyledTarget = styled(Target)``; + const StyledOwnFilter = styled.div.withConfig({ shouldForwardProp: omitStyleProps('title') })``; + + render(); + const { container } = render( + + ); + + expect(received).toEqual(expect.arrayContaining(['size', 'checked'])); + // Its own `omitStyleProps` keeps the element filter. + expect(container.querySelector('.probe')).not.toHaveAttribute('size'); + expect(container.querySelector('.probe')).not.toHaveAttribute('title'); + }); +}); diff --git a/__tests__/helpers/table.test.tsx b/__tests__/helpers/table.test.tsx index c2b4cbcc..2dfb48a6 100644 --- a/__tests__/helpers/table.test.tsx +++ b/__tests__/helpers/table.test.tsx @@ -2,12 +2,16 @@ import { IReqoreTableColumn } from '../../src/components/Table'; import { calculatePinOffsets, flattenColumns, + foldActionsColumns, + getActionsMenuColumnIds, + isActionsMenuWidth, getColumnsCount, getOnlyShownColumns, hasHiddenColumns, updateColumnData, } from '../../src/components/Table/helpers'; import { testColumns } from '../../src/mock/tableData'; +import { SIZE_TO_PX, TOUCH_TARGET_PX } from '../../src/constants/sizes'; test('Updates top level column width', () => { const columns = [...testColumns]; @@ -114,3 +118,91 @@ test('calculatePinOffsets produces cumulative sticky offsets and marks edges', ( expect(offsets.occupation.offset).toBe(columns[3].header.columns![1].width); expect(offsets.occupation.isEdge).toBe(true); }); + +/* + * A narrow table folds a row's actions into one menu button (qorus#646): which columns fold, at what + * width, and how wide a folded column is. + */ + +const actionsColumn = (extra: Partial = {}): IReqoreTableColumn => ({ + dataId: 'actions', + width: 120, + cell: { actions: () => [{ icon: 'EditLine', label: 'Edit' }, { icon: 'DeleteBinLine', label: 'Delete' }] }, + ...extra, +}); + +test('A column with actions folds at or below Reqore\'s phone width of the table', () => { + const column = actionsColumn(); + + expect(isActionsMenuWidth(column, 480)).toBe(true); + expect(isActionsMenuWidth(column, 265)).toBe(true); + expect(isActionsMenuWidth(column, 481)).toBe(false); + // not measured yet: the wide layout, rather than a guess + expect(isActionsMenuWidth(column, undefined)).toBe(false); + expect(isActionsMenuWidth(column, 0)).toBe(false); + // a column without actions never folds + expect(isActionsMenuWidth({ dataId: 'name' }, 265)).toBe(false); +}); + +test('A column folds at its own actionsMenuBelowWidth, and never with 0', () => { + const at700 = actionsColumn({ cell: { ...actionsColumn().cell, actionsMenuBelowWidth: 700 } }); + const never = actionsColumn({ cell: { ...actionsColumn().cell, actionsMenuBelowWidth: 0 } }); + + expect(isActionsMenuWidth(at700, 650)).toBe(true); + expect(isActionsMenuWidth(at700, 701)).toBe(false); + expect(isActionsMenuWidth(never, 100)).toBe(false); +}); + +test('The folded columns are named once, grouped columns included', () => { + const columns: IReqoreTableColumn[] = [ + { dataId: 'name' }, + { dataId: 'group', header: { columns: [actionsColumn({ dataId: 'nested' })] } }, + actionsColumn(), + ]; + + expect(getActionsMenuColumnIds(columns, 300)).toEqual(['nested', 'actions']); + expect(getActionsMenuColumnIds(columns, 900)).toEqual([]); +}); + +test('A column whose id holds a space folds by its whole id, and no other with it', () => { + // `row actions` folds; `row` and `actions` are columns of their own and have no actions to fold. + const columns: IReqoreTableColumn[] = [ + { dataId: 'row', width: 120 }, + { dataId: 'actions', width: 120 }, + actionsColumn({ dataId: 'row actions' }), + ]; + + const ids = getActionsMenuColumnIds(columns, 300); + expect(ids).toEqual(['row actions']); + + const [row, actions, rowActions] = foldActionsColumns(columns, ids, 'normal', false); + expect(row).toBe(columns[0]); + expect(actions).toBe(columns[1]); + expect(rowActions.width).toBe(SIZE_TO_PX.normal); + expect(rowActions.resizable).toBe(false); +}); + +test('A folded column is the menu button\'s width, a finger\'s on touch, and not resizable', () => { + const columns: IReqoreTableColumn[] = [{ dataId: 'name', width: 200 }, actionsColumn({ resizable: true, grow: 2, minWidth: 100 })]; + + // unchanged where nothing folds + expect(foldActionsColumns(columns, [], 'normal', false)).toBe(columns); + + const [name, folded] = foldActionsColumns(columns, ['actions'], 'normal', false); + expect(name).toBe(columns[0]); + expect(folded.width).toBe(SIZE_TO_PX.normal); + expect(folded.resizable).toBe(false); + expect(folded.grow).toBeUndefined(); + expect(folded.minWidth).toBeUndefined(); + + const [, onTouch] = foldActionsColumns(columns, ['actions'], 'normal', true); + expect(onTouch.width).toBe(Math.max(SIZE_TO_PX.normal, TOUCH_TARGET_PX)); + // never wider than the column was + const [narrowColumn] = foldActionsColumns( + [actionsColumn({ width: 20 })], + ['actions'], + 'big', + true + ); + expect(narrowColumn.width).toBe(20); +}); diff --git a/__tests__/hooks/useCloneThroughFragments.test.tsx b/__tests__/hooks/useCloneThroughFragments.test.tsx new file mode 100644 index 00000000..33f16363 --- /dev/null +++ b/__tests__/hooks/useCloneThroughFragments.test.tsx @@ -0,0 +1,43 @@ +import { act, render } from '@testing-library/react'; +import { Fragment, ReactNode, useState } from 'react'; +import { useCloneThroughFragments } from '../../src/hooks/useCloneThroughFragments'; + +/** Clones its children with the index each is given, and re-renders itself (not its parent) on `bump`. */ +let bump: () => void = () => undefined; +const Indexed = ({ children }: { children: ReactNode }) => { + const [renders, setRenders] = useState(0); + bump = () => setRenders((count) => count + 1); + const { clone } = useCloneThroughFragments( + (_props, _reactIndex, realIndex) => ({ 'data-index': realIndex }), + [] + ); + + return
{clone(children)}
; +}; + +/* A control group's own re-render (its state, a context it reads) reused the clone it had made, and the count of + children went on from where the last render left it: the first child of a stacked group was the fourth, and lost + its rounded end. Every render counts from the first child. */ +test('useCloneThroughFragments counts the children from the first on every render', () => { + const { container } = render( + + a + <> + b + {null} + c + + + d + + + ); + const indexes = () => + Array.from(container.querySelectorAll('span')).map((span) => span.getAttribute('data-index')); + + expect(indexes()).toEqual(['0', '1', '2', '3']); + act(() => bump()); + expect(indexes()).toEqual(['0', '1', '2', '3']); + act(() => bump()); + expect(indexes()).toEqual(['0', '1', '2', '3']); +}); diff --git a/__tests__/hooks/useReqorePaging.test.tsx b/__tests__/hooks/useReqorePaging.test.tsx index 2da20b5b..a8a5b59c 100644 --- a/__tests__/hooks/useReqorePaging.test.tsx +++ b/__tests__/hooks/useReqorePaging.test.tsx @@ -343,3 +343,64 @@ test('useReqorePaging still opens a paged (non-infinite) list on page 1 when its rerender({ items: rows(31) }); expect(result.current.currentPage).toEqual(1); }); + +/* Two next() calls from one render at the page before the last (an infinite collection's auto-load observers, + each firing next()) took the page past the last one: the page read as undefined, the collection showed no rows + and still offered "Load more" (qorus#646, the IDE's App Connections Manager). Each step stops at the last page. */ +test('useReqorePaging never goes past the last page, however many steps one render takes', () => { + const wrapper = ({ children }: any) => {children}; + const rows = Array.from({ length: 110 }, (_, i) => ({ id: i + 1 })); + + for (const infinite of [true, false]) { + const { result } = renderHook(() => useReqorePaging({ items: rows, itemsPerPage: 30, infinite }), { + wrapper, + }); + + act(() => { + result.current.next(); + }); + act(() => { + result.current.next(); + }); + expect(result.current.currentPage).toEqual(3); + + const next = result.current.next; + act(() => { + next(); + next(); + }); + expect(result.current.currentPage).toEqual(4); + expect(result.current.isLastPage).toBe(true); + expect(result.current.items).toHaveLength(infinite ? 110 : 20); + expect(result.current.renderControls).toBe(!infinite); + + // and back, as far as the first + const back = result.current.back; + act(() => { + back(); + back(); + back(); + back(); + back(); + }); + expect(result.current.currentPage).toEqual(1); + expect(result.current.items).toHaveLength(30); + } +}); + +test('useReqorePaging takes a page outside the list as the nearest page in it', () => { + const wrapper = ({ children }: any) => {children}; + const rows = Array.from({ length: 110 }, (_, i) => ({ id: i + 1 })); + const { result } = renderHook(() => useReqorePaging({ items: rows, itemsPerPage: 30 }), { + wrapper, + }); + + act(() => { + result.current.setPage(9); + }); + expect(result.current.currentPage).toEqual(4); + act(() => { + result.current.setPage(0); + }); + expect(result.current.currentPage).toEqual(1); +}); diff --git a/__tests__/hoverIsAPointerThatHovers.test.tsx b/__tests__/hoverIsAPointerThatHovers.test.tsx new file mode 100644 index 00000000..391f3aa9 --- /dev/null +++ b/__tests__/hoverIsAPointerThatHovers.test.tsx @@ -0,0 +1,295 @@ +/** + * A hover is a pointer that hovers: a mouse, or a pen held above the screen. Never a finger. + * + * A tap is followed by the mouse events a browser emulates - `mouseenter` among them - with nothing in them to + * tell them from a mouse's, so whatever opened on `mouseenter` opened at a tap: a hover tooltip until the click + * closed it (on a touch laptop, whose mouse is its primary pointer), a panel's floating actions until the next + * tap elsewhere. A browser sends an input's pointer events before the mouse events it makes of it, and the + * pointer event says what the pointer is: that decides, event by event. + */ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createHoverGate, + isHoveringPointer, + ReqorePanel, + ReqorePopover, + ReqoreUIProvider, +} from '../src/index'; + +/** A finger's tap on `el`, as a browser sends it: its pointer events, then the mouse events it emulates. */ +const tapWithAFinger = (el: Element) => { + fireEvent.pointerEnter(el, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerDown(el, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerUp(el, { pointerType: 'touch', buttons: 0 }); + fireEvent.pointerLeave(el, { pointerType: 'touch', buttons: 0 }); + fireEvent.mouseEnter(el); +}; + +/** A mouse coming to rest on `el`. */ +const mouseOnto = (el: Element) => { + fireEvent.pointerEnter(el, { pointerType: 'mouse', buttons: 0 }); + fireEvent.mouseEnter(el); +}; + +/** A pen held above the screen, over `el`. */ +const penAbove = (el: Element) => { + fireEvent.pointerEnter(el, { pointerType: 'pen', buttons: 0 }); + fireEvent.mouseEnter(el); +}; + +const popovers = () => document.querySelectorAll('.reqore-popover-content').length; + +describe('isHoveringPointer', () => { + it('is a mouse, pressed or not', () => { + expect(isHoveringPointer({ pointerType: 'mouse', buttons: 0 })).toBe(true); + expect(isHoveringPointer({ pointerType: 'mouse', buttons: 1 })).toBe(true); + }); + + it('is a pen held above the screen, not one pressed to it', () => { + expect(isHoveringPointer({ pointerType: 'pen', buttons: 0 })).toBe(true); + expect(isHoveringPointer({ pointerType: 'pen', buttons: 1 })).toBe(false); + }); + + it('is never a finger', () => { + expect(isHoveringPointer({ pointerType: 'touch', buttons: 0 })).toBe(false); + expect(isHoveringPointer({ pointerType: 'touch', buttons: 1 })).toBe(false); + }); + + it('is a pointer the event does not name: one it cannot tell, or none at all', () => { + // '' where the type cannot be told - and what user-event sends under jsdom + expect(isHoveringPointer({ pointerType: '', buttons: 0 })).toBe(true); + expect(isHoveringPointer({ pointerType: undefined, buttons: undefined })).toBe(true); + expect(isHoveringPointer({})).toBe(true); + }); +}); + +describe('createHoverGate', () => { + it("reads a mouse event as its pointer's: a mouse hovers, a finger does not", () => { + const gate = createHoverGate(); + gate.notePointer({ pointerType: 'mouse', buttons: 0 }); + expect(gate.isHover()).toBe(true); + + gate.notePointer({ pointerType: 'touch', buttons: 1 }); + expect(gate.isHover()).toBe(false); + }); + + it('reads a mouse event no pointer event came before as a mouse', () => { + expect(createHoverGate().isHover()).toBe(true); + }); + + it("reads what was noted once: a mouse event after a finger's is not taken for the finger's", () => { + const gate = createHoverGate(); + gate.notePointer({ pointerType: 'touch', buttons: 0 }); + expect(gate.isHover()).toBe(false); + // a test's `mouseenter` alone, or a browser without pointer events + expect(gate.isHover()).toBe(true); + }); +}); + +describe('a hover popover', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const renderTrigger = (props: Record = {}) => + render( + + + Trigger + + + ); + + const settle = () => + act(() => { + vi.advanceTimersByTime(1000); + }); + + /** What the popover listens on: the wrapper around what it is the popover of. */ + const trigger = () => + screen.getByText('Trigger').closest('.reqore-popover-wrapper') as HTMLElement; + + it('opens for a mouse', () => { + renderTrigger(); + mouseOnto(trigger()); + settle(); + + expect(popovers()).toBe(1); + }); + + it('opens for a pen held above the screen', () => { + renderTrigger(); + penAbove(trigger()); + settle(); + + expect(popovers()).toBe(1); + }); + + it("opens for nothing at a finger's tap, nor at the mouseenter emulated after it", () => { + renderTrigger(); + tapWithAFinger(trigger()); + settle(); + + expect(popovers()).toBe(0); + }); + + it('opens for a mouse after a tap, as it did before it', () => { + renderTrigger(); + const target = trigger(); + tapWithAFinger(target); + fireEvent.mouseLeave(target); + mouseOnto(target); + settle(); + + expect(popovers()).toBe(1); + }); + + it('opens at a mouseenter no pointer event came before (a test that fires it alone)', () => { + renderTrigger(); + fireEvent.mouseEnter(trigger()); + settle(); + + expect(popovers()).toBe(1); + }); + + it("opens at user-event's hover", async () => { + renderTrigger(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await user.hover(trigger()); + settle(); + + expect(popovers()).toBe(1); + }); + + it("opens for a pointer event that names no pointer type (user-event's under some jsdom setups)", () => { + renderTrigger(); + fireEvent.pointerEnter(trigger(), { pointerType: '', buttons: 0 }); + fireEvent.mouseEnter(trigger()); + settle(); + + expect(popovers()).toBe(1); + }); + + it("that stays (hoverStay) opens for nothing at a finger's tap", () => { + renderTrigger({ handler: 'hoverStay' }); + tapWithAFinger(trigger()); + settle(); + + expect(popovers()).toBe(0); + }); + + it("kept open while hovered (keepOpenOnHover) opens for nothing at a finger's tap, and for a mouse", () => { + renderTrigger({ keepOpenOnHover: true }); + const target = trigger(); + tapWithAFinger(target); + settle(); + expect(popovers()).toBe(0); + + fireEvent.mouseLeave(target); + mouseOnto(target); + settle(); + expect(popovers()).toBe(1); + }); +}); + +describe('a popover opened by a tap or by focus', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens at a tap where it is opened by a click, as touch-first tooltips are', () => { + render( + + + Trigger + + + ); + const trigger = screen.getByText('Trigger').closest('.reqore-popover-wrapper') as HTMLElement; + tapWithAFinger(trigger); + fireEvent.click(trigger); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(popovers()).toBe(1); + }); + + it('opens when the keyboard brings the focus to it', () => { + render( + + + + + + ); + act(() => { + screen.getByLabelText('Field').focus(); + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(popovers()).toBe(1); + }); +}); + +describe("a panel's floating actions", () => { + beforeEach(() => { + vi.useFakeTimers(); + // jsdom lays nothing out: the panel's top-right corner, where the actions float, is the panel's + (document as any).elementFromPoint = () => document.querySelector('.with-floating-actions'); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (document as any).elementFromPoint; + }); + + const renderPanel = () => + render( + + + content + + + ); + + const panel = () => document.querySelector('.with-floating-actions') as HTMLElement; + const floating = () => document.querySelector('.reqore-panel-floating-actions'); + const settle = () => + act(() => { + vi.advanceTimersByTime(300); + }); + + it('float for a mouse', () => { + renderPanel(); + mouseOnto(panel()); + settle(); + + expect(floating()).not.toBeNull(); + }); + + it("float for nothing at a finger's tap", () => { + renderPanel(); + tapWithAFinger(panel()); + settle(); + + expect(floating()).toBeNull(); + }); +}); diff --git a/__tests__/marqueeOnHover.test.tsx b/__tests__/marqueeOnHover.test.tsx index b65411ef..e3ece070 100644 --- a/__tests__/marqueeOnHover.test.tsx +++ b/__tests__/marqueeOnHover.test.tsx @@ -302,6 +302,26 @@ describe('the provider installs it on the document', () => { expect(state(el)).toBeNull(); }); + test("a finger's tap scrolls nothing: the mouseover emulated after it is no hover", () => { + const el = mount(); + + fireEvent.pointerOver(el, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerDown(el, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerUp(el, { pointerType: 'touch', buttons: 0 }); + fireEvent.mouseOver(el); + vi.advanceTimersByTime(2000); + + expect(el.scrollLeft).toBe(0); + expect(state(el)).toBeNull(); + + // a mouse after it scrolls it, as before + fireEvent.mouseOut(el); + fireEvent.pointerOver(el, { pointerType: 'mouse', buttons: 0 }); + fireEvent.mouseOver(el); + vi.advanceTimersByTime(2000); + expect(state(el)).toBe('running'); + }); + test('a pointer that leaves before the delay sets nothing in motion', () => { const el = mount(); diff --git a/__tests__/menu.test.tsx b/__tests__/menu.test.tsx index 7257f792..2554f012 100644 --- a/__tests__/menu.test.tsx +++ b/__tests__/menu.test.tsx @@ -105,9 +105,7 @@ test(' item action with `actions` opens a dropdown of grouped shortcuts' expect(itemCb).not.toHaveBeenCalled(); // The popover lists the two shortcut items (dividers group them). expect(document.querySelectorAll('.reqore-popover-content').length).toBe(1); - const dropdownItems = document.querySelectorAll( - '.reqore-popover-content .reqore-menu-item' - ); + const dropdownItems = document.querySelectorAll('.reqore-popover-content .reqore-menu-item'); expect(dropdownItems.length).toBe(2); // Clicking a shortcut fires its handler. @@ -119,46 +117,66 @@ test(' item action with `actions` opens a dropdown of grouped shortcuts' }); describe(' scrollIntoView', () => { - const renderWithScrollSpy = (options?: Record) => { + /* Inside a menu, the item is scrolled to inside that menu (see menuItemScrollStaysInMenu.test.tsx): the menu + is laid out as a browser would lay it out, scrollable, with the item below its middle. */ + const renderWithMenuScrollSpy = (options?: Record) => { const scrollIntoView = vi.fn(); - - // jsdom does not implement scrollIntoView, so there is nothing to restore afterwards - // beyond the spy itself. window.HTMLElement.prototype.scrollIntoView = scrollIntoView; - - render( + const menuScroll = vi.fn(); + const tree = (marked: boolean) => ( - + ); - - return scrollIntoView; + const { rerender } = render(tree(false)); + const menu = document.querySelector('.reqore-menu')!; + menu.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect; + Object.defineProperty(menu, 'scrollHeight', { configurable: true, value: 300 }); + Object.defineProperty(menu, 'clientHeight', { configurable: true, value: 100 }); + menu.scrollTo = menuScroll as never; + const item = document.querySelector('.second.reqore-menu-item')!; + item.getBoundingClientRect = () => ({ top: 200, height: 20 }) as DOMRect; + rerender(tree(true)); + return { scrollIntoView, menuScroll }; }; - test('centres the item vertically without dragging a vertical list sideways', () => { - const scrollIntoView = renderWithScrollSpy(); + test('centres the item in its menu, and does not scroll anything around the menu', () => { + const { scrollIntoView, menuScroll } = renderWithMenuScrollSpy(); - expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(scrollIntoView).toHaveBeenCalledWith( - expect.objectContaining({ block: 'center', inline: 'nearest' }) - ); + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(menuScroll).toHaveBeenCalledTimes(1); + // the item's middle (210) to the menu's middle (50) + expect(menuScroll).toHaveBeenCalledWith(expect.objectContaining({ top: 160 })); }); test('animates the scroll by default', () => { - const scrollIntoView = renderWithScrollSpy(); + const { menuScroll } = renderWithMenuScrollSpy(); - expect(scrollIntoView).toHaveBeenCalledWith( - expect.objectContaining({ behavior: 'smooth' }) - ); + expect(menuScroll).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'smooth' })); }); test('jumps straight to the item when popover animations are disabled', () => { - const scrollIntoView = renderWithScrollSpy({ animations: { popovers: false } }); + const { menuScroll } = renderWithMenuScrollSpy({ animations: { popovers: false } }); + + expect(menuScroll).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); + }); + + test('an item outside a menu is brought into view by itself, centred without dragging sideways', () => { + const scrollIntoView = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render( + + + + ); - expect(scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); + expect(scrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ block: 'center', inline: 'nearest' }) + ); }); test('does not scroll items that are not marked for it', () => { diff --git a/__tests__/menuHostChildren.test.tsx b/__tests__/menuHostChildren.test.tsx new file mode 100644 index 00000000..f0bc956b --- /dev/null +++ b/__tests__/menuHostChildren.test.tsx @@ -0,0 +1,95 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { fireEvent, render } from '@testing-library/react'; +import { ReqoreMenu, ReqoreMenuItem, ReqoreMenuSection, ReqoreUIProvider } from '../src/index'; + +/** + * qorus#646 (found by the IDE agent): a menu cloned its own props - customTheme, wrap, flat, minimal, size and + * closePopover - onto every child, plain DOM elements included, so React warned about unknown props on a + * div, and the props reached the DOM. A host element takes none of them: the menu and its sections now + * pass them only to components. + */ + +/** What React says when a component's prop lands on a DOM element. React says it once per prop and element + * type in a run, so each test also checks the element's attributes, which do not depend on test order. */ +const DOM_PROP_WARNING = + /React does not recognize|Invalid value for prop|non-boolean attribute|Unknown event handler property/; + +const MENU_PROPS = ['customtheme', 'wrap', 'flat', 'minimal', 'size', 'closepopover']; + +/** Renders `ui` and gives back what React wrote to console.error while it did. */ +const renderCollectingErrors = (ui: React.ReactElement) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const result = render(ui); + return { ...result, errors: consoleError.mock.calls.map((call) => call.map(String).join(' ')) }; + } finally { + consoleError.mockRestore(); + } +}; + +const expectNoMenuPropsOn = (element: Element | null) => { + expect(element).not.toBeNull(); + for (const name of MENU_PROPS) { + expect(element).not.toHaveAttribute(name); + } +}; + +test('a menu with a plain div child passes it none of its props', () => { + const closePopover = vi.fn(); + const { container, errors } = renderCollectingErrors( + + +
Not a menu item
+ close?.()}>Item +
+
+ ); + + expect(errors.filter((error) => DOM_PROP_WARNING.test(error))).toEqual([]); + expectNoMenuPropsOn(container.querySelector('[data-testid="plain-child"]')); + expect(container.querySelector('[data-testid="plain-child"]')).toHaveTextContent( + 'Not a menu item' + ); + + // a component child still gets them: the item closes the menu's popover + fireEvent.click(container.querySelector('.reqore-menu-item') as Element); + expect(closePopover).toHaveBeenCalledTimes(1); +}); + +test('a plain div inside a fragment in a menu gets none of its props either', () => { + const { container, errors } = renderCollectingErrors( + + undefined} + > + <> +
Inside a fragment
+ +
+
+ ); + + expect(errors.filter((error) => DOM_PROP_WARNING.test(error))).toEqual([]); + expectNoMenuPropsOn(container.querySelector('[data-testid="fragment-child"]')); +}); + +test('a menu section with a plain div child passes it none of its props', () => { + const { container, errors } = renderCollectingErrors( + + + +
Not a menu item
+ Item +
+
+
+ ); + + expect(errors.filter((error) => DOM_PROP_WARNING.test(error))).toEqual([]); + expectNoMenuPropsOn(container.querySelector('[data-testid="section-child"]')); + expect(container.querySelectorAll('.reqore-menu-item').length).toBe(1); +}); diff --git a/__tests__/menuItemScrollStaysInMenu.test.tsx b/__tests__/menuItemScrollStaysInMenu.test.tsx new file mode 100644 index 00000000..e8b6b7a6 --- /dev/null +++ b/__tests__/menuItemScrollStaysInMenu.test.tsx @@ -0,0 +1,146 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A menu item brought into view by the keyboard is scrolled to inside its own menu, and nothing else moves +// (qorus#646, David: arrow keys in an editor's completion list scrolled the page past its content - the +// item's `scrollIntoView` scrolled every scrollable ancestor, the page's own scroll container and the +// document included, to centre the item in the viewport). +import { render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ReqoreMenu, ReqoreMenuItem, ReqoreUIProvider } from '../src/index'; + +/** jsdom lays nothing out: an element given the sizes a browser would give it. */ +const laidOut = ( + el: HTMLElement, + box: { top: number; height: number }, + scroll?: { height: number } +) => { + el.getBoundingClientRect = () => + ({ + top: box.top, + height: box.height, + bottom: box.top + box.height, + left: 0, + right: 100, + width: 100, + }) as DOMRect; + if (scroll) { + Object.defineProperty(el, 'scrollHeight', { configurable: true, value: scroll.height }); + Object.defineProperty(el, 'clientHeight', { configurable: true, value: box.height }); + el.style.overflowY = 'auto'; + } +}; + +/** jsdom has no `scrollIntoView`: one to watch, as a browser's would be called. */ +const watchScrollIntoView = () => { + const spy = vi.fn(); + (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView = spy; + return spy; +}; + +afterEach(() => { + delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView; +}); + +describe('a menu item brought into view', () => { + it('scrolls its menu, and not the page around it', () => { + const pageScroll = vi.fn(); + const itemScrollIntoView = watchScrollIntoView(); + const { rerender } = render( + +
+ + One + Two + +
+
+ ); + const page = document.querySelector('.page')!; + const menu = document.querySelector('.reqore-menu')!; + const two = document.querySelector('.two.reqore-menu-item')!; + laidOut(page, { top: 0, height: 300 }, { height: 2000 }); + page.scrollTo = pageScroll as never; + laidOut(menu, { top: 100, height: 100 }, { height: 400 }); + const menuScroll = vi.fn(); + menu.scrollTo = menuScroll as never; + laidOut(two, { top: 250, height: 20 }); + + rerender( + +
+ + One + + Two + + +
+
+ ); + + expect(itemScrollIntoView).not.toHaveBeenCalled(); + expect(pageScroll).not.toHaveBeenCalled(); + // centred in its menu: the item's middle (260) to the menu's middle (150) + expect(menuScroll).toHaveBeenCalledWith(expect.objectContaining({ top: 110 })); + }); + + it('scrolls a menu whose scroller overflows as an overlay', () => { + const pageScroll = vi.fn(); + const itemScrollIntoView = watchScrollIntoView(); + const menuOf = (two?: boolean) => ( + +
+ + One + + Two + + +
+
+ ); + const { rerender } = render(menuOf()); + const page = document.querySelector('.page')!; + const menu = document.querySelector('.reqore-menu')!; + const two = document.querySelector('.two.reqore-menu-item')!; + laidOut(page, { top: 0, height: 300 }, { height: 2000 }); + page.scrollTo = pageScroll as never; + laidOut(menu, { top: 100, height: 100 }, { height: 400 }); + // `overlay`: an older WebKit / Chromium overflow that scrolls as `auto` does + menu.style.overflowY = 'overlay'; + const menuScroll = vi.fn(); + menu.scrollTo = menuScroll as never; + laidOut(two, { top: 250, height: 20 }); + + rerender(menuOf(true)); + + expect(itemScrollIntoView).not.toHaveBeenCalled(); + expect(pageScroll).not.toHaveBeenCalled(); + expect(menuScroll).toHaveBeenCalledWith(expect.objectContaining({ top: 110 })); + }); + + it('scrolls nothing where its menu shows it all', () => { + const itemScrollIntoView = watchScrollIntoView(); + const { rerender } = render( + + + Two + + + ); + const menu = document.querySelector('.reqore-menu')!; + laidOut(menu, { top: 100, height: 100 }, { height: 100 }); + const menuScroll = vi.fn(); + menu.scrollTo = menuScroll as never; + rerender( + + + + Two + + + + ); + expect(itemScrollIntoView).not.toHaveBeenCalled(); + expect(menuScroll).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/modalEscapeClosesFromTheEvent.test.tsx b/__tests__/modalEscapeClosesFromTheEvent.test.tsx new file mode 100644 index 00000000..cafd25b1 --- /dev/null +++ b/__tests__/modalEscapeClosesFromTheEvent.test.tsx @@ -0,0 +1,129 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { ReqoreModal, ReqoreUIProvider } from '../src'; +import { useReqoreProperty } from '../src/hooks/useReqoreContext'; + +/** + * qorus#646 (React's warning, found by the story run's warning gate): Escape closed a modal by calling its + * `onClose` inside the state updater that took it off the provider's Escape stack. React runs an updater while it + * renders the provider, so the host's own state was set during another component's render - React warned "Cannot + * update a component while rendering a different component" (Modal › Can Be Closed With Esc Key With + * Confirmation). And a modal that went while another was above it stayed on the stack: only the top-most was ever + * taken off. `onClose` is called from the key or the confirmation that closes it, and a modal that goes leaves the + * stack wherever it is in it. + */ + +/** What React wrote to console.error while `run` ran. */ +const collectingErrors = async (run: () => Promise | void): Promise => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await run(); + return consoleError.mock.calls.map((call) => call.map(String).join(' ')); + } finally { + consoleError.mockRestore(); + } +}; + +const RENDER_UPDATE = /Cannot update a component .* while rendering a different component/; + +/** A host whose modal closes by setting its own state, as an app's does. */ +const Host = ({ confirmOnClose }: { confirmOnClose?: boolean }) => { + const [open, setOpen] = useState(true); + return ( + <> + {open ? 'open' : 'closed'} + setOpen(false)} + confirmOnClose={confirmOnClose} + label='Host modal' + > + Content + + + ); +}; + +/** The provider's Escape stack, as text. */ +const Stack = () => ( + {(useReqoreProperty('escClosableModals') ?? []).length} +); + +test('Escape closes a modal from the key, not while the provider renders', async () => { + const errors = await collectingErrors(async () => { + render( + + + + ); + await waitFor(() => expect(document.querySelector('.reqore-modal')).not.toBeNull()); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('closed')); + }); + expect(errors.filter((error) => RENDER_UPDATE.test(error))).toEqual([]); +}); + +test('Escape with a confirmation closes the modal from the confirmation, not while the provider renders', async () => { + const errors = await collectingErrors(async () => { + render( + + + + ); + await waitFor(() => expect(document.querySelector('.reqore-modal')).not.toBeNull()); + fireEvent.keyDown(document, { key: 'Escape' }); + await screen.findByText('Are you sure you want to proceed?'); + expect(screen.getByTestId('state').textContent).toBe('open'); + fireEvent.click(screen.getAllByText('Confirm')[0]); + await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('closed')); + }); + expect(errors.filter((error) => RENDER_UPDATE.test(error))).toEqual([]); +}); + +test('Escape closes only the top-most of two modals', async () => { + const closed: string[] = []; + render( + + closed.push('lower')} label='Lower'> + Lower + + closed.push('upper')} label='Upper'> + Upper + + + ); + await waitFor(() => expect(document.querySelectorAll('.reqore-modal')).toHaveLength(2)); + act(() => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + expect(closed).toEqual(['upper']); +}); + +test('a modal that goes while another is above it leaves the Escape stack', async () => { + const Two = () => { + const [lower, setLower] = useState(true); + return ( + <> + + setLower(false)} label='Lower'> + Lower + + undefined} label='Upper'> + Upper + + + + ); + }; + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('stack').textContent).toBe('2')); + fireEvent.click(screen.getByText('close lower')); + await waitFor(() => expect(screen.getByTestId('stack').textContent).toBe('1')); +}); diff --git a/__tests__/notifications.test.tsx b/__tests__/notifications.test.tsx index 985d8f74..3f6cef0c 100644 --- a/__tests__/notifications.test.tsx +++ b/__tests__/notifications.test.tsx @@ -253,6 +253,52 @@ test('The timer line shows with a duration and holds while hovered', async () => expect(finishFn).toHaveBeenCalledWith('test'); }); +test("A finger's tap holds no timer: the mouseenter emulated after it is no hover", async () => { + const finishFn = vi.fn(); + + act(() => { + render( + + + + ); + }); + + fireEvent.click(screen.getByText('Add Notification')); + const notification = document.querySelector('.reqore-notification') as HTMLElement; + fireEvent.pointerEnter(notification, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerDown(notification, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerUp(notification, { pointerType: 'touch', buttons: 0 }); + fireEvent.mouseEnter(notification); + + act(() => vi.advanceTimersByTime(3500)); + + expect(finishFn).toHaveBeenCalledWith('test'); +}); + +test('A leave no hover came before restarts no timer', async () => { + const finishFn = vi.fn(); + + act(() => { + render( + + + + ); + }); + + fireEvent.click(screen.getByText('Add Notification')); + act(() => vi.advanceTimersByTime(1000)); + // a tap elsewhere: the mouseleave a browser emulates for the tap before it + fireEvent.mouseLeave(document.querySelector('.reqore-notification') as HTMLElement); + act(() => vi.advanceTimersByTime(1500)); + + // 2.5s of its 3s: still up, not cut to the beat a hover's leave leaves + expect(finishFn).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1000)); + expect(finishFn).toHaveBeenCalledWith('test'); +}); + test('pauseOnHover false keeps the timer running while hovered', async () => { const finishFn = vi.fn(); diff --git a/__tests__/pagination.test.tsx b/__tests__/pagination.test.tsx index fd96caf7..af88f143 100644 --- a/__tests__/pagination.test.tsx +++ b/__tests__/pagination.test.tsx @@ -435,3 +435,42 @@ test('Renders and changes pages with horizontal scroll', () => { expect(screen.getAllByText('Rob Pooley')).toBeTruthy(); }); + +/* The auto-load observer was made again (here: auto-load turned off and on) without the one before being + disconnected, so both answered the same scroll: two pages for one. */ +test('Renders with auto load more: one page per time the end comes into view', () => { + const { rerender } = render( + + + + + + + + ); + const renderWith = (autoLoadMore: boolean) => + rerender( + + + + + + + + ); + + renderWith(false); + renderWith(true); + // 300 rows, and the badge on each of the two buttons + expect(document.querySelectorAll('.reqore-tag').length).toBe(302); + + mockAllIsIntersecting(true); + + expect(document.querySelectorAll('.reqore-tag').length).toBe(602); +}); diff --git a/__tests__/panelFloatingActionsPlacement.test.tsx b/__tests__/panelFloatingActionsPlacement.test.tsx index e8855b2c..95b3bace 100644 --- a/__tests__/panelFloatingActionsPlacement.test.tsx +++ b/__tests__/panelFloatingActionsPlacement.test.tsx @@ -1,41 +1,107 @@ // Copyright 2026 Qore Technologies, s.r.o. /** - * A panel's floating actions sit above its top-right corner, their right edge on the panel's. Hidden by - * an earlier placement (the panel's top was covered), they measured 0 x 0 when placed again, so their left - * edge went on the panel's right edge and they reached past it - past the screen's edge on a phone. + * A panel's floating actions sit above its top-right corner, their right edge on the panel's. + * + * - Hidden by an earlier placement (the panel's top was covered), they measured 0 x 0 when placed again, + * so their left edge went on the panel's right edge and they reached past it - past the screen's edge on + * a phone. + * - They are fixed to the screen, so nothing moves them but a placement, and they were placed only as + * they were shown, when the actions changed and on a scroll. A window that resized, a panel that grew + * or shrank, and actions whose content widened each left them where they had been: mid-screen once the + * window widened (reqraft's "Expression Can Be Wrapped", captured wider than it was played), or reaching + * past the panel's edge once they widened. Each of these places them again now, once per frame however + * many come in, and none does once they are hidden or the panel is gone. */ import { act, fireEvent, render } from '@testing-library/react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { ReqorePanel, ReqoreUIProvider } from '../src'; -const PANEL = { left: 100, right: 300, top: 200, bottom: 400, width: 200, height: 200 }; -const ACTIONS = { width: 120, height: 32 }; +const INITIAL_PANEL = { left: 100, right: 300, top: 200, bottom: 400, width: 200, height: 200 }; +const INITIAL_ACTIONS = { width: 120, height: 32 }; +let panel = { ...INITIAL_PANEL }; +let actionsSize = { ...INITIAL_ACTIONS }; let topCovered = true; +let elementFromPoint: ReturnType; + +/** A ResizeObserver whose reports the test makes, for the elements it was asked to observe. */ +class TestResizeObserver { + static instances: TestResizeObserver[] = []; + + readonly observed = new Set(); + disconnected = false; + + constructor(readonly callback: ResizeObserverCallback) { + TestResizeObserver.instances.push(this); + } + + observe(element: Element) { + this.observed.add(element); + } + + unobserve(element: Element) { + this.observed.delete(element); + } + + disconnect() { + this.disconnected = true; + this.observed.clear(); + } + + /** Reports a size change of `element` to every observer still observing it. */ + static resize(element: Element) { + TestResizeObserver.instances + .filter((observer) => observer.observed.has(element)) + .forEach((observer) => + observer.callback( + [{ target: element } as ResizeObserverEntry], + observer as unknown as ResizeObserver + ) + ); + } +} beforeEach(() => { vi.useFakeTimers(); + panel = { ...INITIAL_PANEL }; + actionsSize = { ...INITIAL_ACTIONS }; + TestResizeObserver.instances = []; + vi.stubGlobal('ResizeObserver', TestResizeObserver); // the actions measure as laid out: nothing while hidden - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement + ) { if (this.classList.contains('reqore-panel-floating-actions')) { const shown = this.style.display !== 'none'; - return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, toJSON: () => ({}), - width: shown ? ACTIONS.width : 0, height: shown ? ACTIONS.height : 0 } as DOMRect; + return { + x: 0, + y: 0, + left: 0, + top: 0, + right: 0, + bottom: 0, + toJSON: () => ({}), + width: shown ? actionsSize.width : 0, + height: shown ? actionsSize.height : 0, + } as DOMRect; } - return { x: PANEL.left, y: PANEL.top, ...PANEL, toJSON: () => ({}) } as DOMRect; + return { x: panel.left, y: panel.top, ...panel, toJSON: () => ({}) } as DOMRect; }); - // the panel's top-right corner is covered at first, then not - (document as any).elementFromPoint = () => - topCovered ? document.body : document.querySelector('.with-floating-actions'); + // the panel's top-right corner is covered while `topCovered` + elementFromPoint = vi.fn(() => + topCovered ? document.body : document.querySelector('.with-floating-actions') + ); + (document as any).elementFromPoint = elementFromPoint; }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); topCovered = true; }); -test('floating actions placed again after being hidden end at the panel\'s right edge', async () => { +const renderPanel = () => render( ); - fireEvent.mouseEnter(document.querySelector('.with-floating-actions') as HTMLElement); + +const panelElement = () => document.querySelector('.with-floating-actions') as HTMLElement; +const floatingActions = () => + document.querySelector('.reqore-panel-floating-actions') as HTMLElement | null; + +/** Hovers the panel, its top-right corner in view, and returns its actions, placed. */ +const hover = async () => { + topCovered = false; + fireEvent.mouseEnter(panelElement()); + await act(async () => { + vi.advanceTimersByTime(300); + }); + const actions = floatingActions(); + expect(actions).not.toBeNull(); + expect(actions!.style.display).toBe('flex'); + return actions!; +}; + +const nextFrame = () => + act(() => { + vi.advanceTimersToNextFrame(); + }); + +const expectPlacedByThePanel = (actions: HTMLElement) => { + expect(actions.style.left).toBe(`${panel.right - actionsSize.width}px`); + expect(actions.style.top).toBe(`${panel.top - actionsSize.height + 1}px`); +}; + +test("floating actions placed again after being hidden end at the panel's right edge", async () => { + renderPanel(); + fireEvent.mouseEnter(panelElement()); await act(async () => { vi.advanceTimersByTime(300); }); - const actions = document.querySelector('.reqore-panel-floating-actions') as HTMLElement; + const actions = floatingActions()!; // the first placement finds the corner covered, and hides them expect(actions.style.display).toBe('none'); // uncovered, the next placement (a scroll) shows them topCovered = false; - await act(async () => { + act(() => { window.dispatchEvent(new Event('scroll')); }); + nextFrame(); expect(actions.style.display).toBe('flex'); - expect(actions.style.left).toBe(`${PANEL.right - ACTIONS.width}px`); - expect(actions.style.top).toBe(`${PANEL.top - ACTIONS.height + 1}px`); + expectPlacedByThePanel(actions); +}); + +test("a window that resizes after the actions are shown takes them with the panel's corner", async () => { + renderPanel(); + const actions = await hover(); + expectPlacedByThePanel(actions); + + // the window widens, and the panel with it + panel = { ...panel, right: 1100, width: 1000 }; + act(() => { + window.dispatchEvent(new Event('resize')); + }); + nextFrame(); + + expectPlacedByThePanel(actions); + expect(actions.style.left).toBe('980px'); +}); + +test('a panel that resizes, with no scroll and no window resize, takes its actions with it', async () => { + renderPanel(); + const actions = await hover(); + + // its container narrows: the panel's corner moves left and down + panel = { ...panel, right: 250, width: 150, top: 240 }; + act(() => TestResizeObserver.resize(panelElement())); + nextFrame(); + + expectPlacedByThePanel(actions); + expect(actions.style.left).toBe('130px'); + expect(actions.style.top).toBe('209px'); +}); + +test("actions whose content widens are placed again, their right edge still on the panel's", async () => { + renderPanel(); + const actions = await hover(); + expect(actions.style.left).toBe('180px'); + + // a label, an icon or a font loads in them: 40px wider, and nothing about the panel changed + actionsSize = { ...actionsSize, width: 160 }; + act(() => TestResizeObserver.resize(actions)); + nextFrame(); + + // their right edge is the panel's, not 40px past it + expect(actions.style.left).toBe('140px'); + expectPlacedByThePanel(actions); +}); + +test('they are placed once per frame, however many changes come in during it', async () => { + renderPanel(); + const actions = await hover(); + elementFromPoint.mockClear(); + + panel = { ...panel, right: 500, width: 400 }; + act(() => { + window.dispatchEvent(new Event('resize')); + window.dispatchEvent(new Event('scroll')); + TestResizeObserver.resize(panelElement()); + TestResizeObserver.resize(actions); + }); + // none inside the reports themselves: a placement there that changes the actions' size (shown again) + // is a layout change the browser leaves unreported for the frame + expect(elementFromPoint).not.toHaveBeenCalled(); + + nextFrame(); + expect(elementFromPoint).toHaveBeenCalledTimes(1); + expectPlacedByThePanel(actions); + + // and nothing more until something changes again + nextFrame(); + expect(elementFromPoint).toHaveBeenCalledTimes(1); +}); + +test('a panel whose actions are hidden again places them no more, and observes nothing', async () => { + renderPanel(); + await hover(); + const [observer] = TestResizeObserver.instances; + expect(observer.observed.has(panelElement())).toBe(true); + expect(observer.observed.has(floatingActions()!)).toBe(true); + + // a change comes in, and the pointer leaves for what is beside the panel before the frame + act(() => { + window.dispatchEvent(new Event('resize')); + }); + fireEvent.mouseLeave(panelElement(), { + relatedTarget: document.body, + clientX: 5000, + clientY: 5000, + }); + expect(floatingActions()).toBeNull(); + expect(observer.disconnected).toBe(true); + elementFromPoint.mockClear(); + + nextFrame(); + act(() => { + window.dispatchEvent(new Event('resize')); + window.dispatchEvent(new Event('scroll')); + }); + nextFrame(); + + expect(elementFromPoint).not.toHaveBeenCalled(); +}); + +test('a panel that is gone places nothing in a frame asked for before it went', async () => { + const { unmount } = renderPanel(); + await hover(); + const [observer] = TestResizeObserver.instances; + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + unmount(); + elementFromPoint.mockClear(); + + nextFrame(); + window.dispatchEvent(new Event('resize')); + nextFrame(); + + expect(elementFromPoint).not.toHaveBeenCalled(); + expect(observer.disconnected).toBe(true); +}); + +test('without ResizeObserver, a window that resizes still takes the actions with the panel', async () => { + vi.stubGlobal('ResizeObserver', undefined); + renderPanel(); + const actions = await hover(); + + panel = { ...panel, right: 900, width: 800 }; + act(() => { + window.dispatchEvent(new Event('resize')); + }); + nextFrame(); + + expectPlacedByThePanel(actions); + expect(TestResizeObserver.instances).toHaveLength(0); }); diff --git a/__tests__/panelFloatingActionsReachable.test.tsx b/__tests__/panelFloatingActionsReachable.test.tsx new file mode 100644 index 00000000..e6432361 --- /dev/null +++ b/__tests__/panelFloatingActionsReachable.test.tsx @@ -0,0 +1,97 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/** + * A panel's floating actions can be reached with the mouse. + * + * They float above the panel's top-right corner, so the pointer leaves the panel through its top edge - + * usually to the left of the actions, on a diagonal - before it reaches them. The hover ended as it left + * the panel, the actions went, and the click landed on what they had covered (qorus#646: an expression's + * Remove landed on the Visual / Text switch beneath it). + */ +import { act, fireEvent, render } from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { ReqorePanel, ReqoreUIProvider } from '../src'; + +const PANEL = { left: 100, right: 300, top: 200, bottom: 400, width: 200, height: 200 }; +const ACTIONS = { width: 120, height: 32 }; + +beforeEach(() => { + vi.useFakeTimers(); + // as laid out: the panel where it is, the actions where they were placed + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.classList.contains('reqore-panel-floating-actions')) { + const left = parseFloat(this.style.left) || 0; + const top = parseFloat(this.style.top) || 0; + return { x: left, y: top, left, top, right: left + ACTIONS.width, bottom: top + ACTIONS.height, + width: ACTIONS.width, height: ACTIONS.height, toJSON: () => ({}) } as DOMRect; + } + return { x: PANEL.left, y: PANEL.top, ...PANEL, toJSON: () => ({}) } as DOMRect; + }); + (document as any).elementFromPoint = () => document.querySelector('.with-floating-actions'); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +const hovered = async () => { + render( + + + content + + + ); + const panel = document.querySelector('.with-floating-actions') as HTMLElement; + fireEvent.mouseEnter(panel); + await act(async () => { + vi.advanceTimersByTime(300); + }); + return panel; +}; +const actionsShown = () => !!document.querySelector('.reqore-panel-floating-actions .remove-me'); + +test('the actions stay while the pointer crosses from the panel to them', async () => { + const panel = await hovered(); + expect(actionsShown()).toBe(true); + // out through the top edge, left of the actions (they span x 180-300, y 169-201) + await act(async () => { + fireEvent.mouseLeave(panel, { clientX: 150, clientY: 199, relatedTarget: document.body }); + }); + expect(actionsShown()).toBe(true); + // on its way, over what lies above the panel + await act(async () => { + fireEvent.mouseMove(document.body, { clientX: 170, clientY: 190 }); + }); + expect(actionsShown()).toBe(true); + // and onto the actions: still there to be clicked + const remove = document.querySelector('.reqore-panel-floating-actions .remove-me') as HTMLElement; + await act(async () => { + fireEvent.mouseMove(remove, { clientX: 290, clientY: 185 }); + }); + expect(actionsShown()).toBe(true); +}); + +test('the actions go when the pointer leaves the strip for anything else', async () => { + const panel = await hovered(); + await act(async () => { + fireEvent.mouseLeave(panel, { clientX: 150, clientY: 199, relatedTarget: document.body }); + }); + await act(async () => { + fireEvent.mouseMove(document.body, { clientX: 150, clientY: 120 }); + }); + expect(actionsShown()).toBe(false); +}); + +test('leaving the panel away from its actions ends the hover at once, as before', async () => { + const panel = await hovered(); + await act(async () => { + fireEvent.mouseLeave(panel, { clientX: 150, clientY: 420, relatedTarget: document.body }); + }); + expect(actionsShown()).toBe(false); +}); diff --git a/__tests__/panelFloatingActionsTouch.test.tsx b/__tests__/panelFloatingActionsTouch.test.tsx new file mode 100644 index 00000000..dd04c011 --- /dev/null +++ b/__tests__/panelFloatingActionsTouch.test.tsx @@ -0,0 +1,56 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/** + * On a device that cannot hover, a panel's hover actions are in its own bar. + * + * `floatingActions` takes the panel's `show: 'hover'` actions out of its bar and floats them above its + * corner while a pointer hovers it. A phone or a tablet never hovers, so nothing floated them, and with + * the bar no longer holding them a tap could not reach them at all. Where the device cannot hover + * (`isHoverCapable` false), the actions stay in the bar, shown, as a panel's hover actions do there + * without `floatingActions`. + */ +import { fireEvent, render } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; +import { ReqorePanel, ReqoreUIProvider } from '../src'; + +// the provider asks whether the primary pointer hovers: here it does not (a phone) +vi.mock('../src/hooks/useReqoreMedia', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useReqoreMedia: (query: string, defaultState = false) => + query.includes('hover') ? false : defaultState, + }; +}); + +test("a floating panel's hover action is in its bar, and a tap reaches it", () => { + const onClick = vi.fn(); + + render( + + + content + + + ); + + const panel = document.querySelector('.floating-on-touch') as HTMLElement; + const remove = panel.querySelector('.reqore-panel-title .remove-me') as HTMLElement; + + // in the bar, beside the action that is always there - and nothing floats + expect(remove).toBeTruthy(); + expect(panel.querySelector('.reqore-panel-title .edit-me')).toBeTruthy(); + expect(document.querySelector('.reqore-panel-floating-actions')).toBeNull(); + + fireEvent.click(remove); + + expect(onClick).toHaveBeenCalledTimes(1); +}); diff --git a/__tests__/pointerDragImplicitCapture.test.tsx b/__tests__/pointerDragImplicitCapture.test.tsx new file mode 100644 index 00000000..1493caaf --- /dev/null +++ b/__tests__/pointerDragImplicitCapture.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render } from '@testing-library/react'; +import { IPointerDragDelta, usePointerDrag } from '../src/hooks/usePointerDrag'; + +/** + * A finger's pointer is captured by the element it lands on. When a drag engages, the element + * that drags takes the capture, and the element under the finger - a title, a label inside it - + * reports losing it. That is the drag starting, not ending: the gesture ends only on the capture + * it took itself. Ended on the inner element's, a swipe stopped after its first few pixels + * whenever the finger landed on something inside the element (seen with real touch events on a + * table's header, #710). jsdom has neither touch capture nor PointerEvent: the events are the ones + * the browser sends, dispatched by hand. + */ + +const Probe = ({ + onMove, + onEnd, +}: { + onMove: (delta: IPointerDragDelta) => void; + onEnd: (delta: IPointerDragDelta, committed: boolean) => void; +}) => { + const { handlers } = usePointerDrag({ axis: 'x', onMove, onEnd }); + + return ( +
+ Swipe +
+ ); +}; + +const finger = (clientX: number, buttons = 1) => ({ + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + button: 0, + buttons, + clientX, + clientY: 100, +}); + +const lostCapture = (element: Element) => + fireEvent( + element, + Object.assign(new Event('lostpointercapture', { bubbles: true }), { pointerId: 1 }) + ); + +const show = () => { + const onMove = vi.fn(); + const onEnd = vi.fn(); + const { container } = render(); + + return { + handle: container.querySelector('.probe-handle') as HTMLElement, + child: container.querySelector('.probe-child') as HTMLElement, + onMove, + onEnd, + }; +}; + +test('a swipe that starts on something inside the element keeps going when that loses the capture', () => { + const { child, onMove, onEnd } = show(); + + fireEvent.pointerDown(child, finger(300)); + fireEvent.pointerMove(child, finger(280)); + // the element that drags took the capture: the one under the finger loses it + lostCapture(child); + fireEvent.pointerMove(child, finger(200)); + fireEvent.pointerUp(child, finger(200, 0)); + + expect(onMove).toHaveBeenLastCalledWith(expect.objectContaining({ dx: -100 }), expect.anything()); + expect(onEnd).toHaveBeenCalledTimes(1); + expect(onEnd).toHaveBeenCalledWith(expect.objectContaining({ dx: -100 }), true); +}); + +test('a drag ends when the element that drags loses the capture it took', () => { + const { handle, child, onMove, onEnd } = show(); + + fireEvent.pointerDown(child, finger(300)); + fireEvent.pointerMove(child, finger(260)); + lostCapture(handle); + fireEvent.pointerMove(child, finger(200)); + + expect(onEnd).toHaveBeenCalledTimes(1); + expect(onEnd).toHaveBeenCalledWith(expect.objectContaining({ dx: -40 }), false); + // nothing after the gesture ended moved it + expect(onMove).toHaveBeenLastCalledWith(expect.objectContaining({ dx: -40 }), expect.anything()); +}); diff --git a/__tests__/popover-resize.test.tsx b/__tests__/popover-resize.test.tsx index 33b9bbb4..7cc042f4 100644 --- a/__tests__/popover-resize.test.tsx +++ b/__tests__/popover-resize.test.tsx @@ -39,26 +39,92 @@ const Preview = ({ updater }: { updater: number }) => ( ); +// The frames the popover asks for, run by the test. +let frames: Map; +let nextFrame: number; + +const runFrames = () => { + const due = [...frames.values()]; + frames.clear(); + due.forEach((frame) => frame(0)); +}; + beforeEach(() => { popper.forceUpdate.mockClear(); TestResizeObserver.instances = []; vi.stubGlobal('ResizeObserver', TestResizeObserver); + frames = new Map(); + nextFrame = 0; + vi.stubGlobal('requestAnimationFrame', (frame: FrameRequestCallback) => { + frames.set(++nextFrame, frame); + return nextFrame; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); }); afterEach(() => { vi.unstubAllGlobals(); }); -test('repositions an open popover when asynchronous content changes size', async () => { - render(); +const openObserver = async () => { expect(await screen.findByText('Asynchronous preview')).toBeInTheDocument(); - await waitFor(() => expect(TestResizeObserver.instances).toHaveLength(1)); const observer = TestResizeObserver.instances[0]; expect(observer.observe).toHaveBeenCalledWith(document.querySelector('.reqore-popover-content')); + // what opening asked for already + act(() => runFrames()); + popper.forceUpdate.mockClear(); + return observer; +}; + +test('repositions an open popover when asynchronous content changes size, at the next frame', async () => { + render(); + const observer = await openObserver(); act(() => observer.callback([], observer as unknown as ResizeObserver)); - expect(popper.forceUpdate).toHaveBeenCalled(); + act(() => observer.callback([], observer as unknown as ResizeObserver)); + // not inside the observer's callback: Popper's new position can resize the popover, a layout change the + // browser would leave unreported ("ResizeObserver loop completed with undelivered notifications") + expect(popper.forceUpdate).not.toHaveBeenCalled(); + + act(() => runFrames()); + // once, however many reports came in during the frame + expect(popper.forceUpdate).toHaveBeenCalledTimes(1); +}); + +test('repositions an open popover when its target changes size, at the next frame', async () => { + render(); + const observer = await openObserver(); + + // the target is observed with the popover: a target that grows or shrinks with no scroll and no window + // resize (its container resized, a label or a font that loaded) moves the edge the popover is placed by + const target = screen + .getByText('Preview trigger') + .closest('.reqore-popover-wrapper') as HTMLElement; + expect(observer.observe).toHaveBeenCalledWith(target); + + act(() => + observer.callback( + [{ target } as unknown as ResizeObserverEntry], + observer as unknown as ResizeObserver + ) + ); + expect(popper.forceUpdate).not.toHaveBeenCalled(); + + act(() => runFrames()); + expect(popper.forceUpdate).toHaveBeenCalledTimes(1); +}); + +test('a frame asked for by a popover that has since closed does not run', async () => { + const { unmount } = render(); + const observer = await openObserver(); + + act(() => observer.callback([], observer as unknown as ResizeObserver)); + unmount(); + runFrames(); + + expect(popper.forceUpdate).not.toHaveBeenCalled(); + expect(observer.disconnect).toHaveBeenCalled(); }); test('honors the explicit updater prop while the popover is open', async () => { diff --git a/__tests__/popoverControlsAnswerNow.test.tsx b/__tests__/popoverControlsAnswerNow.test.tsx new file mode 100644 index 00000000..87c16f4a --- /dev/null +++ b/__tests__/popoverControlsAnswerNow.test.tsx @@ -0,0 +1,202 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { act, fireEvent, render } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; +import { + ReqoreContent, + ReqoreDropdown, + ReqoreLayoutContent, + ReqoreTextarea, + ReqoreUIProvider, +} from '../src'; +import { IPopoverControls } from '../src/components/Popover'; + +/** + * The controls a popover hands out answer for the popover as it is now, not as it was when they were + * handed out. + * + * `isOpen` was a closure over the render that passed it, and the holder got a fresh copy only after an + * effect and a render of its own. A template list opened by a click was therefore still "closed" to the + * field that owns it when a key typed straight after the click arrived: typing did not put the list away, + * and it opened over the text being written (qorus#646: a field chosen into a value, then " Stk." typed + * after it). + */ + +const isOpen = () => document.querySelectorAll('.reqore-popover-content').length === 1; + +const items = [ + { label: 'qty', value: '$record:{qty}' }, + { label: 'price', value: '$record:{price}' }, +]; + +const wrap = (node: React.ReactNode) => ( + + + {node} + + +); + +describe("a popover's controls", () => { + it('say it is open once it opens, whichever copy is asked', () => { + const handed: IPopoverControls[] = []; + const Field = () => { + const [, setData] = useState(); + return ( + { + handed.push(data); + setData(data); + }} + /> + ); + }; + render(wrap()); + const first = handed[0]; + expect(first.isOpen()).toBe(false); + fireEvent.click(document.querySelector('.reqore-button') as HTMLElement); + expect(isOpen()).toBe(true); + // the copy handed out while it was closed + expect(first.isOpen()).toBe(true); + act(() => first.close()); + expect(isOpen()).toBe(false); + expect(handed[handed.length - 1].isOpen()).toBe(false); + }); + + it("open and close with the popover's callbacks as they are now, whichever copy is asked", () => { + const handed: IPopoverControls[] = []; + const Field = ({ refuse }: { refuse: boolean }) => ( + true} + onBeforeClose={() => !refuse} + passPopoverData={(data) => handed.push(data)} + /> + ); + const { rerender } = render(wrap()); + // handed out while it was closed and the close was allowed; the popover has not opened since + const first = handed[0]; + rerender(wrap()); + act(() => first.open()); + expect(isOpen()).toBe(true); + // the copy handed out first asks the `onBeforeClose` of now, which refuses + act(() => first.close()); + expect(isOpen()).toBe(true); + }); +}); + +describe('a template list', () => { + it('is put away by text typed straight after the click that opened it', () => { + render(wrap( undefined} />)); + const field = document.querySelector('.reqore-textarea') as HTMLElement; + // the click and the key in one go: the key arrives before the field has been told the list opened + act(() => { + fireEvent.click(field); + fireEvent.keyDown(field, { key: 'S' }); + }); + expect(isOpen()).toBe(false); + }); + + it('is put away by text written without a key, as an on-screen keyboard writes it', () => { + render(wrap( undefined} />)); + const field = document.querySelector('.reqore-textarea') as HTMLElement; + act(() => { + fireEvent.click(field); + }); + expect(isOpen()).toBe(true); + // a touch keyboard sends no keydown for the character, only the input it makes (qorus#646: the list + // stayed open over the completion list a typed `@` opened, which then could not be seen) + act(() => { + fireEvent( + field, + new InputEvent('beforeinput', { bubbles: true, data: '@', inputType: 'insertText' }) + ); + }); + expect(isOpen()).toBe(false); + }); + + it('is put away by composition text, as an Android keyboard writes ordinary letters', () => { + render(wrap( undefined} />)); + const field = document.querySelector('.reqore-textarea') as HTMLElement; + act(() => { + fireEvent.click(field); + }); + expect(isOpen()).toBe(true); + // Gboard sends an `Unidentified` keydown and writes the letter as a composition + act(() => { + fireEvent.keyDown(field, { key: 'Unidentified', keyCode: 229 }); + fireEvent( + field, + new InputEvent('beforeinput', { + bubbles: true, + data: 'a', + inputType: 'insertCompositionText', + }) + ); + }); + expect(isOpen()).toBe(false); + }); + + it('stays open for composition text with nothing in it, which writes nothing', () => { + render(wrap( undefined} />)); + const field = document.querySelector('.reqore-textarea') as HTMLElement; + act(() => { + fireEvent.click(field); + }); + act(() => { + fireEvent( + field, + new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText' }) + ); + }); + expect(isOpen()).toBe(true); + }); + + it('stays open for keys that do not change the text', () => { + render(wrap( undefined} />)); + const field = document.querySelector('.reqore-textarea') as HTMLElement; + act(() => { + fireEvent.click(field); + fireEvent.keyDown(field, { key: 'ArrowDown' }); + }); + expect(isOpen()).toBe(true); + }); +}); + +/** + * Escape puts away the list that is open, and goes no further. + * + * The popover heard Escape on the document, after every handler in the page: an editor around the field + * (reqraft's form row, where Escape discards the edit) heard it first, so one Escape meant for the list + * also closed the editor and threw away what had been typed (qorus#646: a fill value of 42 back to empty). + */ +describe('Escape with a list open', () => { + const Editor = ({ onEscape }: { onEscape: () => void }) => ( +
{ + if (event.key === 'Escape') { + onEscape(); + } + }} + > + undefined} /> +
+ ); + + it('closes the list, and the editor around the field does not hear it', () => { + const heard: string[] = []; + render(wrap( heard.push('escape')} />)); + const field = document.querySelector('.reqore-textarea') as HTMLElement; + fireEvent.click(field); + expect(isOpen()).toBe(true); + fireEvent.keyDown(field, { key: 'Escape' }); + expect(isOpen()).toBe(false); + expect(heard).toEqual([]); + // with no list open, Escape is the editor's again + fireEvent.keyDown(field, { key: 'Escape' }); + expect(heard).toEqual(['escape']); + }); +}); diff --git a/__tests__/popoverEscapeGoesOn.test.tsx b/__tests__/popoverEscapeGoesOn.test.tsx new file mode 100644 index 00000000..95c72fe7 --- /dev/null +++ b/__tests__/popoverEscapeGoesOn.test.tsx @@ -0,0 +1,173 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { act, fireEvent, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ReqoreButton, + ReqoreContent, + ReqoreDropdown, + ReqoreInput, + ReqoreLayoutContent, + ReqoreModal, + ReqoreUIProvider, +} from '../src'; + +/** + * Escape closes every open popover, and goes on to the rest of the page unless it was meant for + * one. + * + * A popover hears Escape in the capture phase, so that an Escape meant for a list in a field closes + * the list and never reaches the editor around the field (a form row that discards the edit on + * Escape). It stopped the key for every open popover, wherever the focus was - and every tooltip is + * a popover, so a tooltip showing anywhere on the page swallowed Escape: a modal with one up did + * not close, and a row editor or a table's search never heard it. An Escape belongs to a popover + * only when it comes from its trigger or from its own surface, and only a popover that actually + * closed keeps it. + */ + +const openPopovers = () => document.querySelectorAll('.reqore-popover-content').length; + +const items = [ + { label: 'qty', value: '$record:{qty}' }, + { label: 'price', value: '$record:{price}' }, +]; + +const wrap = (node: React.ReactNode) => ( + + + {node} + + +); + +/** An editor around the field: Escape discards the edit, as a form row does. */ +const Editor = ({ onEscape, children }: { onEscape: () => void; children: React.ReactNode }) => ( +
{ + if (event.key === 'Escape') { + onEscape(); + } + }} + > + {children} +
+); + +const settle = () => { + act(() => { + vi.advanceTimersByTime(1); + }); +}; + +describe('Escape', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('closes a modal that has a tooltip showing in it', () => { + const onClose = vi.fn(); + render( + wrap( + + + Save + +

Text

+
+ ) + ); + settle(); + fireEvent.mouseEnter(document.querySelector('.with-tooltip') as HTMLElement); + settle(); + expect(openPopovers()).toBe(1); + + fireEvent.keyDown(document.querySelector('.modal-text') as HTMLElement, { key: 'Escape' }); + settle(); + // the tooltip goes, and so does the modal: the key was not the tooltip's to keep + expect(openPopovers()).toBe(0); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("reaches an editor's own handler while a tooltip shows elsewhere", () => { + const heard = vi.fn(); + render( + wrap( + <> + + Save + + + + + + ) + ); + fireEvent.mouseEnter(document.querySelector('.with-tooltip') as HTMLElement); + settle(); + expect(openPopovers()).toBe(1); + + fireEvent.keyDown(document.querySelector('.row-input') as HTMLElement, { key: 'Escape' }); + settle(); + expect(openPopovers()).toBe(0); + expect(heard).toHaveBeenCalledTimes(1); + }); + + it('closes an open list in a field, and the editor around the field does not hear it', () => { + const heard = vi.fn(); + render( + wrap( + + + + ) + ); + const field = document.querySelector('.reqore-input') as HTMLInputElement; + act(() => { + field.focus(); + }); + settle(); + expect(openPopovers()).toBe(1); + + fireEvent.keyDown(field, { key: 'Escape' }); + settle(); + expect(openPopovers()).toBe(0); + expect(heard).not.toHaveBeenCalled(); + + // with the list closed, Escape is the editor's again + fireEvent.keyDown(field, { key: 'Escape' }); + expect(heard).toHaveBeenCalledTimes(1); + }); + + it('goes on to the editor when the list refuses to close', () => { + const heard = vi.fn(); + const onBeforeClose = vi.fn(() => false); + render( + wrap( + + + + ) + ); + const field = document.querySelector('.reqore-input') as HTMLInputElement; + act(() => { + field.focus(); + }); + settle(); + expect(openPopovers()).toBe(1); + + fireEvent.keyDown(field, { key: 'Escape' }); + settle(); + // it stays open, so the key was not used: the editor hears it + expect(onBeforeClose).toHaveBeenCalled(); + expect(openPopovers()).toBe(1); + expect(heard).toHaveBeenCalledTimes(1); + }); +}); 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__/popoverOpensAtATap.test.tsx b/__tests__/popoverOpensAtATap.test.tsx new file mode 100644 index 00000000..bda50830 --- /dev/null +++ b/__tests__/popoverOpensAtATap.test.tsx @@ -0,0 +1,235 @@ +/** + * A popover meant to be reached opens at a tap. + * + * A finger cannot hover, so the hover gate turns away the `mouseenter` a browser emulates after a + * tap (`createHoverGate`). For a plain hover tooltip that is the point: it does not pop open under + * the finger, `hoverStay` or not. But a hover popover that is a way in (`opensOnTap`: a tab bar's, + * a segmented control's or a breadcrumb trail's More menu, any dropdown) and one held open while + * hovered (`keepOpenOnHover`) opened on nothing else, so on a phone the hidden tabs, segments and + * crumbs could not be reached. They open on the tap's click now, and close as a click popover's do. + * `keepOpenOnHover` with `handler: 'click'` (or `'focus'`) listens to its own event as well as to + * the hover: before, it heard only the hover, and a tap opened nothing (qorus-ide: `handler = + * isHoverCapable ? 'hover' : 'click'` with `keepOpenOnHover`). + */ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ReqoreLayoutContent, ReqorePopover, ReqoreTabs, ReqoreUIProvider } from '../src/index'; + +/** + * A finger's tap on `el`, as a browser sends it: its pointer events, the mouse events it emulates + * and its click. + */ +const tapWithAFinger = (el: Element) => { + fireEvent.pointerOver(el, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerEnter(el, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerDown(el, { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerUp(el, { pointerType: 'touch', buttons: 0 }); + fireEvent.pointerOut(el, { pointerType: 'touch', buttons: 0 }); + fireEvent.pointerLeave(el, { pointerType: 'touch', buttons: 0 }); + fireEvent.mouseOver(el); + fireEvent.mouseEnter(el); + fireEvent.mouseMove(el); + fireEvent.mouseDown(el); + fireEvent.mouseUp(el); + fireEvent.click(el); +}; + +/** A mouse coming to rest on `el`. */ +const mouseOnto = (el: Element) => { + fireEvent.pointerEnter(el, { pointerType: 'mouse', buttons: 0 }); + fireEvent.mouseEnter(el); +}; + +/** A mouse's click on `el` it rests on. */ +const mouseClick = (el: Element) => { + fireEvent.pointerDown(el, { pointerType: 'mouse', buttons: 1 }); + fireEvent.mouseDown(el); + fireEvent.pointerUp(el, { pointerType: 'mouse', buttons: 0 }); + fireEvent.mouseUp(el); + fireEvent.click(el); +}; + +const popovers = () => document.querySelectorAll('.reqore-popover-content'); + +describe('a popover meant to be reached', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const renderTrigger = (props: Record = {}) => + render( + + + Trigger + + Elsewhere + + ); + + const settle = () => + act(() => { + vi.advanceTimersByTime(1000); + }); + + /** What the popover listens on: the wrapper around what it is the popover of. */ + const trigger = () => + screen.getByText('Trigger').closest('.reqore-popover-wrapper') as HTMLElement; + + it('that stays (hoverStay) opens at a tap, and a tap elsewhere closes it', () => { + renderTrigger({ handler: 'hoverStay', opensOnTap: true }); + tapWithAFinger(trigger()); + settle(); + expect(popovers()).toHaveLength(1); + + tapWithAFinger(screen.getByText('Elsewhere')); + settle(); + expect(popovers()).toHaveLength(0); + }); + + it('that stays (hoverStay) opens at once at a tap, without the delay a hover waits', () => { + renderTrigger({ handler: 'hoverStay', opensOnTap: true, delay: 500 }); + tapWithAFinger(trigger()); + + expect(popovers()).toHaveLength(1); + }); + + it('that stays (hoverStay) opens for a mouse on its hover, and its click closes nothing', () => { + renderTrigger({ handler: 'hoverStay', opensOnTap: true }); + mouseOnto(trigger()); + settle(); + expect(popovers()).toHaveLength(1); + + mouseClick(trigger()); + settle(); + expect(popovers()).toHaveLength(1); + }); + + it('that stays (hoverStay) opens for nothing at a mouse click no hover came before', () => { + renderTrigger({ handler: 'hoverStay', opensOnTap: true }); + mouseClick(trigger()); + settle(); + + expect(popovers()).toHaveLength(0); + }); + + it('kept open while hovered (keepOpenOnHover) opens at a tap', () => { + renderTrigger({ keepOpenOnHover: true }); + tapWithAFinger(trigger()); + settle(); + + expect(popovers()).toHaveLength(1); + }); + + it('kept open while hovered, opened by a click, opens at a tap; a second tap closes it', () => { + renderTrigger({ keepOpenOnHover: true, handler: 'click' }); + tapWithAFinger(trigger()); + settle(); + expect(popovers()).toHaveLength(1); + + // the emulated mouse is still on the trigger: the second tap sends no mouseenter + fireEvent.pointerDown(trigger(), { pointerType: 'touch', buttons: 1 }); + fireEvent.pointerUp(trigger(), { pointerType: 'touch', buttons: 0 }); + fireEvent.click(trigger()); + settle(); + expect(popovers()).toHaveLength(0); + }); + + it('kept open while hovered, opened by a click, opens for a mouse; its click keeps it', () => { + renderTrigger({ keepOpenOnHover: true, handler: 'click' }); + mouseOnto(trigger()); + settle(); + expect(popovers()).toHaveLength(1); + + mouseClick(trigger()); + settle(); + expect(popovers()).toHaveLength(1); + }); + + it('kept open while hovered, opened by focus, opens when the keyboard brings the focus', () => { + render( + + + + + + ); + act(() => { + screen.getByLabelText('Field').focus(); + }); + settle(); + + expect(popovers()).toHaveLength(1); + }); +}); + +describe('a plain hover tooltip', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('that stays (hoverStay) but is no way in opens for nothing at a tap', () => { + render( + + + Trigger + + + ); + tapWithAFinger(screen.getByText('Trigger').closest('.reqore-popover-wrapper') as HTMLElement); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(popovers()).toHaveLength(0); + }); + + it('opens for nothing at a tap, its click included', () => { + render( + + + Trigger + + + ); + tapWithAFinger(screen.getByText('Trigger').closest('.reqore-popover-wrapper') as HTMLElement); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(popovers()).toHaveLength(0); + }); +}); + +describe("a tab bar's More menu", () => { + it('opens at a tap and lists the tabs it hides', () => { + render( + + + + + + ); + + tapWithAFinger(document.querySelector('.reqore-tabs-list-item-menu')!); + + expect(popovers()).toHaveLength(1); + expect(document.querySelectorAll('.reqore-menu-item')).toHaveLength(3); + }); +}); diff --git a/__tests__/reactWarningGate.test.ts b/__tests__/reactWarningGate.test.ts new file mode 100644 index 00000000..a6470fd2 --- /dev/null +++ b/__tests__/reactWarningGate.test.ts @@ -0,0 +1,280 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { describe, expect, it } from 'vitest'; +import { + REACT_WARNING_RULES, + reactWarning, + reactWarningsFailure, + watchReactWarnings, +} from '../src/stories/utils/reactWarningGate'; + +/** React 18's component stack for the crawl history's nested paragraph, as the story run logged it. */ +const STACK = [ + '', + ' at p', + ' at O2 (http://localhost:63315/node_modules/.vite/vitest/f843/deps/chunk-UZI45VCO.js?v=b120c02b:1094:7)', + ' at p', + ' at QorusTable (http://localhost:63315/src/components/QorusTable/index.tsx:147:3)', + ' at http://localhost:63315/src/views/interfaces/aiCollection/CrawlHistoryPanel.tsx:23:6', + ' at InterfaceDeletionGuard (http://localhost:63315/src/views/interfaces/_shared/InterfaceDeletionGuard.tsx:29:3)', + ' at http://localhost:63315/src/views/PageWrapper.tsx:158:5', + ' at AiCollectionDetailPage (http://localhost:63315/src/views/interfaces/aiCollection/AiCollectionDetailPage.tsx:169:3)', +].join('\n'); + +/** The arguments React 18 gives `console.error` for a paragraph inside a paragraph. */ +const NESTED_PARAGRAPH = [ + 'Warning: validateDOMNesting(...): %s cannot appear as a descendant of <%s>.%s', + '

', + 'p', + STACK, +]; + +const CRAWL_WARNING = [ + 'Warning: validateDOMNesting(...):

cannot appear as a descendant of

.', + 'at QorusTable (src/components/QorusTable/index.tsx:147:3)', + 'at src/views/interfaces/aiCollection/CrawlHistoryPanel.tsx:23:6', + 'at InterfaceDeletionGuard (src/views/interfaces/_shared/InterfaceDeletionGuard.tsx:29:3)', + 'at src/views/PageWrapper.tsx:158:5', +].join('\n '); + +/** React 18's `console.warn` for `createFactory`, which it gives as the module calling it loads: no component stack. */ +const CREATE_FACTORY = [ + 'Warning: React.createFactory() is deprecated and will be removed in a future major release. Consider using JSX ' + + 'or use React.createElement() directly instead.', +]; + +/** The stack of a console call made as a module loads. */ +const LOADING_STACK = [ + 'Error', + ' at console.warn (http://localhost:63315/src/stories/reactWarningGate.ts:160:40)', + ' at createFactoryWithValidation (http://localhost:63315/node_modules/.vite/deps/react.js:2393:7)', + ' at shouldUpdate (http://localhost:63315/node_modules/.vite/deps/recompose.js:120:15)', + ' at http://localhost:63315/src/components/Date/index.tsx:9:16', +].join('\n'); + +/** The rule a warning falls under, by name. */ +const ruleOf = (args: readonly unknown[]) => reactWarning(args)?.rule.name; + +describe("React's warnings", () => { + it('are read as React prints them, with the first four frames that name the source', () => { + expect(reactWarning(NESTED_PARAGRAPH)?.text).toBe(CRAWL_WARNING); + }); + + it('are read without a component stack, from the stack of the call', () => { + expect(reactWarning(CREATE_FACTORY, LOADING_STACK)?.text).toBe( + `${CREATE_FACTORY[0]}\n at src/components/Date/index.tsx:9:16` + ); + expect( + reactWarning([ + 'Warning: validateDOMNesting(...): %s cannot appear as a child of <%s>.%s%s', + '', + 'table', + '', + ' Add a , or to your code to match the DOM tree generated by the browser.', + ])?.text + ).toBe( + 'Warning: validateDOMNesting(...): cannot appear as a child of . Add a , or ' + + ' to your code to match the DOM tree generated by the browser.' + ); + }); + + it('say which render React means, when it says', () => { + expect( + reactWarning([ + 'Warning: Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.%s', + '\n\nCheck the render method of `MapperCreator`.', + '', + STACK, + ])?.text + ).toBe( + 'Warning: Each child in a list should have a unique "key" prop. Check the render method of `MapperCreator`. ' + + 'See https://reactjs.org/link/warning-keys for more information.\n ' + + 'at QorusTable (src/components/QorusTable/index.tsx:147:3)\n ' + + 'at src/views/interfaces/aiCollection/CrawlHistoryPanel.tsx:23:6\n ' + + 'at InterfaceDeletionGuard (src/views/interfaces/_shared/InterfaceDeletionGuard.tsx:29:3)\n ' + + 'at src/views/PageWrapper.tsx:158:5' + ); + }); + + it('fall under the rule that names them', () => { + expect(ruleOf(NESTED_PARAGRAPH)).toBe("React's validateDOMNesting"); + expect(ruleOf(CREATE_FACTORY)).toBe("React's deprecation warning"); + expect( + ruleOf([ + 'Warning: %s: Support for defaultProps will be removed from function components in a future major release. Use JavaScript default parameters instead.%s', + 'Sketch', + STACK, + ]) + ).toBe("React's deprecation warning"); + expect( + ruleOf([ + 'Warning: %s uses the legacy contextTypes API which is no longer supported and will be removed in the next major release.', + 'Old', + ]) + ).toBe("React's deprecation warning"); + expect( + ruleOf([ + 'Warning: An update to %s inside a test was not wrapped in act(...).', + 'CommandPalette', + ]) + ).toBe("React's act warning"); + expect( + ruleOf([ + 'Warning: Cannot update a component (`%s`) while rendering a different component (`%s`).', + 'Variables', + 'Select', + ]) + ).toBe("React's update-while-rendering warning"); + expect( + ruleOf([ + 'Warning: React does not recognize the `%s` prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase `%s` instead.', + 'isLoading', + 'isloading', + ]) + ).toBe("React's unknown DOM property warning"); + expect( + ruleOf([ + 'Warning: Unknown event handler property `%s`. It will be ignored.%s', + 'onSave', + STACK, + ]) + ).toBe("React's unknown DOM property warning"); + expect( + ruleOf(['Warning: Received `%s` for a non-boolean attribute `%s`.', 'true', 'active']) + ).toBe("React's unknown DOM property warning"); + expect( + ruleOf([ + 'Warning: Received NaN for the `%s` attribute. If this is expected, cast the value to a string.%s', + 'value', + STACK, + ]) + ).toBe("React's NaN attribute warning"); + expect( + ruleOf([ + 'Warning: Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.%s', + '', + '', + STACK, + ]) + ).toBe("React's key warning"); + expect(ruleOf(['Warning: Encountered two children with the same key, `%s`.', 'a'])).toBe( + "React's key warning" + ); + expect( + ruleOf(['Warning: Failed %s type: %s%s', 'prop', 'The prop `x` is required', STACK]) + ).toBe('a React warning'); + }); + + it('are every call that React prefixes as its warning, and nothing else', () => { + expect(reactWarning(['Request failed', 404])).toBe(undefined); + expect( + reactWarning(['worksheet pattern not valid', new SyntaxError('Unterminated group')]) + ).toBe(undefined); + expect(reactWarning(['Something is deprecated'])).toBe(undefined); + expect(reactWarning([new Error('Warning: validateDOMNesting')])).toBe(undefined); + expect(reactWarning([])).toBe(undefined); + }); + + it('have a rule for every kind, the last one for any other', () => { + expect(REACT_WARNING_RULES.at(-1)?.matches('Warning: anything')).toBe(true); + expect(new Set(REACT_WARNING_RULES.map((rule) => rule.name)).size).toBe( + REACT_WARNING_RULES.length + ); + }); +}); + +describe('a watch on the console', () => { + /** A console that keeps what it prints. */ + const printing = () => { + const printed: [string, unknown[]][] = []; + return { + printed, + console: { + error: (...args: unknown[]) => void printed.push(['error', args]), + warn: (...args: unknown[]) => void printed.push(['warn', args]), + }, + }; + }; + + it('keeps each React warning, from errors and warnings, until asked, and the console still prints everything', () => { + const { printed, console } = printing(); + const watch = watchReactWarnings(console); + console.error(...NESTED_PARAGRAPH); + console.warn(...CREATE_FACTORY); + console.error('Request failed', 404); + expect(watch.take().map((warning) => warning.rule.name)).toEqual([ + "React's validateDOMNesting", + "React's deprecation warning", + ]); + expect(watch.take()).toEqual([]); + expect(printed).toEqual([ + ['error', NESTED_PARAGRAPH], + ['warn', CREATE_FACTORY], + ['error', ['Request failed', 404]], + ]); + }); + + it('passes on a call it cannot read as text, unread', () => { + const { printed, console } = printing(); + const watch = watchReactWarnings(console); + const unreadable = { + toString: () => { + throw new Error('no text'); + }, + }; + console.error( + 'Warning: validateDOMNesting(...): %s cannot appear as a descendant of <%s>.', + unreadable, + 'p' + ); + expect(watch.take()).toEqual([]); + expect(printed).toHaveLength(1); + }); + + it('gives the console back as it was', () => { + const { printed, console } = printing(); + const original = { error: console.error, warn: console.warn }; + const watch = watchReactWarnings(console); + watch.restore(); + expect(console.error).toBe(original.error); + expect(console.warn).toBe(original.warn); + console.error(...NESTED_PARAGRAPH); + expect(watch.take()).toEqual([]); + expect(printed).toEqual([['error', NESTED_PARAGRAPH]]); + }); +}); + +describe('the failure a story is given', () => { + const nesting = reactWarning(NESTED_PARAGRAPH)!; + const factory = reactWarning(CREATE_FACTORY, LOADING_STACK)!; + + it('says what each rule means and what to do, then names its warnings, in the order of the rules', () => { + expect(reactWarningsFailure({ during: [factory, nesting] })?.message).toBe( + [ + "The story renders markup a browser does not build as written (React's validateDOMNesting): put the inner " + + 'element where its parent may hold it, or make it one that can be there.', + ` - ${CRAWL_WARNING}`, + "The story uses a React API that React deprecates and a later React removes (React's deprecation warning): " + + 'use what React names in its place.', + ` - ${factory.text}`, + ].join('\n') + ); + }); + + it('says when a warning came before the story started, or after the last story', () => { + const message = reactWarningsFailure({ + before: [factory], + during: [nesting], + after: [nesting], + })?.message; + expect(message).toContain( + ` - before the story started (as its file loaded, or as the story before it unmounted): ${factory.text}` + ); + expect(message).toContain(` - ${CRAWL_WARNING}`); + expect(message).toContain(` - after the file's last story: ${CRAWL_WARNING}`); + }); + + it('is nothing when there were none', () => { + expect(reactWarningsFailure({})).toBe(undefined); + expect(reactWarningsFailure({ before: [], during: [], after: [] })).toBe(undefined); + }); +}); diff --git a/__tests__/readOnlyIsNotPressable.test.tsx b/__tests__/readOnlyIsNotPressable.test.tsx index 9aa80709..05a9e0cf 100644 --- a/__tests__/readOnlyIsNotPressable.test.tsx +++ b/__tests__/readOnlyIsNotPressable.test.tsx @@ -50,6 +50,21 @@ describe('a read-only tag', () => { expect(tag.getAttribute('tabindex')).toBe('0'); expect(getComputedStyle(tag).cursor).toBe('pointer'); }); + + /* As a button does: a tag inside a control that is itself the tab stop is + reached with that control's own keys. Read-only still wins - no tab stop. */ + it('keeps the tabIndex it is given while pressable, and only then', () => { + const { container } = wrap( + <> + undefined} /> + undefined} /> + + + ); + expect(container.querySelector('.skipped')?.getAttribute('tabindex')).toBe('-1'); + expect(container.querySelector('.refused')?.getAttribute('tabindex')).toBeNull(); + expect(container.querySelector('.plain')?.getAttribute('tabindex')).toBeNull(); + }); }); describe("a template chip in an editor that cannot be typed into", () => { @@ -97,7 +112,9 @@ describe("a template chip in an editor that cannot be typed into", () => { ); const chip = chipOf(container); - expect(chip.getAttribute('tabindex')).toBe('0'); + // pressable, but not a tab stop of its own: the editor is the tab stop, and + // the chip is reached with the caret (richTextEditor.test.tsx) + expect(chip.getAttribute('tabindex')).toBe('-1'); expect(getComputedStyle(chip).cursor).toBe('pointer'); }); }); diff --git a/__tests__/reqoreMediaHydration.test.tsx b/__tests__/reqoreMediaHydration.test.tsx new file mode 100644 index 00000000..700820c5 --- /dev/null +++ b/__tests__/reqoreMediaHydration.test.tsx @@ -0,0 +1,109 @@ +/** + * `useReqoreMedia` has the browser's answer from a page's first render, and is safe to hydrate. + * + * Rendered only in the browser, the first render already answers the query (a `responsiveLayout` + * drawer opened on a phone is a sheet from its first frame). Rendered on a server - the docs site + * renders `ReqoreUIProvider` there - the server cannot ask, and draws `defaultState`; the browser's + * first render, the hydrating one, must draw the same, or React finds a page that is not the one + * the server drew. It asked `matchMedia` in that render too, and a phone's `true` met the server's + * `false`. + * + * `CAN_QUERY_MEDIA` is decided when the module loads, so `matchMedia` is in place before it is + * imported. + */ +import { act, render } from '@testing-library/react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const QUERY = '(max-width: 480px)'; +let matches = true; +const listeners = new Set<() => void>(); + +Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: (query: string) => ({ + get matches() { + return query === QUERY ? matches : false; + }, + media: query, + onchange: null, + addEventListener: (_type: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener), + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + }), +}); + +const { CAN_QUERY_MEDIA, useReqoreMedia } = await import('../src/hooks/useReqoreMedia'); + +/** What each render of a component asking the query answered. */ +let answers: boolean[] = []; + +const Probe = () => { + const answer = useReqoreMedia(QUERY); + answers.push(answer); + + return {String(answer)}; +}; + +describe('useReqoreMedia', () => { + beforeEach(() => { + matches = true; + answers = []; + listeners.clear(); + }); + + afterAll(() => { + delete (window as { matchMedia?: unknown }).matchMedia; + }); + + it('can ask the browser here', () => { + expect(CAN_QUERY_MEDIA).toBe(true); + }); + + it("answers the browser's answer on the first render of a page drawn in the browser", () => { + const { container } = render(); + + expect(answers[0]).toBe(true); + expect(container.textContent).toBe('true'); + }); + + it('draws the default on a server, which cannot ask', () => { + expect(renderToString()).toContain('false'); + }); + + it("hydrates what the server drew, then answers with the browser's answer", async () => { + const container = document.createElement('div'); + container.innerHTML = renderToString(); + document.body.appendChild(container); + const onRecoverableError = vi.fn(); + answers = []; + + let root: ReturnType | undefined; + await act(async () => { + root = hydrateRoot(container, , { onRecoverableError }); + }); + + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(answers[0]).toBe(false); + expect(container.textContent).toBe('true'); + + act(() => root?.unmount()); + container.remove(); + }); + + it('follows the query when its answer changes', () => { + const { container } = render(); + expect(container.textContent).toBe('true'); + + act(() => { + matches = false; + listeners.forEach((listener) => listener()); + }); + + expect(container.textContent).toBe('false'); + }); +}); diff --git a/__tests__/richTextEditor.test.tsx b/__tests__/richTextEditor.test.tsx index edc6be6b..db5a51f3 100644 --- a/__tests__/richTextEditor.test.tsx +++ b/__tests__/richTextEditor.test.tsx @@ -309,3 +309,21 @@ test('adopts nothing while read-only', () => { expect(ref.current?.selection).toBeNull(); }); + +/* A chip in a text editor is reached with the caret, as any character is; the + editor is the tab stop. A chip that was a tab stop of its own was where Tab + from the text went next, and what was typed after that went to the chip, + which writes nothing (qorus#646: the text field looked focused and took no + typing). */ +test('a chip in an editable text is not a tab stop of its own', () => { + renderWithTag(createRef()); + const editable = document.querySelector('[contenteditable="true"]')!; + const chip = editable.querySelector('.reqore-tag')!; + + expect(chip.getAttribute('tabindex')).toBe('-1'); + const tabStops = Array.from( + document.querySelectorAll('[tabindex]:not([tabindex="-1"]), [contenteditable="true"]') + ); + expect(tabStops.filter((el) => editable.contains(el) && el !== editable)).toEqual([]); +}); + 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__/rowActionsMenu.test.tsx b/__tests__/rowActionsMenu.test.tsx new file mode 100644 index 00000000..a6966621 --- /dev/null +++ b/__tests__/rowActionsMenu.test.tsx @@ -0,0 +1,213 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { fireEvent, render } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { ReqoreContent, ReqoreLayoutContent, ReqoreUIProvider } from '../src'; +import ReqoreRowActionsMenu, { + getRowActionLabel, + hasRowActionLabel, +} from '../src/components/RowActionsMenu'; + +/* + * The menu a narrow row folds its actions into (qorus#646): each action named in words, with its icon, + * and a click in it is the menu's, not the row's. + */ + +test('An action is named by its label, or else by its tooltip when that is text', () => { + expect(getRowActionLabel({ label: 'Edit', tooltip: 'Open in the editor' })).toBe('Edit'); + expect(getRowActionLabel({ label: 3 })).toBe('3'); + expect(getRowActionLabel({ tooltip: 'Remove' })).toBe('Remove'); + expect(getRowActionLabel({ tooltip: { content: 'Dismiss' } })).toBe('Dismiss'); + expect(getRowActionLabel({ tooltip: '' })).toBeUndefined(); + expect(getRowActionLabel({ tooltip: { content: Dismiss } })).toBeUndefined(); + expect(getRowActionLabel({})).toBeUndefined(); +}); + +test('A label empty, blank or not text gives way to the tooltip, then the aria-label', () => { + expect(getRowActionLabel({ label: '', tooltip: 'Remove' })).toBe('Remove'); + expect(getRowActionLabel({ label: ' ', tooltip: { content: 'Dismiss' } })).toBe('Dismiss'); + expect(getRowActionLabel({ label: Open, tooltip: 'Open the row' })).toBe('Open the row'); + expect(getRowActionLabel({ label: '', 'aria-label': 'Delete' })).toBe('Delete'); + expect(getRowActionLabel({ label: 0 })).toBe('0'); + expect(getRowActionLabel({ label: '' })).toBeUndefined(); + + expect(hasRowActionLabel('Open')).toBe(true); + expect(hasRowActionLabel(0)).toBe(true); + expect(hasRowActionLabel(Open)).toBe(true); + expect(hasRowActionLabel('')).toBe(false); + expect(hasRowActionLabel(' ')).toBe(false); + expect(hasRowActionLabel(undefined)).toBe(false); + expect(hasRowActionLabel(false)).toBe(false); +}); + +test('Opens a menu naming each action with its icon; the one picked runs, the row underneath does not', () => { + vi.useFakeTimers(); + const onRowClick = vi.fn(); + const onEdit = vi.fn(); + const onDelete = vi.fn(); + + try { + act(() => { + render( + + + +
+ +
+
+
+
+ ); + }); + + const button = document.querySelector('.reqore-row-actions-menu-button') as HTMLElement; + expect(button.getAttribute('aria-label')).toBe('Row actions'); + expect(button.getAttribute('aria-haspopup')).toBe('menu'); + expect(button.getAttribute('aria-expanded')).toBe('false'); + + fireEvent.click(button); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(button.getAttribute('aria-expanded')).toBe('true'); + + const items = Array.from( + document.querySelectorAll('.reqore-popover-content .reqore-menu-item') + ); + // an animated button draws its label three times: shown, animated in, and hidden to hold its width + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain('Edit'); + expect(items[1].textContent).toContain('Delete'); + for (const item of items) { + expect(item.querySelector('.reqore-icon')).toBeTruthy(); + } + + fireEvent.click(items[1]); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onEdit).not.toHaveBeenCalled(); + expect(onRowClick).not.toHaveBeenCalled(); + expect(document.querySelectorAll('.reqore-popover-content .reqore-menu-item')).toHaveLength(0); + } finally { + vi.useRealTimers(); + } +}); + +const renderMenu = (actions: React.ComponentProps['actions']) => + act(() => { + render( + + + + + + + + ); + }); + +const tick = () => + act(() => { + vi.advanceTimersByTime(1); + }); + +test('The open list is a menu the button controls; the arrow keys move the focus in it', () => { + vi.useFakeTimers(); + const onDelete = vi.fn(); + + try { + renderMenu([ + { icon: 'EditLine', label: 'Edit' }, + { icon: 'DeleteBinLine', tooltip: 'Delete', onClick: onDelete }, + ]); + + const button = document.querySelector('.reqore-row-actions-menu-button') as HTMLElement; + expect(button.getAttribute('aria-controls')).toBeNull(); + + button.focus(); + fireEvent.click(button); + tick(); + + // the button names the list it opened, and the list is a menu of menu items, named + const menuId = button.getAttribute('aria-controls'); + expect(menuId).toBeTruthy(); + const menu = document.getElementById(menuId!) as HTMLElement; + expect(menu.getAttribute('role')).toBe('menu'); + expect(menu.getAttribute('aria-label')).toBe('Row actions'); + const items = Array.from(menu.querySelectorAll('[role="menuitem"]')); + expect(items.map((item) => item.textContent)).toEqual([ + expect.stringContaining('Edit'), + expect.stringContaining('Delete'), + ]); + + // the arrow keys move the focus from item to item, so a screen reader reads the one it is on + fireEvent.keyDown(button, { key: 'ArrowDown' }); + tick(); + expect(document.activeElement).toBe(items[0]); + expect(document.activeElement?.getAttribute('role')).toBe('menuitem'); + + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + tick(); + expect(document.activeElement).toBe(items[1]); + expect(document.activeElement?.textContent).toContain('Delete'); + + // Enter runs it, closes the menu and gives the button the focus back + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }); + tick(); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(document.querySelectorAll('.reqore-popover-content .reqore-menu-item')).toHaveLength(0); + expect(document.activeElement).toBe(button); + expect(button.getAttribute('aria-controls')).toBeNull(); + + // Escape from inside the menu puts it away, the focus back on the button too + fireEvent.click(button); + tick(); + fireEvent.keyDown(button, { key: 'ArrowDown' }); + tick(); + expect(document.activeElement?.getAttribute('role')).toBe('menuitem'); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + tick(); + expect(document.querySelectorAll('.reqore-popover-content .reqore-menu-item')).toHaveLength(0); + expect(document.activeElement).toBe(button); + } finally { + vi.useRealTimers(); + } +}); + +test('A panel action drawn as its own component is named, and clicked, by its props', () => { + vi.useFakeTimers(); + const onToggle = vi.fn(); + const Toggle = () => null; + + try { + renderMenu([ + { icon: 'EditLine', label: 'Edit' }, + { as: Toggle, props: { label: 'Enabled', icon: 'ToggleLine', onClick: onToggle } }, + ]); + + const button = document.querySelector('.reqore-row-actions-menu-button') as HTMLElement; + fireEvent.click(button); + tick(); + + const items = Array.from( + document.querySelectorAll('.reqore-popover-content .reqore-menu-item') + ); + expect(items).toHaveLength(2); + expect(items[1].textContent).toContain('Enabled'); + expect(items[1].querySelector('.reqore-icon')).toBeTruthy(); + + fireEvent.click(items[1]); + tick(); + expect(onToggle).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } +}); diff --git a/__tests__/table-drag-to-scroll.test.tsx b/__tests__/table-drag-to-scroll.test.tsx new file mode 100644 index 00000000..f7636c63 --- /dev/null +++ b/__tests__/table-drag-to-scroll.test.tsx @@ -0,0 +1,166 @@ +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); +}); + +/** + * A press on a column's resize handle resizes the column and leaves the table where it is (Foxhoundn, #708). + * The header's gesture started on the handle too, so the table scrolled under the column edge being dragged: + * on every table with a finger, and with the mouse when `dragToScroll` is on. + */ +test("a drag on a column's resize handle resizes the column and leaves the table where it is", () => { + const { body, header } = renderTable({ dragToScroll: true }); + const handle = header.querySelector('.reqore-table-header-resize') as HTMLElement; + expect(handle).toBeTruthy(); + + // with the mouse + pointer(handle, 'pointerDown', { clientX: 300 }); + pointer(handle, 'pointerMove', { clientX: 240 }); + pointer(handle, 'pointerUp', { clientX: 240 }); + expect(body.scrollLeft).toBe(0); + + // with a finger: the header takes sideways swipes on every table + pointer(handle, 'pointerDown', { clientX: 300, pointerType: 'touch' }); + pointer(handle, 'pointerMove', { clientX: 240, pointerType: 'touch' }); + pointer(handle, 'pointerUp', { clientX: 240, pointerType: 'touch' }); + expect(body.scrollLeft).toBe(0); +}); diff --git a/__tests__/tableCellActionTypes.test.tsx b/__tests__/tableCellActionTypes.test.tsx new file mode 100644 index 00000000..6895fb18 --- /dev/null +++ b/__tests__/tableCellActionTypes.test.tsx @@ -0,0 +1,105 @@ +import { render } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { expectTypeOf } from 'vitest'; +import { ReqoreLayoutContent, ReqoreTable, ReqoreUIProvider } from '../src'; +import { IReqoreButtonProps } from '../src/components/Button'; +import { IReqoreEntityRowAction } from '../src/components/EntityRow'; +import { IReqorePanelAction } from '../src/components/Panel'; +import { IReqoreSeverityRowAction } from '../src/components/SeverityRow'; +import { IReqoreTableColumn } from '../src/components/Table'; + +/* + * A table cell takes the actions a page gives a panel, a button group or a row as its own + * (qorus#646): qorus-ide hands the same action lists to a panel and to a table column. Before + * a7790c8f a cell action's type had no `label`, so any of them was accepted; a7790c8f typed it + * `string`, then `string | number`, which rejected a button's (any content) - TS2322s in qorus-ide, + * from a patch release. This file is type-checked by `yarn build:test`, so the assignments below + * are a test as well. + */ +const panelActions = (): IReqorePanelAction[] => [ + { label: 'Edit', icon: 'EditLine' }, + { label: 7, icon: 'NumbersLine' }, +]; +const buttonActions = (): IReqoreButtonProps[] => [ + { label: Open, icon: 'EditLine' }, +]; +const entityRowActions = (): IReqoreEntityRowAction[] => [{ label: 'Run' }]; +const severityRowActions = (): IReqoreSeverityRowAction[] => [{ label: 'Dismiss' }]; + +type TCellActions = NonNullable['actions']>; + +test('a column takes panel actions, whose labels are strings or numbers, as its cell actions', () => { + const column: IReqoreTableColumn = { dataId: 'x', cell: { actions: panelActions } }; + + expectTypeOf(panelActions).toMatchTypeOf(); + // and the column hands them on as they are + expect(column.cell?.actions?.({}).map(({ label }) => label)).toEqual(['Edit', 7]); +}); + +test('a column takes button and row actions, labelled with any content, as cell actions', () => { + const columns: IReqoreTableColumn[] = [ + { dataId: 'button', cell: { actions: buttonActions } }, + { dataId: 'entity', cell: { actions: entityRowActions } }, + { dataId: 'severity', cell: { actions: severityRowActions } }, + ]; + + expectTypeOf(buttonActions).toMatchTypeOf(); + expectTypeOf(entityRowActions).toMatchTypeOf(); + expectTypeOf(severityRowActions).toMatchTypeOf(); + expect(columns.map((column) => column.cell?.actions?.({}).length)).toEqual([1, 1, 1]); +}); + +const renderWideTable = (actions: TCellActions) => + act(() => { + render( + + + + + + ); + }); + +const cellButtons = () => + Array.from(document.querySelectorAll('.reqore-table-cell button')); + +test('a wide table draws a cell action\'s label in its button, a number as text', () => { + vi.useFakeTimers(); + try { + renderWideTable(panelActions); + + const [edit, seven] = cellButtons(); + expect(edit.textContent).toContain('Edit'); + expect(seven.textContent).toContain('7'); + // a label in sight names the button: no aria-label over it + expect(edit.getAttribute('aria-label')).toBeNull(); + expect(seven.getAttribute('aria-label')).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + +test('a wide table draws a labelled action\'s text, and names an icon-only one by tooltip', () => { + vi.useFakeTimers(); + try { + renderWideTable(() => [ + { icon: 'EditLine', label: 'Open', tooltip: 'Open the row' }, + { icon: 'DeleteBinLine', tooltip: 'Delete' }, + ]); + + const [open, remove] = cellButtons(); + expect(open.textContent).toContain('Open'); + expect(open.getAttribute('aria-label')).toBeNull(); + expect(remove.textContent).toBe(''); + expect(remove.getAttribute('aria-label')).toBe('Delete'); + } finally { + vi.useRealTimers(); + } +}); diff --git a/__tests__/tableNarrowSearch.test.tsx b/__tests__/tableNarrowSearch.test.tsx new file mode 100644 index 00000000..c69aa16d --- /dev/null +++ b/__tests__/tableNarrowSearch.test.tsx @@ -0,0 +1,170 @@ +import { fireEvent, render } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { ReqoreLayoutContent, ReqoreTable, ReqoreUIProvider } from '../src'; +import { IReqoreInputProps } from '../src/components/Input'; + +/* + * A narrow table's search is a button until it is used (qorus#646). Shown only for the text in it - + * the table narrowed with a search in it - the field went away with its last character, or with its + * clear button, and the focus that was in it fell to the page. And a caller's `filterProps` + * handlers replaced the ones that close the open field. + */ + +/* + * Every observer reports the width it is told, now and again on `resize`. Installed before the + * table is imported: react-use's `useMeasure`, which measures the table, settles at import time on + * a version that measures nothing where there is no ResizeObserver, as under jsdom. + */ +const SizedResizeObserver = vi.hoisted(() => { + class Observer { + static width = 900; + static instances: Observer[] = []; + readonly targets: Element[] = []; + constructor(readonly callback: ResizeObserverCallback) { + Observer.instances.push(this); + } + observe(target: Element) { + this.targets.push(target); + this.report(target); + } + unobserve() {} + disconnect() {} + report(target: Element) { + const width = Observer.width; + this.callback( + [ + { + target, + contentRect: { x: 0, y: 0, top: 0, left: 0, width, height: 400, right: width }, + contentBoxSize: [{ inlineSize: width, blockSize: 400 }], + } as unknown as ResizeObserverEntry, + ], + this as unknown as ResizeObserver + ); + } + } + + (globalThis as Record).ResizeObserver = Observer; + + return Observer; +}); + +const resize = (width: number) => { + SizedResizeObserver.width = width; + act(() => { + for (const observer of SizedResizeObserver.instances) { + observer.targets.forEach((target) => observer.report(target)); + } + }); +}; + +beforeEach(() => { + vi.useFakeTimers(); + SizedResizeObserver.width = 900; + SizedResizeObserver.instances = []; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const renderTable = (filterProps?: () => IReqoreInputProps) => + act(() => { + render( + + + + + + ); + }); + +const searchField = () => + document.querySelector('.reqore-panel-title input.reqore-input'); +const searchToggle = () => document.querySelector('.reqore-table-search-toggle'); +const type = (value: string) => { + fireEvent.change(searchField()!, { target: { value } }); + act(() => { + vi.advanceTimersByTime(400); + }); +}; + +/** A search typed into a wide table, which then narrows with the focus still in the field. */ +const narrowWithTextTyped = () => { + renderTable(); + expect(searchToggle()).toBeNull(); + searchField()!.focus(); + type('alp'); + resize(300); + // narrow, the field stays for its text, and the focus with it + expect(searchField()).not.toBeNull(); + expect(document.activeElement).toBe(searchField()); +}; + +test('A folded search emptied while it is typed in stays open, with the focus in it', () => { + narrowWithTextTyped(); + + type(''); + + expect(searchField()).not.toBeNull(); + expect(document.activeElement).toBe(searchField()); + // and left empty, it closes as an open search does + act(() => { + searchField()!.blur(); + }); + expect(searchField()).toBeNull(); + expect(searchToggle()).not.toBeNull(); +}); + +test('A folded search emptied with its clear button stays open, with the focus in it', () => { + narrowWithTextTyped(); + + fireEvent.click(document.querySelector('.reqore-clear-input-button')!); + act(() => { + vi.advanceTimersByTime(400); + }); + + expect(searchField()).not.toBeNull(); + expect(searchField()!.value).toBe(''); + expect(document.activeElement).toBe(searchField()); +}); + +test("A folded search closes on Escape, calling the caller's own key and blur handlers too", () => { + const onKeyDown = vi.fn(); + const onBlur = vi.fn(); + SizedResizeObserver.width = 300; + renderTable(() => ({ onKeyDown, onBlur })); + + fireEvent.click(searchToggle()!); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(document.activeElement).toBe(searchField()); + + fireEvent.keyDown(searchField()!, { key: 'Escape' }); + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(searchField()).toBeNull(); + expect(document.activeElement).toBe(searchToggle()); + + // the blur handler too: opened again and left empty, it closes and the caller hears it + fireEvent.click(searchToggle()!); + act(() => { + vi.advanceTimersByTime(1); + }); + act(() => { + searchField()!.blur(); + }); + expect(onBlur).toHaveBeenCalledTimes(1); + expect(searchField()).toBeNull(); +}); diff --git a/__tests__/templateListOpensOnFocus.test.tsx b/__tests__/templateListOpensOnFocus.test.tsx new file mode 100644 index 00000000..d5f88f7f --- /dev/null +++ b/__tests__/templateListOpensOnFocus.test.tsx @@ -0,0 +1,285 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { act, fireEvent, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + ReqoreContent, + ReqoreLayoutContent, + ReqoreRichTextEditor, + ReqoreTextarea, + ReqoreUIProvider, +} from '../src'; + +/** + * A field that offers templates lists them when the author comes into it, by a click, a tap or the keyboard + * (qorus#646, David: a value's field, tabbed into, offered nothing). The list a click opens is the click's: + * the focus the click brings does not open it a second time, which would close it again. And focus that + * comes back to the field from the list - a template picked - does not open it again. + */ + +const isOpen = () => document.querySelectorAll('.reqore-popover-content').length === 1; + +const items = [ + { label: 'qty', value: '$record:{qty}' }, + { label: 'price', value: '$record:{price}' }, +]; + +const show = () => { + render( + + + + + undefined} /> + + + + ); + return { + field: document.querySelector('.reqore-textarea') as HTMLElement, + before: document.querySelector('.before') as HTMLElement, + }; +}; + +describe('a template list', () => { + it('opens when the field is come into from the keyboard', () => { + const { field, before } = show(); + act(() => { + fireEvent.focusIn(field, { relatedTarget: before }); + }); + expect(isOpen()).toBe(true); + }); + + it('opens once for a click, which brings the focus too', () => { + const { field, before } = show(); + act(() => { + fireEvent.pointerDown(field); + fireEvent.focusIn(field, { relatedTarget: before }); + fireEvent.click(field); + }); + expect(isOpen()).toBe(true); + }); + + it('opens from the keyboard after a click on the field while it had the focus', () => { + const { field, before } = show(); + act(() => { + field.focus(); + }); + // a click on the field that already has the focus brings no focus: it lists the templates, as a click does + act(() => { + fireEvent.pointerDown(field); + fireEvent.click(field); + }); + expect(isOpen()).toBe(true); + act(() => { + fireEvent.keyDown(field, { key: 'Escape' }); + }); + expect(isOpen()).toBe(false); + // away and back by the keyboard: that focus is the keyboard's + act(() => { + before.focus(); + }); + act(() => { + fireEvent.focusIn(field, { relatedTarget: before }); + }); + expect(isOpen()).toBe(true); + }); + + it('opens from the keyboard after a click that brought no focus', () => { + const { field, before } = show(); + // a press whose focus the page held back (its default prevented): the click still ends it + act(() => { + fireEvent.pointerDown(field); + fireEvent.click(document.body); + }); + act(() => { + fireEvent.focusIn(field, { relatedTarget: before }); + }); + expect(isOpen()).toBe(true); + }); + + it("leaves a tap's focus to its click, though the focus comes after the release", () => { + const { field, before } = show(); + // a finger is released before the browser focuses the field, with the click the tap makes + act(() => { + fireEvent.pointerDown(field, { pointerType: 'touch' }); + fireEvent.pointerUp(field, { pointerType: 'touch' }); + fireEvent.focusIn(field, { relatedTarget: before }); + }); + expect(isOpen()).toBe(false); + act(() => { + fireEvent.click(field); + }); + expect(isOpen()).toBe(true); + }); + + it('opens from the keyboard after a touch on the field that scrolled the page', () => { + const { field, before } = show(); + // the page takes the touch for a scroll: the press is cancelled, nothing focused or clicked + act(() => { + fireEvent.pointerDown(field, { pointerType: 'touch' }); + fireEvent.pointerCancel(field, { pointerType: 'touch' }); + }); + act(() => { + fireEvent.focusIn(field, { relatedTarget: before }); + }); + expect(isOpen()).toBe(true); + }); + + it('opens from the keyboard after a mouse press released without a click', () => { + const { field, before } = show(); + // a press whose focus the page held back, released somewhere that makes no click + act(() => { + fireEvent.pointerDown(field, { pointerType: 'mouse' }); + fireEvent.pointerUp(document.body, { pointerType: 'mouse' }); + }); + act(() => { + fireEvent.focusIn(field, { relatedTarget: before }); + }); + expect(isOpen()).toBe(true); + }); + + it('does not open for focus that comes back from the list', () => { + const { field } = show(); + act(() => { + fireEvent.focusIn(field, { relatedTarget: null }); + }); + expect(isOpen()).toBe(false); + }); +}); + +/** + * A chip in a rich-text field takes the focus from the editor for the moment it is pressed, and its click + * hands it back. That focus never left the field: it is not the author coming into it from the keyboard. + * Taken for one, it opened the list, whose keep-below rule scrolled the page by the list's height, and + * handing the focus back closed it again: the page jumped by ~137px and showed nothing (qorus-ide). + */ +describe('a chip in a rich-text field that offers templates', () => { + const value = [ + { + type: 'paragraph', + children: [ + { text: 'before' }, + { type: 'tag', value: '$.result', label: 'result', children: [{ text: '' }] }, + { text: '' }, + ], + }, + ]; + + const showRichText = () => { + render( + + + + + undefined} + tags={{ template: { label: 'Templates', items } } as never} + /> + + + + ); + const editor = document.querySelector('[contenteditable="true"]'); + return { + editor, + chip: editor.querySelector('.reqore-tag'), + before: document.querySelector('.before'), + }; + }; + + /** A press on the chip: its focus moves from the editor to the chip, and its click hands it back. */ + const pressChip = (editor: HTMLElement, chip: HTMLElement) => { + act(() => { + fireEvent.pointerDown(chip); + chip.focus(); + }); + const openOnFocus = isOpen(); + act(() => { + fireEvent.click(chip); + editor.focus(); + }); + return openOnFocus; + }; + + it('does not list them when the cursor is already in the editor', () => { + const { editor, chip } = showRichText(); + act(() => { + editor.focus(); + }); + expect(isOpen()).toBe(false); + + // Neither the focus moving to the chip nor the chip's click lists them. + expect(pressChip(editor, chip)).toBe(false); + expect(isOpen()).toBe(false); + expect(document.activeElement).toBe(editor); + }); + + it('leaves a list that is open as it is', () => { + const { editor, chip } = showRichText(); + // a click in the text lists them + act(() => { + fireEvent.pointerDown(editor); + editor.focus(); + fireEvent.click(editor); + }); + expect(isOpen()).toBe(true); + + expect(pressChip(editor, chip)).toBe(true); + expect(isOpen()).toBe(true); + }); + + it('still lists them for a click in the text, and when the field is come into from the keyboard', () => { + const { editor, chip, before } = showRichText(); + act(() => { + editor.focus(); + }); + pressChip(editor, chip); + expect(isOpen()).toBe(false); + + act(() => { + fireEvent.pointerDown(editor); + fireEvent.click(editor); + }); + expect(isOpen()).toBe(true); + + // away, and back by the keyboard + act(() => { + before.focus(); + }); + expect(isOpen()).toBe(false); + act(() => { + fireEvent.focusIn(editor, { relatedTarget: before }); + }); + expect(isOpen()).toBe(true); + }); + + it("still lists them for a click on a chip's remove button", () => { + const { editor } = showRichText(); + act(() => { + editor.focus(); + }); + // an editable field's chip can be removed + const remove = editor.querySelector('.reqore-tag-remove'); + expect(remove).not.toBeNull(); + act(() => { + fireEvent.pointerDown(remove); + fireEvent.click(remove); + }); + expect(isOpen()).toBe(true); + }); + + it('puts the list away when the focus leaves the field', () => { + const { editor, before } = showRichText(); + act(() => { + fireEvent.pointerDown(editor); + editor.focus(); + fireEvent.click(editor); + }); + expect(isOpen()).toBe(true); + act(() => { + before.focus(); + }); + expect(isOpen()).toBe(false); + }); +}); diff --git a/__tests__/treeDialogControlledInputs.test.tsx b/__tests__/treeDialogControlledInputs.test.tsx new file mode 100644 index 00000000..90c30894 --- /dev/null +++ b/__tests__/treeDialogControlledInputs.test.tsx @@ -0,0 +1,62 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { fireEvent, render, screen } from '@testing-library/react'; +import { ReqoreUIProvider } from '../src'; +import { ReqoreTreeManagementDialog } from '../src/components/Tree/modal'; + +/** + * qorus#646 (React's warning, found by the story run's warning gate): the tree's dialog for a new item drew its + * key and value fields with no value, and the first key typed gave them one - React warned "A component is + * changing an uncontrolled input to be controlled" (Tree › New Object Item Can Be Added). They hold text from the + * start, empty for a new item; a host's own key or value renderer is still given what the item holds. + */ + +const UNCONTROLLED = /uncontrolled input to be controlled/; + +test("a new item's key and value fields are controlled from the start", () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + let errors: string[] = []; + try { + render( + + + + ); + const key = document.querySelector('.reqore-modal input') as HTMLInputElement; + const value = document.querySelector('.reqore-modal textarea') as HTMLTextAreaElement; + expect(key.value).toBe(''); + expect(value.value).toBe(''); + fireEvent.change(key, { target: { value: 'New item' } }); + fireEvent.change(value, { target: { value: 'New item value' } }); + expect(key.value).toBe('New item'); + expect(value.value).toBe('New item value'); + errors = consoleError.mock.calls.map((call) => call.map(String).join(' ')); + } finally { + consoleError.mockRestore(); + } + expect(errors.filter((error) => UNCONTROLLED.test(error))).toEqual([]); +}); + +test("a host's own renderers are given what a new item holds: nothing", () => { + const keys: unknown[] = []; + const values: unknown[] = []; + render( + + { + keys.push(value); + return key renderer; + }} + ValueRenderer={({ value }) => { + values.push(value); + return value renderer; + }} + /> + + ); + expect(screen.getByText('key renderer')).toBeTruthy(); + expect(keys.at(-1)).toBeUndefined(); + expect(values.at(-1)).toBeUndefined(); +}); diff --git a/package.json b/package.json index 3b902454..6a562ea7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqore", - "version": "0.78.1", + "version": "0.78.5", "description": "ReQore is a highly theme-able and modular UI library for React", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -16,7 +16,7 @@ "test": "vitest run --project unit", "test:watch": "vitest --project unit", "test:ci": "vitest run --project unit --reporter=json --outputFile=tests.json", - "test:stories": "vitest run --project storybook", + "test:stories": "vitest run --project storybook --project storybook-touch", "precheck": "yarn run lint && yarn run test && yarn build:test:prod && yarn build:test", "build": "./node_modules/typescript/bin/tsc --project tsconfig.prod.json", "build:test": "./node_modules/typescript/bin/tsc --noEmit", @@ -112,11 +112,9 @@ "react-aria-components": "^1.2.0", "react-hotkeys-hook": "^5.3.2", "react-icons": "5.4.0", - "react-overflow-list": "^0.5.0", "react-popper": "^2.3.0", "react-scrollbar": "^0.5.6", "react-use": "^17.6.0", - "react-use-pagination-hook": "^1.1.2", "react-window": "^1.8.6", "scheduler": "^0.23.0", "slate": "^0.103.0", diff --git a/src/components/Accordion/index.tsx b/src/components/Accordion/index.tsx index f05c2d89..4fd5d447 100644 --- a/src/components/Accordion/index.tsx +++ b/src/components/Accordion/index.tsx @@ -1,6 +1,7 @@ import { rgba } from 'polished'; import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; -import styled, { css } from 'styled-components'; +import { css } from 'styled-components'; +import styled from '../../helpers/styled'; import { GAP_FROM_SIZE, PADDING_FROM_SIZE, diff --git a/src/components/Breadcrumbs/index.tsx b/src/components/Breadcrumbs/index.tsx index 63879db5..5cc1e3ba 100644 --- a/src/components/Breadcrumbs/index.tsx +++ b/src/components/Breadcrumbs/index.tsx @@ -1,7 +1,8 @@ import { size as count } from 'lodash'; -import React from 'react'; -import { OverflowList } from 'react-overflow-list'; -import styled, { css } from 'styled-components'; +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { css } from 'styled-components'; +import styled from '../../helpers/styled'; import { ReqoreDropdown, ReqoreErrorBoundary } from '../..'; import { CONTROL_ICON_OPACITY } from '../../constants/colors'; import { MARGIN_FROM_SIZE, PADDING_FROM_SIZE, TSizes } from '../../constants/sizes'; @@ -14,7 +15,7 @@ import { IReqoreDropdownItem } from '../Dropdown/list'; import ReqoreIcon from '../Icon'; import { IReqoreTabsListItem } from '../Tabs'; import { StyledTabListItem } from '../Tabs/item'; -import ReqoreTabsList, { StyledReqoreTabsList } from '../Tabs/list'; +import ReqoreTabsList, { StyledReqoreTabsList, getTabsLength } from '../Tabs/list'; import ReqoreBreadcrumbsItem from './item'; export interface IReqoreBreadcrumbItemTabs { @@ -44,7 +45,7 @@ export interface IReqoreBreadcrumbsProps items: IReqoreBreadcrumbItem[]; rightElement?: JSX.Element; // Internal prop, ignore! (Kept for API compatibility — the trail width is now - // measured live via `OverflowList`, so this no longer drives the collapse.) + // measured live, so this no longer drives the collapse.) _testWidth?: number; customTheme?: IReqoreBreadcrumbsTheme; size?: TSizes; @@ -89,8 +90,8 @@ const StyledReqoreBreadcrumbs = styled.div` // The trail region — takes all the width the right element leaves and clips. // `min-width: 0` lets it shrink below its content so the current page can // ellipsise; `overflow: hidden` is the final safety net. Its single child (the -// `OverflowList` row, or the static row) fills it so `OverflowList` measures the -// AVAILABLE width, not its own content. +// folding row, or the static row) fills it, so the row's end is the end of the +// AVAILABLE width, not of its own content. const StyledTrail = styled.div<{ $color: string }>` flex: 1 1 auto; min-width: 0; @@ -104,14 +105,6 @@ const StyledTrail = styled.div<{ $color: string }>` min-width: 0; } - /* The OverflowList container sets only display/flexWrap/minWidth inline (no - align-items), so without this its crumbs top-align against the tallest one - — e.g. a crumb carrying a two-line description. Center them so the row - reads as a single baseline, matching the static trail. */ - .reqore-breadcrumbs-overflow-list { - align-items: center; - } - /* Force the readable item colour onto the crumbs + collapsed dropdowns, as the original did with a descendant selector. A minimal/flat button sets its OWN text colour directly on the element — for a chromatic custom theme that @@ -139,8 +132,123 @@ const StyledStaticTrail = styled.div` min-width: 0; `; -const StyledBreadcrumbsTabsWrapper = styled.div` +// The responsive trail's row: its crumbs on one line, centred against the tallest +// (a crumb with a two-line description), and as wide as the trail, whatever they +// need - what does not fit is folded into the menu at its start. +const StyledFoldingTrail = styled.div` display: flex; + flex-wrap: nowrap; + align-items: center; + min-width: 0; +`; + +/** + * Whether the crumb holding tabs is squeezed while a crumb before it could fold instead: it clips + * the tabs it draws, or - at the end of the trail, where it takes the room the other parts leave - + * it has less room than all its tabs need, so that some are in their "More" menu. That room is what + * the tabs list itself compares its width with (`getTabsLength`, in `data-tabs-width`), not the + * width of the tabs it draws now: once some are in "More", it draws narrower, and a trail made + * wider again would otherwise unfold its crumbs first. When it is the first crumb shown, it is + * squeezed rather than folded, and its tabs fold into their own menu. + */ +const tabsSqueezed = (part: Element): boolean => { + if ( + !(part instanceof HTMLElement) || + !part.classList.contains('reqore-breadcrumbs-tabs') || + part.dataset.position === '0' + ) { + return false; + } + + const list = part.querySelector('.reqore-tabs-list'); + // its layout width, as the tabs list measures it: a transform on an ancestor scales neither + const room = list ? parseFloat(getComputedStyle(list).width) : NaN; + + return Number(part.dataset.tabsWidth) > room || part.scrollWidth > part.clientWidth + 0.5; +}; + +/** + * Whether the trail draws more than it has room for: a part (a crumb, an arrow, the menu) reaches + * past its end, or the crumb holding tabs is squeezed while a crumb before it could fold instead. + */ +const trailOverflows = (row: HTMLElement | null): boolean => { + if (!row) { + return false; + } + + const end = row.getBoundingClientRect().right; + + return Array.from(row.children).some( + (part) => part.getBoundingClientRect().right > end + 0.5 || tabsSqueezed(part) + ); +}; + +/** + * Whether a part is as wide as the room the other parts leave it in the trail: the crumb holding + * tabs at its end, the dropdown standing in for the current page. It gets narrower when the trail + * does, which leaves no more room for anything else. + */ +const fillsTrail = (part: Element): boolean => + part.classList.contains('reqore-breadcrumbs-overflow-current') || + (part instanceof HTMLElement && part.dataset.fills === 'true'); + +/** + * Whether two trails are drawn alike, so the second needs no measuring again: the same crumbs, each with the + * same values. Handlers are left out (a page that passes new ones on every render still passes the same + * trail), and an object value (`props`, an `effect`) is compared one level down. + */ +const sameTrail = (one: IReqoreBreadcrumbItem[], other: IReqoreBreadcrumbItem[]): boolean => { + if (one === other) { + return true; + } + + if (one.length !== other.length) { + return false; + } + + const sameValue = (a: unknown, b: unknown): boolean => { + if (a === b || (typeof a === 'function' && typeof b === 'function')) { + return true; + } + + if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || React.isValidElement(a)) { + return false; + } + + const keys = Object.keys(a); + + return ( + keys.length === Object.keys(b).length && + keys.every((key) => (a as Record)[key] === (b as Record)[key]) + ); + }; + + return one.every((crumb, index) => { + const keys = Object.keys(crumb); + const otherCrumb = other[index] as Record; + + return ( + keys.length === Object.keys(otherCrumb).length && + keys.every((key) => sameValue((crumb as Record)[key], otherCrumb[key])) + ); + }); +}; + +/** How many crumbs the trail folds, and what it was measured for. */ +interface IReqoreTrailFold { + items: IReqoreBreadcrumbItem[]; + size: TSizes; + folded: number; + /** One fewer crumb folded at a time while everything still fits (more room, or a new trail). */ + unfolding: boolean; +} + +// At the end of the trail, the crumb holding tabs takes all the room the other parts leave +// (`$fills`): the tabs list measures the room it is given, so a crumb only as wide as the tabs it +// draws would keep the ones it once put in "More" there however wide the trail got again. +const StyledBreadcrumbsTabsWrapper = styled.div<{ $fills?: boolean }>` + display: flex; + flex: ${({ $fills }) => ($fills ? '1 1 auto' : '0 1 auto')}; align-items: center; overflow: hidden; min-width: 0; @@ -207,6 +315,202 @@ const ReqoreBreadcrumbs: React.FC = ({ const leafItem = items[items.length - 1]; + /* The crumbs folded from the start of the trail into its menu: as many as it takes for every part the + trail draws to lie inside it - the current page too, into one dropdown labelled with it, when even it + does not fit. It is settled before the browser paints, so no frame ever draws a crumb past the trail + (qorus#646: when the page changed its crumbs, the trail used to fold one crumb per frame, and a phone + showed "… › New chat" running 79px past the trail for 50ms). After each render a layout effect + measures the parts and folds one more crumb while one reaches past the trail (or the crumb holding + tabs clips them, see `trailOverflows`), or one fewer while everything fits and the trail is + unfolding; each step is a render React makes before the browser paints. + + What measures the trail again: + - a new trail (`items`, unless it is drawn alike) or crumb size: from what is folded now, unfolding + while everything fits; + - the trail getting wider, or a part narrower: unfolding the same way; + - the trail getting narrower, or a part wider (a badge arriving, padding animating after the page has + loaded), so that a part reaches past it: folding. + The last two come from a ResizeObserver, after the browser's layout and before its paint. It + renders the new fold there and then (`flushSync`), so that frame is painted folded too. It lets + go of every part first and takes them up again in the next frame: a size changed inside the + observer's own callback would be left unreported, which the browser reports as an error. For + the same reason the trail keeps its height until then: a crumb with a description is taller + than the others, and the trail's height changed inside the callback would change the size of + what holds it, which another observer may watch (a panel's, a page layout's). + A part's width is taken when the trail draws it, not from the observer's first report, which + can come after it has changed. The trail's own width is taken when it is first drawn and then + as the observer reports it, so a render that made it wider (a narrower right element, a wider + `maxWidth`) is reported too, and unfolds what fits. Widths are compared as + `getBoundingClientRect()` gives them, as `trailOverflows` measures: the box the observer + reports is not scaled by a transform on an ancestor, so under one every report would differ. */ + const rowRef = useRef(null); + const [fold, setFold] = useState({ items, size, folded: 0, unfolding: false }); + const folded = Math.min(fold.folded, count(items)); + // as the trail last drew it, for the observer: with every crumb folded, none is left to fold + const drawnFold = useRef({ folded, total: count(items) }); + + if (fold.size !== size || !sameTrail(fold.items, items)) { + // measured again during this render, so no frame shows the old fold over the new crumbs + setFold({ items, size, folded, unfolding: folded > 0 }); + } + + // The width all of a crumb's tabs need, by the tabs list's own reckoning (see `tabsSqueezed`). + const tabsWidths = useMemo( + () => + items.map((item) => + item.withTabs + ? getTabsLength( + item.withTabs.tabs.filter((tab) => tab.show !== false), + 'width', + item.withTabs.activeTab, + size + ) + : undefined + ), + [items, size] + ); + + useLayoutEffect(() => { + if (!responsive) { + return; + } + + if (trailOverflows(rowRef.current)) { + if (folded < count(items)) { + setFold((current) => ({ ...current, folded: folded + 1, unfolding: false })); + } else if (fold.unfolding) { + setFold((current) => ({ ...current, unfolding: false })); + } + } else if (fold.unfolding) { + setFold((current) => + folded > 0 ? { ...current, folded: folded - 1 } : { ...current, unfolding: false } + ); + } + }); + + // The parts the observer watches, taken up after every render (see above). + const watchParts = useRef<() => void>(); + + useLayoutEffect(() => { + if (!responsive || typeof ResizeObserver === 'undefined') { + return undefined; + } + + // what the observer watches, and the width each part was last measured at (see above) + const watched = new Set(); + const widths = new Map(); + // the trail's height as the observer last reported it: its layout height, exact, which a + // transform does not scale + let rowHeight: number | undefined; + let pinned: HTMLElement | undefined; + let suspended = false; + let frame = 0; + + const observer = new ResizeObserver((entries) => { + const row = rowRef.current; + + if (!row || suspended) { + return; + } + + let unfold = false; + + for (const { target, borderBoxSize, contentRect } of entries) { + const width = target.getBoundingClientRect().width; + const was = widths.get(target); + + if (target === row) { + rowHeight = borderBoxSize?.[0]?.blockSize ?? contentRect.height; + } + + if (was !== undefined && Math.abs(width - was) >= 0.5) { + widths.set(target, width); + // the trail wider, or a part narrower: more may fit + if (target === row ? width > was : width < was && !fillsTrail(target)) { + unfold = true; + } + } + } + + const { folded: drawn, total } = drawnFold.current; + + // a part past the end with every crumb folded (a trail narrower than the current page's + // dropdown, which keeps a few characters of it) cannot be folded away + if (!unfold && (drawn >= total || !trailOverflows(row))) { + return; + } + + observer.disconnect(); + watched.clear(); + suspended = true; + if (rowHeight !== undefined) { + pinned = row; + row.style.height = `${rowHeight}px`; + } + try { + flushSync(() => + setFold((current) => ({ ...current, unfolding: unfold || current.unfolding })) + ); + } finally { + frame = requestAnimationFrame(() => { + frame = 0; + suspended = false; + pinned?.style.removeProperty('height'); + pinned = undefined; + watchParts.current?.(); + }); + } + }); + + watchParts.current = () => { + const row = rowRef.current; + + if (!row || suspended) { + return; + } + + const parts = [row, ...Array.from(row.children)]; + + for (const element of Array.from(watched)) { + if (!parts.includes(element)) { + observer.unobserve(element); + watched.delete(element); + } + } + + for (const element of Array.from(widths.keys())) { + if (!parts.includes(element)) { + widths.delete(element); + } + } + + for (const element of parts) { + if (!watched.has(element)) { + // the border box: a crumb that grows by its padding keeps its content box + observer.observe(element, { box: 'border-box' }); + watched.add(element); + } + + if (element !== row || !widths.has(row)) { + widths.set(element, element.getBoundingClientRect().width); + } + } + }; + watchParts.current(); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + pinned?.style.removeProperty('height'); + watchParts.current = undefined; + }; + }, [responsive]); + + useLayoutEffect(() => { + drawnFold.current = { folded, total: count(items) }; + watchParts.current?.(); + }); + const renderArrow = (key: string) => ( = ({ /> ); - // `OverflowList` calls this for every VISIBLE crumb (collapsed ancestors go to - // `renderOverflow` instead), passing the crumb's POSITION. Both the React key - // and the separator are derived from that position — NOT from the item's - // identity or `items.indexOf(item)`. This matters because on a re-render with a - // new `items` array, OverflowList briefly maps over its previous (stale) item - // objects; `indexOf` would return -1 for them, collapsing every crumb onto the - // same key `crumb--1` and making React accumulate DOM (a new crumb appended on - // every render). A positional key is always valid and unique. + // Draws every VISIBLE crumb (folded ones go to `renderOverflow` instead), by its + // POSITION among them. The React key is the crumb's place in the WHOLE trail + // (`folded + index`): a key by its place among the visible crumbs gave a folded + // crumb's element to the next crumb along, which then animated from that + // crumb's look to its own - and was measured while it did. // // The separator precedes every visible crumb except the one at position 0. When // ancestors collapse, `renderOverflow` emits the "…" group followed by its own // trailing "›", so the first visible crumb (position 0, no leading separator) // still connects to it. - const renderItem = (item: IReqoreBreadcrumbItem, index: number) => { - const key = `crumb-${index}`; + const renderItem = (item: IReqoreBreadcrumbItem, index: number, folded: number) => { + const key = `crumb-${folded + index}`; if (item.withTabs) { + // only at the end of a folding trail: a crumb after it would be pushed to the trail's end + const fills = responsive && folded + index === count(items) - 1; + return ( - + {index !== 0 && renderArrow(`${key}-arrow`)} = ({ derives its readable text + intent shades from `theme.main`; handing it the resolved theme (whose `.main` is the base dark colour, not the breadcrumbs colour) turned the text white and shifted the pills. */} - + ); }; // The collapsed crumbs, folded into a dropdown at the start of the trail. - // Two shapes, because collapse happens from the start (`collapseFrom:'start'`): + // Two shapes, because crumbs fold from the start: // // - Only ancestors collapsed → a compact "…" with a hidden-count badge; the // current page stays inline (in full) to its right. @@ -284,9 +599,7 @@ const ReqoreBreadcrumbs: React.FC = ({ return null; } - // Count-based, not identity-based: everything collapsed ⇒ the overflow holds - // as many crumbs as the trail has. (Identity would break when OverflowList - // maps over stale item objects during a re-render.) + // everything folded: the menu stands in for the current page const isFullyCollapsed = count(overflowItems) >= count(items); if (isFullyCollapsed) { @@ -321,6 +634,13 @@ const ReqoreBreadcrumbs: React.FC = ({ intent={leafItem?.intent} label={leafItem?.label} badge={leafItem?.badge} + // ...and is NAMED as that crumb is, in full: its label may be cut with an ellipsis to + // fit, so a screen reader gets the whole name - the leaf's own, its label, or the label + // its menu row has (an icon-only leaf, a Home that is just a house). No title of its + // own: hovering opens the menu, which names every crumb in full, and a browser's tooltip + // would sit on it (a phone shows none). + aria-label={leafItem?.['aria-label'] ?? leafItem?.label ?? leafItem?.menuLabel} + title={leafItem?.title} // A floor so the current page always keeps a few readable characters — // never just an icon — however tight the bar gets. This can't cause an // overlap: the trail is `min-width: 0; overflow: hidden`, so it clips @@ -365,20 +685,15 @@ const ReqoreBreadcrumbs: React.FC = ({ > {responsive ? ( - + // Every crumb may fold, the current page too: when even it does not fit, the trail is the one + // dropdown labelled with it. Keeping the current page out of the fold is what used to leave it + // squeezed to an unreadable stub. + + {renderOverflow(items.slice(0, folded))} + {items.slice(folded).map((item, index) => renderItem(item, index, folded))} + ) : ( - {items.map(renderItem)} + {items.map((item, index) => renderItem(item, index, 0))} )} {rightElement && ( diff --git a/src/components/Breadcrumbs/item.tsx b/src/components/Breadcrumbs/item.tsx index 03a052de..cb5b6f4b 100644 --- a/src/components/Breadcrumbs/item.tsx +++ b/src/components/Breadcrumbs/item.tsx @@ -15,7 +15,18 @@ export interface IReqoreBreadcrumbItemStyle extends IReqoreBreadcrumbItemProps { } const ReqoreBreadcrumbsItem = forwardRef( - ({ label, props, as, customTheme, ...rest }: IReqoreBreadcrumbItemProps, ref) => { + ( + // `interactive` is not a button prop; a button rendered `as` a router link would hand it on. + { + label, + props, + as, + customTheme, + interactive: _interactive, + ...rest + }: IReqoreBreadcrumbItemProps, + ref + ) => { const theme = useReqoreTheme('main', customTheme); return ( diff --git a/src/components/Bubble/index.tsx b/src/components/Bubble/index.tsx index eac864fd..079c71d0 100644 --- a/src/components/Bubble/index.tsx +++ b/src/components/Bubble/index.tsx @@ -11,7 +11,8 @@ import { type ReactNode, } from 'react'; import { rgba } from 'polished'; -import styled, { css } from 'styled-components'; +import { css } from 'styled-components'; +import styled from '../../helpers/styled'; import { BUBBLE_AVATAR_RADIUS_FROM_SIZE, BUBBLE_RADIUS_FROM_RADIUS_SIZE, diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 76d998e5..ab53954e 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -3,7 +3,7 @@ import { size } from 'lodash'; import { rgba, saturate, tint } from 'polished'; import React, { forwardRef, memo, useCallback, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import styled, { css, keyframes } from 'styled-components'; +import { css, keyframes } from 'styled-components'; import { CONTROL_ICON_OPACITY } from '../../constants/colors'; import { CONTROL_HORIZONTAL_PADDING_FROM_SIZE, @@ -27,7 +27,12 @@ import { isAchromatic, } from '../../helpers/colors'; import { shortcutHasModifier, TReqoreKeyboardShortcut } from '../../helpers/shortcuts'; -import { omitStyleProps } from '../../helpers/styled'; +import styled, { + listReqoreStyleProps, + omitStyleProps, + REQORE_CONTROL_GROUP_CHILD_PROPS, + TReqoreStylePropKeys, +} from '../../helpers/styled'; import { alignToFlexAlign, getOneLessSize } from '../../helpers/utils'; import { useCombinedRefs } from '../../hooks/useCombinedRefs'; import { useReqoreProperty } from '../../hooks/useReqoreContext'; @@ -333,12 +338,76 @@ export const StyledAnimatedTextWrapper = styled.span` align-items: ${({ textAlign }) => alignToFlexAlign(textAlign)}; `; +/** + * Every button prop that only styles the button. None of them reaches the rendered element — + * the `