From b293652f168ef572d9c56d0a8854e352b2017343 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Wed, 7 Oct 2026 10:25:51 +0200 Subject: [PATCH 01/87] =?UTF-8?q?fix(popover):=20a=20list=20opened=20by=20?= =?UTF-8?q?focus=20opens=20for=20a=20field=20focused=20as=20it=20mounts=20?= =?UTF-8?q?=E2=80=94=200.77.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A popover with handler='focus' listened for focusin only. A field focused as it mounts - an editor that opens with the cursor in its field, as a read-first form row does when it is clicked - was focused before the listener was attached, so no focusin came and its list stayed closed. The user then clicked in the field, which already had the focus, and still saw nothing until they left it and came back. In the Qorus IDE this hid every template a typed field offers (qorus#646: an added field of type Whole number showed no fields of the record to refer to). When the listener is attached, a focus popover whose field already has the focus opens. 0.77.9: 0.77.8 is taken by #696. --- __tests__/popoverFocusOpens.test.tsx | 84 ++++++++++++++++++++++++++++ package.json | 2 +- src/components/Popover/index.tsx | 8 +++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 __tests__/popoverFocusOpens.test.tsx diff --git a/__tests__/popoverFocusOpens.test.tsx b/__tests__/popoverFocusOpens.test.tsx new file mode 100644 index 00000000..8b5baa34 --- /dev/null +++ b/__tests__/popoverFocusOpens.test.tsx @@ -0,0 +1,84 @@ +// 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 for a field nobody is in', () => { + show(); + settle(); + expect(document.activeElement).toBe(document.body); + expect(isOpen()).toBe(false); + }); +}); diff --git a/package.json b/package.json index 11511f4b..f7b7d228 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqore", - "version": "0.77.7", + "version": "0.77.9", "description": "ReQore is a highly theme-able and modular UI library for React", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index a1d0eed3..1f7db3ed 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -627,6 +627,14 @@ export const ReqorePopover = memo( if (handler === 'hoverStay') { componentRef.addEventListener('mouseleave', cancelTimeout); } + + /* A popover opened by focus is opened by the user being in the field, however they got there. + A field focused as it mounts (an editor that opens with the cursor in its field) was focused + before this listener was attached: no `focusin` came, and its list stayed closed until the + user left the field and came back. */ + if (handler === 'focus' && componentRef.contains(document.activeElement)) { + open(); + } } } From cdaba6c496b1b5abe2c743f2c8458b3ef2f3c27a Mon Sep 17 00:00:00 2001 From: David Nichols Date: Wed, 7 Oct 2026 13:19:41 +0200 Subject: [PATCH 02/87] fix(rich-text-editor): a value is compared with the document as it is committed, so a key typed after it is kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor compared a value it was given with its document in a passive effect, which the browser runs after painting - under load, after the next key. A value that matched the document when it was rendered was then compared with a document that had a key more, taken as a value from outside, and put in its place: the key was lost and the caret went to the end. reqraft's SmartEditor › With Markdown Docs typed "arr.for" and got "arr.fr" in CI; under 6x CPU throttling it lost a key in most runs. The comparison runs in a layout effect, before the browser handles another key. A value from outside still replaces the document. richTextEditorValueSync.test.tsx types a key between a value's commit and its passive effects: lost before, kept now. --- __tests__/richTextEditorValueSync.test.tsx | 78 ++++++++++++++++++++++ src/components/RichTextEditor/index.tsx | 29 ++++++-- 2 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 __tests__/richTextEditorValueSync.test.tsx 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/src/components/RichTextEditor/index.tsx b/src/components/RichTextEditor/index.tsx index 2e48dd0f..0eb78c85 100644 --- a/src/components/RichTextEditor/index.tsx +++ b/src/components/RichTextEditor/index.tsx @@ -1,7 +1,15 @@ import isPropValid from '@emotion/is-prop-valid'; import { map, size } from 'lodash'; -import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useState } from 'react'; -import { useUpdateEffect } from 'react-use'; +import { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { BaseEditor, createEditor, Editor, Range, Transforms } from 'slate'; import { HistoryEditor, withHistory } from 'slate-history'; import { @@ -408,9 +416,20 @@ export const ReqoreRichTextEditor = forwardRef< useImperativeHandle(ref, () => editor, [editor]); const [target, setTarget] = useState(); - useUpdateEffect(() => { + /* The value is compared with the document as it is committed - in a layout effect, which runs before + the browser handles another key. A passive effect ran after painting and, under load, after the next + key: a value that matched the document when it was rendered was then compared with a document that + had a key more, taken as a value from outside, and put in its place - the key was lost and the caret + went to the end ("arr.for" typed came out "arr.fr"). */ + const valueKey = JSON.stringify(value); + const mounted = useRef(false); + useLayoutEffect(() => { + if (!mounted.current) { + mounted.current = true; + return; + } // Only update the editor's children if the value has changed - if (JSON.stringify(value) === JSON.stringify(editor.children)) { + if (valueKey === JSON.stringify(editor.children)) { return; } // Use Slate transforms instead of direct mutation @@ -432,7 +451,7 @@ export const ReqoreRichTextEditor = forwardRef< editor.selection = null; editor.children = value; } - }, [JSON.stringify(value)]); + }, [valueKey]); const isEmpty = useMemo(() => { return size(value) === 1 && size(value[0].children) === 1 && value[0].children[0].text === ''; From 9e025c6798c996266e83ba759e7cc55564988849 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Wed, 7 Oct 2026 14:17:14 +0200 Subject: [PATCH 03/87] =?UTF-8?q?fix(popover):=20the=20focus=20check=20is?= =?UTF-8?q?=20made=20once=20per=20field,=20so=20Escape=20closes=20a=20focu?= =?UTF-8?q?s=20list=20again=20=E2=80=94=200.78.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foxhoundn's review of #708: the check for a field focused as it mounts ran on every re-run of the listener effect, and the effect re-runs whenever the list opens or closes (it depends on `open`, which depends on `isOpen`). Closing the list with the cursor still in the field - Escape, or a click on it with closeOnTargetClick - re-ran it, found the field focused, and reopened the list at once. The check is made once per trigger, remembered in a ref, as the listener is first attached. Test: a list closed with Escape stays closed while the field keeps the focus (fails without the change). 0.78.1: #696 goes in first as 0.78.0. --- __tests__/popoverFocusOpens.test.tsx | 18 ++++++++++++++++++ package.json | 2 +- src/components/Popover/index.tsx | 13 ++++++++++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/__tests__/popoverFocusOpens.test.tsx b/__tests__/popoverFocusOpens.test.tsx index 8b5baa34..5e13e5cb 100644 --- a/__tests__/popoverFocusOpens.test.tsx +++ b/__tests__/popoverFocusOpens.test.tsx @@ -75,6 +75,24 @@ describe('a list opened by focus', () => { 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(); diff --git a/package.json b/package.json index f7b7d228..3b902454 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqore", - "version": "0.77.9", + "version": "0.78.1", "description": "ReQore is a highly theme-able and modular UI library for React", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index 1f7db3ed..482cb159 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -216,6 +216,10 @@ export const ReqorePopover = memo( const popperRef = useRef(null); const [isOpen, setIsOpen] = React.useState(false); + /* The trigger whose focus was checked when its listener was attached. The listener effect re-runs + whenever the list opens or closes (it depends on `open`, which depends on `isOpen`); a check on + every re-run reopened a list Escape had just closed while the field kept the focus. */ + const focusCheckedFor = useRef(null); const timeoutRef = useRef(null); const isTargetHovered = useRef(false); const isPopoverHovered = useRef(false); @@ -631,9 +635,12 @@ export const ReqorePopover = memo( /* A popover opened by focus is opened by the user being in the field, however they got there. A field focused as it mounts (an editor that opens with the cursor in its field) was focused before this listener was attached: no `focusin` came, and its list stayed closed until the - user left the field and came back. */ - if (handler === 'focus' && componentRef.contains(document.activeElement)) { - open(); + user left the field and came back. Checked once per trigger, as it is attached. */ + if (handler === 'focus' && focusCheckedFor.current !== componentRef) { + focusCheckedFor.current = componentRef; + if (componentRef.contains(document.activeElement)) { + open(); + } } } } From 27dad97bd8bc717d4fd501709a0ce302cce9787e Mon Sep 17 00:00:00 2001 From: David Nichols Date: Wed, 7 Oct 2026 14:31:55 +0200 Subject: [PATCH 04/87] docs(components): the focus popover's attach-time check and the rich-text value sync --- src/components/COMPONENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/COMPONENTS.md b/src/components/COMPONENTS.md index 6e486d7d..7ad96174 100644 --- a/src/components/COMPONENTS.md +++ b/src/components/COMPONENTS.md @@ -49,11 +49,11 @@ | **ReqorePaging** | Pagination controls component with page buttons, load more, and scroll-based infinite loading options. | | **ReqorePanel** | Container component with optional header, footer, actions, and resizable panels with breadcrumbs support. **An action's `responsive: false` is honoured by the panel itself as of 0.76.0.** A narrow panel drops its actions' labels and keeps the icons — the right trade for a toolbar, since the label is a verb the icon already carries — and `responsive: false` is how an action says it is not one of those. The prop existed and the panel never read it: only the `ReqoreControlGroup` rendering the actions did, so the panel stripped the label back off and the action rendered as a bare icon anyway. It now reads it where the label is decided, which is why `Layout/Panel → Fluid` finally shows the contrast its fixture was written for ("Visible" keeps its label, "Hidden when small" does not). Use it for an action whose icon cannot carry its meaning alone — a destructive one, or either half of a two-way choice. Optional `raised` adds a subtle 3D inset highlight when paired with `flat`, suppressed whenever the panel actually draws a border — normally that means an `intent` is set, but an `accentPosition` panel draws no border, so `raised` applies there too. Optional `accentPosition` (`'left'` \| `'top'`) + `accentSize` (px or a `TSizes` name via `ACCENT_SIZE_TO_PX`; default `5` = `'normal'`) render the intent as a single edge strip instead of tinting the whole border (the border goes neutral — at rest AND on hover — or disappears on `flat` panels) — mirroring ReqoreCallout's accent API. With `stickyHeader` the wrapper stops clipping, so the strip rounds its own corners to match the panel. `stickyHeader` pins flush to the visible top edge of whatever scrolls, even when that scrollport carries its own top padding — which every default panel/drawer/modal body does, since the panel body puts `padding` and `overflow` on one element. The panel measures that padding at runtime and subtracts it from the sticky `top`; `stickyHeaderOffset` remains a deliberate caller offset applied on top of that corrected zero. Note the scroll ancestor is resolved by walking for `overflow-y: auto | scroll | overlay | hidden` — `hidden` counts, because it is a scroll container and does capture sticky. Header layout is configurable via `iconWithLabel` (default `false`; when `true`, the leading icon renders inside the label+badge row so the description sits beneath both icon and label) and `iconVerticalAlign` (`'top'` \| `'center'` \| `'bottom'` — default `'center'`; only takes effect when `iconWithLabel={false}`). Accepts an optional `radiusSize` (`TSizes`) that overrides the default `size`-derived corner roundness using the pronounced `RADIUS_FROM_RADIUS_SIZE` scale. An action declared `show: 'hover'` is hidden until the panel is hovered — but ONLY where the pointer can hover: the hiding rule sits inside a `@media (hover: hover) and (pointer: fine)` query, so on touch the action stays visible instead of being `display: none` with no route to it. **Media** (new in 0.78.0): `media` (an image URL, or any node — ``, ``, `