Skip to content

fix: focus lists, rich-text sync, table header scroll + dragToScroll, panel floating actions, no styling prop reaches the DOM, descriptions take block content — 0.78.5 - #708

Merged
Foxhoundn merged 91 commits into
developfrom
bugfix/focus-popover-opens-when-focused
Oct 15, 2026
Merged

Foxhoundn merged 91 commits into
developfrom
bugfix/focus-popover-opens-when-focused

Conversation

@davidnich

@davidnich davidnich commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

All of our open reqore fixes in one PR, as 0.78.5. The PR carries what #709 (closed), #710 and #713 (consolidated here, closed unmerged) held. 0.78.0 is released; 0.78.1, 0.78.3 and 0.78.4 are claimed by the prereleases of #708, #710 and #713, so the one bump for this PR is 0.78.5 (7c9c127).

1. A list opened by focus opens for a field focused as it mounts (b293652)

A popover with handler='focus' listened only for focusin. A field focused as it mounts was focused before the listener was attached, so no focusin came and its list stayed closed. This is what an editor that opens with the cursor in its field does, such as a read-first form row when it is clicked. Clicking the already-focused field still showed nothing until the user left it and came back.

In the Qorus IDE, this hid every template a typed field offers: an added field of type Whole number showed none of the record's fields.

  • Fix: when the listener is first attached to a trigger, a focus popover whose field already has the focus opens. The check runs once per trigger (a ref), not on every re-run of the listener effect. Running it on every re-run reopened a list that Escape had just closed (Foxhoundn's review, 9e025c6).
  • Tests: popoverFocusOpens.test.tsx
    • opens on focus;
    • opens for a field focused as it mounts (fails without the fix);
    • stays closed for a field nobody is in;
    • stays closed after Escape while the field keeps the focus (fails without the once-per-trigger check).

2. A rich-text value is compared with the document as it is committed, so a key typed after it is kept (cdaba6c)

The editor compared an incoming value with its document in a passive effect. The browser runs those after painting, which under load can be after the next key. A value that matched the document when it was rendered was then compared with a document that had one more key, taken as a value from outside, and put in its place. The key was lost and the caret jumped to the end.

reqraft's SmartEditor › With Markdown Docs typed arr.for and got arr.fr in CI (toolkit-react run 37601004195). With the CPU throttled 6x in a real browser, it lost a key in most runs.

  • Fix: the comparison runs in a layout effect, before the browser handles another key. A value from outside still replaces the document.
  • Tests: richTextEditorValueSync.test.tsx types a key between a value's commit and its passive effects: the key was lost before the fix and is kept now. A value from outside still replaces the document.
  • Verified: the same story at 6x throttling went from losing a key in most runs to 8 out of 8 passing with this build.

3. No Reqore styling prop reaches a component a Reqore component is rendered as (from #709, closed unmerged: 3d5c4a3, 0669d75, 9180338)

<ReqoreControlGroup vertical stack><ReqoreButton as={Link} to='/x' fluid compact /></ReqoreControlGroup> handed the router link 36 props, which it wrote onto its <a>. Among them were fluid, compact, flat, animate, maxWidth, alignSelf, active, readOnly, tooltip, description and the group's stack-position flags (isChild, isFirst, childId, …). A resizable + transparent ReqorePanel handed re-resizable transparent, which re-resizable spreads onto its div.

  • Root cause: on develop, Button/index.tsx:341 kept back only fill/color/wrap, and the panel's list (Panel/index.tsx:587-596) was eight names written by hand. styled-components forwards everything else to a component target, and both lists had gone stale as props were added.
  • Fix: TReqoreStylePropKeys<TProps, TTargetProps> + listReqoreStyleProps<…>({ … }) (src/helpers/styled.ts). Each list is a Record of every key of the component's prop interface that its element does not take, so a new prop left out of the list fails the build.
  • Sweep of every as-capable component: the same leak was in Tag, Paragraph, the TextEffect base (block/inline), the Tabs list item (theme, active, size, …), the Textarea rendered as Slate's editable, the Icon's animated.span wrapper and the Breadcrumbs item (interactive). ReqoreTooltipComponent no longer hands tooltip to any component, and the Timeline item no longer writes disabled onto its <li> (it sets aria-disabled).

4. A description takes block content (from #709, and ae99313)

ReqoreEntityRow, ReqoreSeverityRow, ReqoreCallout, ReqoreFeatureCard, ReqoreTier (classic and #696's modern appearance) and ReqoreTestimonial (quote) drew the description in a <p>. Consumers pass ReqoreP/ReqoreControlGroup there (qorus-ide: ~14 call sites), so React warned (validateDOMNesting) and the browser's parser moved the blocks out of the paragraph.

  • Fix: a description that is text (a string or a number) is still a <p>, exactly as before, so consumer CSS aimed at p still matches. Anything else is a ReqoreP as='div', which gets exactly the classes the <p> gets (getReqoreTextElement, ae99313). A callout's children are drawn in a div too. The root of each of these components (the effect's span, display: flex/grid) is a div, and it keeps back the control-group flags and its own styling props (disabled, radiusSize, inheritCustomTheme, …). The prop docs and COMPONENTS.md say a description takes block content.
  • ReqoreButton's and ReqoreCheckbox's descriptions stay inline content: they sit inside a <button>/<label>.

5. A styling prop that is also an HTML attribute name reaches only the elements it is an attribute of (7f6bd6c)

size='normal' (a Reqore size) was written as size="normal" onto every div/span it was handed: control groups, panel titles and contents, tag contents, table rows and cells, accordions, timelines, statistics, progress bars, …. Other props leaked the same way:

  • a checkbox's checked reached its divs;
  • table cells' and collection items' selected/disabled reached theirs;
  • a rating's readOnly reached its div;
  • a dropdown button got placeholder.

React is silent about all of these.

  • Root cause: is-prop-valid, which styled-components filters a DOM tag's props with, only asks whether a name is an attribute of any element.
  • Fix at that root, not in ~60 styled components:
    • REQORE_AMBIGUOUS_PROP_ELEMENTS maps each such name (size, width, height, wrap, color, fill, opacity, direction, offset, spacing, disabled, readOnly, checked, selected, placeholder, label, type, value) to the elements it is an attribute of. The element names are typed against React's intrinsic elements, and SVG keeps its presentation attributes.
    • omitStyleProps lets such a name through to a tag only when it belongs there.
    • src/helpers/styled.ts now exports Reqore's styled: styled-components' own styled, with every styled component it makes filtered this way. All of src imports it, and an ESLint rule forbids the default import of styled-components, so a new styled component cannot leak these props again.
    • The rich-text editor no longer hands Slate disabled (Slate is disabled through readOnly).
  • Sweep: all 1125 stories were rendered and I read what React handed each element. Before the fix: 52k size, 7.9k selected, 6.6k disabled, 534 checked, 95 readOnly, 4 placeholder. After: nothing, except Slate's own internal zindex on its editable.

Tests for 3–5

  • __tests__/domPropLeaks.test.tsx: 16 new cases, and the detector now also flags size/checked/selected/placeholder on elements that don't take them.
    • New cases: a button/breadcrumb/menu item/tag/paragraph/panel/tabs item rendered as a router link; a resizable transparent panel; resizable panels in a stack group; a textarea as an editor; animated icons; 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.
    • Also: negative/positive checks on each style list.
  • __tests__/helpers-styled.test.tsx: the element table, case by case, and Reqore's styled (keeps size off a div while the styles still read it, keeps it on an input, uses the element given as as, forwards everything to a component).
  • __tests__/blockDescriptions.test.tsx: every component's description given block content (a div, no validateDOMNesting warning), a callout's block children, and plain text still drawn as a <p>.
  • __tests__/domPropLeaks.test.tsx (ae99313): a textarea in a filling stack group; the five surfaces in a filling stack group, and rendered as a router link with every surface prop set; <ReqorePanel as='fieldset' disabled> disables its fields, <ReqoreTag as='button' disabled> is a disabled button.
  • Each new test fails without its fix. Unit suite: 109 files, 1438 tests pass; typecheck (test + prod) and lint pass.
  • Visual: I captured all 75 story files (1110 snapshots) locally on this branch and on 27dad97. Every story file passed on both. 1090 of 1110 snapshots are byte-identical. Of the 20 that differ, a second branch run reproduced the 27dad97 image for 16. ErrorBoundary Show Details prints a timestamped URL. Checkbox Basic lost a loading image once and showed it on the next two runs. The other 14 changed only by 1–2 colour levels on a few anti-aliased pixels. Four stayed different after re-running: DatePicker With Effect, MultiSelect Empty, Panel Description Expands On Click and SegmentedControl Item Props. They differ by at most 1–2 colour levels on 3–71 anti-aliased pixels (rounded-corner edges and the clear button's ×). That is invisible, and SegmentedControl Item Props also changes between two runs of 27dad97. No layout, size or colour moved anywhere: the CSS is unchanged, and only DOM attributes went away.

6. A table is scrolled sideways from its header too; dragToScroll pulls it with the mouse (from #710: eaff15b, c432b42, 30a6e81)

From David's review of qorus#646.

Symptom. On a phone, a swipe on a wide table's header did not scroll the table, though the same swipe on its rows did. With the mouse, a table wider than its frame could only be scrolled by its scrollbar or Shift+wheel; the IDE had built its own drag-to-scroll around reqore tables, which could not reach the header either.

Root cause. The header is scrolled with the body (the body syncs headerRef.scrollLeft), but it is not a scroller itself (overflow: hidden). The browser has nothing to pan under a finger there, and the header forwarded only the wheel (deltaX) to the body.

Fix

  • Header swipes: the header takes sideways touch swipes (touch-action: pan-y, so the page still pans up and down) and scrolls the body with them.
  • New dragToScroll prop (default false): a mouse or pen presses and pulls the table by its body or its header.
    • A press that does not move is still a click: a header cell keeps opening its options and runs its column's onClick.
    • The click that ends a real drag is not delivered.
    • A press on a text field is the field's.
  • One gesture implementation: the gesture is ReqoreFadeScroller's dragToScroll, moved into a shared useDragToScroll hook that both components use. The hook rebinds when the element it scrolls appears after mount, since a table renders no body until it has rows.

Also in this PR

  • Shift with a plain wheel over the header scrolls the table sideways, as it does over the body. Before, only a wheel's deltaX was forwarded.
  • With dragToScroll the body shows a grab cursor, and grabbing with no text selection while it is pulled.
  • A finger's swipe kept going: a real swipe on a phone moved the table about 5px and stopped. A touch pointer is captured by the element it lands on; taking the capture for the header made that element report lostpointercapture, and the gesture ended on it. Now only the capture the gesture itself took ends it. The dispatched-event tests could not see this; it was found with real touch events, and a test now covers it.

Tests

  • __tests__/table-drag-to-scroll.test.tsx: 9 tests. 4 of the first 6 fail against develop's Table: the touch swipe on the header, the header's touch-action, and mouse pulls by the header and by the body.
  • The FadeScroller drag tests are unchanged and pass.
  • New story Collections/Table · Drag To Scroll: it pulls the table by its header with the mouse and with a finger, and checks the header follows the body.
  • yarn precheck passes.

Docs: COMPONENTS.md, ReqoreTable row.

7. Floating actions stay while the pointer crosses to them (from #713: c701745)

From David's review of qorus#646.

Symptom. In reqraft's expression builder, hovering an expression and clicking its floating Remove (the trash can) did nothing visible. The click switched the Visual / Text view underneath instead.

Root cause. floatingActions floats a panel's show: 'hover' actions above its top-right corner. A pointer heading for them leaves the panel through its top edge, usually to the left of the actions, on a diagonal.

  • handleMouseLeave ended the hover unless the pointer went straight into the actions, so they were gone before it reached them.
  • The click then landed on what they had been covering. Verified with a real mouse path on the IDE's Storybook: the element under the pointer at the Remove button's position was the Visual / Text switch.

Fix. While the pointer is in the strip between the panel's top edge and the actions, across the panel's width, the hover stays. It ends when the pointer leaves that strip for anything that is neither the panel nor the actions. This is decided by where the pointer is on each move, with no timer. The same applies when the pointer goes from the actions back towards the panel.

Tests.

  • __tests__/panelFloatingActionsReachable.test.tsx: crossing to the actions (fails without the change), leaving the strip elsewhere, and leaving the panel away from the actions (unchanged).
  • The placement tests still pass. yarn precheck passes.

Docs: COMPONENTS.md, ReqorePanel row.

8. Fixes found reviewing the Qorus IDE's value tabs (qorus#646: 94edea0, d877213, ba2c40f)

  • A popover's controls answer for it as it is now; Escape closes an open list and goes no further (94edea0). passPopoverData handed out isOpen as a closure over the render that passed it. A key typed straight after the click that opened a template list found the list "closed", so typing did not put it away. Escape was heard on the document's bubble phase, after an editor around the field (reqraft's form row, where Escape discards the edit), so one Escape for the list also threw the edit away. The open state is now kept as it changes. Escape is heard in the capture phase and, while a popover is open, closes it and stops there.
  • An item brought into view scrolls its menu, and nothing around it (d877213). ReqoreMenuItem's scrollIntoView scrolled every scrollable ancestor to centre the item, the page's scroll container and the document included. Arrow keys in an editor's completion list moved the page, and in a short view left it blank. Inside a .reqore-menu, the item is now centred in the first scrollable element up to and including the menu. Outside a menu it behaves as before.
  • A field that offers templates lists them when it is come into from the keyboard (ba2c40f). A click or a tap listed them; tabbing in offered nothing. Focus brought by a pointer is left to the click, since opening for it too would close the list again on the click. Focus coming back from the list, or from nowhere, opens nothing. A press counts as bringing the focus only when the field does not have it, and the mark goes with the click that ends every press, so a click on an already-focused field cannot make the next keyboard focus look like a pointer's.

Tests. popoverControlsAnswerNow, menuItemScrollStaysInMenu, templateListOpensOnFocus and the updated menu.test all fail before their fixes. Stories: TextArea › Escape Closes The List Not The Editor Around It (fails before) and RichTextEditor › Typing Straight After The Click Dismisses The List (a guard). Locally: precheck passes (1470 unit tests) and all 1128 stories pass. Version stays 0.78.5: only -pr prereleases of it exist on npm.

9. A drag on a column's resize handle resizes the column and leaves the table where it is (be112f0; found by Foxhoundn in #710's changes)

The header's drag-to-scroll gesture also started on the resize handle, so the table scrolled under the column edge being dragged: on every table with a finger, and with the mouse when dragToScroll is on. useDragToScroll now leaves out anything marked reqore-no-drag-scroll, and the header cell marks its resize handle with it. A consumer can mark its own grab handles the same way; COMPONENTS.md documents this in the ReqoreTable row. Test: table-drag-to-scroll, a drag on a column's resize handle (fails before).

10. Text written without a key puts a field's template list away, as typing does (97f9adb, qorus#646). An on-screen keyboard sends no keydown for a character, only the input it makes. So on a touch device the template list stayed open over what was being written, and over the completion list that a typed @ or $ opens beside it, which then could not be seen. The field's native beforeinput now closes the list for input that writes or deletes text. A composition starting, which writes nothing yet, leaves it open. Tests: popoverControlsAnswerNow, text written without a key (fails before), and input that writes nothing.

11. A click on a chip in a template field neither lists the templates nor scrolls the page (f9512c6, a regression from ba2c40f found in the Qorus IDE with 0.78.5-pr.708.g432b59a)

Symptom: with the cursor already in a rich-text field that offers templates, a click on a chip moved the page ~137px and showed nothing. On 0.78.0 the page stayed put.

Root cause:

  1. The chip takes the focus from the editor for the moment of the press.
  2. ba2c40f's keyboard-entry check only asked where the focus came from, never whether that was inside the field. So it took this move for the author coming into the field and opened the list.
  3. The list's keep-below rule scrolled the page by the list's height.
  4. The chip, handing the focus back to the editor on its click, closed the list about 12 ms later. The page stayed moved.

Fix:

  • Focus that moves within the field is not an entry: the check now asks that the focus came from outside the field (Textarea).
  • Nor is it leaving the field. The blur handler no longer puts the list away when the focus moves to something inside the field, so a click on a chip leaves an open list as it is. This follows the list's intent: the keyboard opens it, a click in the text lists it, and a press inside the field does not put it away (closeOnTargetClick={false}).
  • A click on a chip's body is the chip's (it places the cursor after it), not a click that lists the templates. The list's click listener is on the field around the editor, so it hears the chip's click before the chip's handler can stop it. The rich-text editor therefore says so through the list's onBeforeOpen.
    • Until now that click opened the list and the chip's hand-back closed it within the same click. With the blur no longer closing it, it must not open at all.
    • A click on the chip's remove button or actions lists the templates as before; WithCustomTags relies on that.

Tests:

  • templateListOpensOnFocus, which fails at 432b59a for the first two:
    • a press on a chip with the cursor in the editor lists nothing;
    • an open list stays open through a press on a chip;
    • a click in the text and an entry from the keyboard still list the templates;
    • the remove button still lists them;
    • leaving the field puts the list away.
  • New stories:
    • RichTextEditor › Clicking A Chip Neither Lists Nor Scrolls: a field low in a scroll container; click into it, Escape, click the chip, and the list stays shut and the scroll position does not change. Fails at 432b59a.
    • RichTextEditor › Clicking A Chip Leaves An Open List Open.

12. Merged with develop (0.78.1, #714), and two gesture fixes brought across (f74b521, 79d92e0)

The merge: FadeScroller conflicted. #710 here had moved the row's drag-to-scroll into useDragToScroll, shared with the table. #714 had moved it onto usePointerDrag, the drag a sheet, a modal, a toast and a tier stack share.

  • Develop's version is taken, with this branch's styled import.
  • The table keeps useDragToScroll, because its scrollers appear after mount and its header scrolls another element.

Gesture fixes: two fixes this branch made to the shared gesture had reached only useDragToScroll; 79d92e0 brings them across.

  • A finger's drag keeps going when the element it landed on loses the capture. usePointerDrag ended a gesture on any lostpointercapture for its pointer. A finger is captured by the element it lands on, and the element that drags takes that capture when the drag engages. So a swipe that started on a title or label inside a sheet, a toast or a tier stopped after a few pixels. This is the same bug fix(table): a table is scrolled sideways from its header too; dragToScroll pulls it with the mouse — 0.78.3 #710 found with real touch events on a table's header; now only the capture the gesture took ends it.
  • The row's drag leaves reqore-no-drag-scroll grab handles alone again. As documented, the row now takes the threshold and that list from useDragToScroll.
  • Tests:
    • pointerDragImplicitCapture (fails on develop's hook);
    • fadeScroller-drag, a press on a reqore-no-drag-scroll element (fails on develop's FadeScroller).

Checks for 11–12:

  • Typecheck (test and prod) and lint pass; 120 test files, 1528 tests pass locally.
  • These story files pass, run one at a time: TemplateFieldUX, RichTextEditor, TextArea, Popover, FadeScroller, Table, Drawer, Modal, Notification, InApp, TierGroup, Tier.
  • Rendering the stories of every component feat(Drawer): responsiveLayout — qorus-ide's bottom sheet, moved into the library (0.78.1) #714 touched, plus the rich-text and textarea stories, finds no leaked props except Slate's own zindex.
  • The version stays 0.78.5, above develop's 0.78.1.

13. A responsiveLayout drawer opened on a phone is a sheet the screen's width from its first frame (9ef15cc, #714 code)

Symptom: a floating drawer that became a phone sheet kept right: 10px, so it was 380px wide on a 390px phone.

Root cause:

  • useReqoreMedia returned react-use's useMedia(query, false), which renders false first and only asks the browser in an effect. So the drawer mounted as the caller's side panel for one render. The provider's isMobile, isTablet and isHoverCapable were also wrong for that first render.
  • The enter spring animated the drawer's offset from its edge ([position]: floating ? '10px' : '0px' in getSpringConfig). That value was fixed at the render the drawer entered in, so a later switch to a sheet never removed it.

Fix:

  • useReqoreMedia is now Reqore's own hook. Where it can (CAN_QUERY_MEDIA) it asks matchMedia as its state is created, and an effect keeps the answer current. Its other callers are the provider's three flags, which are now right from the first render.
  • The spring now animates only how far beyond its edge the drawer still is, as a translate along that edge. The offsets are the box's own style, recomputed on every render. Under half a pixel from rest no translate is applied, so a drawer at rest is not drawn on a layer of its own.

Tests:

  • drawerSheetOffsets: a floating drawer that becomes a sheet after it entered sits flush with the screen, and one that enters on a narrow screen does too. Fails before.
  • Story Dialogs/Drawer › Floating Sheet On A Phone (tagged touch, run in the phone project): the box is measured in the commit that mounts it and at each of the next 30 frames, and is a sheet exactly the screen's width with no offset each time. Fails before: the first frame is not a sheet.

Visual:

  • I captured the Drawer and Modal story files locally before and after. Apart from 1–2-level anti-aliasing noise, one snapshot differs: the focus ring of the first title-bar button in Sheet Swipe In Progress, by a sub-pixel. The same button is focused in both, with :focus-visible matching.
  • In CI's Qlip build every existing snapshot is unchanged.

14. DeepScan: the narrow-row stories pass their memoized rows stable props (34553ea)

The three issues DeepScan reported as new at 8faff91/47fde14d were all REACT_INEFFICIENT_PURE_COMPONENT_PROP in stories:

  • EntityRow.stories.tsx:729: an actions array built on every render;
  • SeverityRow.stories.tsx:545 and :663: a leading tag element built on every render.

They are module constants now. DeepScan at 9ef15cc: 0 new issues in both projects.

Checks for 13–14: typecheck (test and prod) and lint pass; 133 test files, 1619 tests pass locally. The Drawer, Modal and Manager story files pass on the desktop project, and Drawer on the phone project.

15. Floating actions and popovers follow what they are placed by when it resizes (87239b9; found in reqraft #137's Qlip build)

Symptom:

  • In reqraft's Expression Can Be Wrapped, the expression's floating Wrap / Remove actions were captured mid-screen, short of the card they belong to. The story is played at 1200px and captured at 1920px.
  • On the build before, the actions also reached 8px past the card's right edge once their content widened.

Root cause:

  • A panel's floating actions are position: fixed, so nothing moves them except a placement.
  • They were placed only when shown, when the actions changed, and on a scroll.
  • So when the window resized, the panel grew or shrank, or the actions' own content changed width (a label, an icon or a font that loads), they stayed where they were.
  • A popover has the same gap for its target. Popper places it again only on a scroll or a window resize, and the popover itself only on its own size. Its mutation observer on the target sees only changes inside the target, and its rect polling covers only the first half second.
  • So a target that grew or shrank by itself left the popover where the target's edge had been. With useTargetWidth, the popover also kept the target's old width.

Fix:

  • The panel places its floating actions again on a window resize, and on a ResizeObserver report for the panel or for the actions, as well as on a scroll.
  • These placements are coalesced to at most one per frame, never inside an observer's callback.
  • When the actions are shown and when they change, they are still placed at once.
  • Once they are hidden or the panel is gone, nothing is listened to or observed, and a frame already requested is cancelled.
  • The popover's ResizeObserver, already one placement per frame for its own size, now also observes its target.

Tests:

  • panelFloatingActionsPlacement, all seven new cases failing before the fix:
    • a window resize after the actions are shown;
    • a panel that resizes with no scroll and no window resize;
    • actions whose content widens, which keep their right edge on the panel's;
    • one placement per frame, however many reports arrive;
    • nothing placed or observed once the actions are hidden;
    • nothing placed after the panel is gone;
    • a window resize still followed where there is no ResizeObserver.
  • The existing case, actions shown again after the panel's top was covered, now places on the frame after a scroll.
  • popover-resize: a target that changes size is observed, and re-places the popover at the next frame. Fails before.
  • Story Layout/Panel › Floating Actions Follow Their Panel: hovers a panel, narrows it, then widens one of its actions. Neither change re-renders the panel. The actions must end at the panel's top-right corner each time. Fails before: 169px off once the panel narrows.
  • Story Other/Popover › Follows A Target That Resizes: a popover below its target's right end. The target narrows through its container, with nothing changing inside it, and the popover must follow. Fails before: 240px off.
  • The Panel (60), Popover (26), Collection (24), Dropdown (30), Menu (16) and Select (15) story files pass.

Docs: COMPONENTS.md, the ReqorePanel and ReqorePopover rows.

Checks for 15:

  • Typecheck (test and prod) and lint pass, and 133 test files with 1627 tests pass locally and in CI. CI's 79 story files pass.
  • DeepScan at 87239b9: 0 new issues in both projects.
  • Qlip build 20261014-142132: every existing snapshot is unchanged, including all the floating-actions, popover, tooltip, dropdown and collection stories.
  • The two new stories are pending for a baseline. Their captures show the actions on the panel's corner and the popover on its target's right end, both after the play's resize.

16. A tap opens no hover; only a pointer that hovers does (d45a15b; the reqore side of reqraft #137's fix, qorus#646)

Symptom:

  • A tap is followed by mouse events the browser emulates (mouseenter, mouseover, mousemove), and nothing in them tells them from a mouse's.
  • So whatever opened on them opened at a tap too:
    • a hover tooltip, until the tap's click closed it. On a touch laptop, whose mouse makes its tooltips a hover's, every tooltip a finger touched flashed.
    • a hoverStay tooltip, which stayed open over what the finger had pressed;
    • a panel's floating actions;
    • a notification's pause;
    • a rating's preview, shown over a value the tap had just cleared;
    • a nav rail's reveal and labels;
    • a truncated label's marquee.
  • Each lasted until the next tap elsewhere.

Fix:

  • 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.
  • A hover gate (createHoverGate / useHoverGate / watchHoverGate in src/helpers/hoveringPointer.ts, exported) notes the pointer of those pointer events. The mouse event after them asks isHover(), which reads what was noted once. A mouse, or a pen held above the screen (isHoveringPointer), is a hover; a finger is not.
  • The decision is per event, not per device, so a touch laptop's mouse still hovers.
  • A mouse event no pointer event came before is a mouse's, so the many tests that fire mouseenter alone, reqore's and its consumers', still open their tooltips.
  • Applied to: Popover (hover, hoverStay, keepOpenOnHover, and so every tooltip, in tables, menus, dropdowns and collection items alike), Panel floatingActions, Notification pauseOnHover, Rating's preview, NavRail idleReveal / showLabels: 'hover', and the marquee.
  • Click- and focus-opened popovers (touch-first tooltips are click-opened), closing, and isHoverCapable are as they were.
  • A notification's leave now releases only a hold a hover made: a tap's leave had restarted its running timer at the short beat a hover's leave leaves.
  • reqraft has its own isHoveringPointer (Progress on Storybook improvements #137) and can use reqore's once a release ships it; Progress on Storybook improvements #137 is not pinned to this prerelease.

Tests:

  • hoverIsAPointerThatHovers:
    • isHoveringPointer and the gate (read once; no pointer counts as a mouse);
    • a hover popover opens for a mouse, a hovering pen, and a bare mouseenter; it does not open at a finger's tap, for hover, hoverStay or keepOpenOnHover, and opens for a mouse after a tap;
    • a click-opened popover still opens at a tap, and a focus-opened one on focus;
    • a panel's floating actions float for a mouse, and not at a tap.
  • Finger cases in notifications (also: a leave no hover came before restarts no timer), Rating, NavRail and marqueeOnHover.
  • Nine cases fail before the fix; the mouse, pen and bare mouseenter ones pass both ways.
  • Story Other/Popover › Tap Opens No Hover Tooltip (phone project): taps a plain tooltip's button and a hoverStay one's with a real finger (CDP touch). Each is pressed; no tooltip is ever added. It fails before the fix: the hoverStay tooltip opened, 2 of 2 runs.
  • Story Other/Popover › Hover Tooltip Opens For A Mouse: the browser's own mouse, moved by Playwright, opens the tooltip. The play ends with the mouse moved off the page (withMouse, src/stories/utils/mouse.ts), so it hovers nothing in later stories.

Checks:

  • Typecheck (test and prod) and lint pass, and 134 test files with 1651 tests pass.
  • Locally: Popover (27), Panel (60), Notifications (7), Rating (11), NavRail (34), Collection (24), Menu (16), Dropdown (30) and Table (69) story files, and the whole phone project.
  • CI at d45a15b is green: 80 story files, 1191 stories.
  • Qlip build 20261014-202235: no existing snapshot changed; the two new stories are pending for a baseline.

Docs: COMPONENTS.md, "A hover is a pointer that hovers".

17. Two fixes the IDE's story run found (03ddb5d, d11a941; pushed with the qorus#646 contrast work, but not part of it)

  • Paging: two next() calls from one render at the page before the last left currentPage undefined. The collection showed nothing while still offering "Load more".
    • Fix: useReqorePaging keeps its own page state, always within the list. react-use-pagination-hook is removed.
    • Fix: the auto-load observer is disconnected when it is recreated, and it always calls the latest next.
  • useCloneThroughFragments: it kept counting children across renders that reused its memoized clone. A stacked ControlGroup that re-rendered on its own (for example on resize) lost its rounded ends.

The contrast work moved to #717 (7279d2e)

At the front-end lead's request, approved by David, the qorus#646 contrast work (text and icons picked against what they are drawn on, the story run's contrast gate with axe-core, and the reduced-motion support that came with it) is reviewed and released in #717, which is based on this branch. 7279d2e takes it out of this one: it reverts 85f6099, 3edf31d, 76de251, 98c46c3, 5960623, b5f7a66, de9b9cd, 2dcd352, bd20c18, 2b84953, a7194b5 and 0fba3d8. The tree is exactly the one before 85f6099 plus 6e203e6 (the hover fix), so nothing else in this PR changed. b5f7a66 (the DeepScan fix) went with them: every line it changed was written by the contrast commits.

Checks at 7279d2e: typecheck and lint pass; 135 test files with 1658 unit tests pass; all 76 story files pass locally (1191 stories); CI is green and DeepScan reports 0 new issues.

Review (Foxhoundn, 2026-10-07)

Point Where it is addressed
#708 1: check the focus once per trigger, not on every re-run 9e025c6 (focusCheckedFor ref)
#708 2: the Escape test 9e025c6, popoverFocusOpens.test.tsx
#708 3: version after #696 7c9c127: the PR is now 0.78.5 (see above)
#709 1: version after #696 and #708 7c9c127: one version, 0.78.5
#709 2: the panel's media* props break tsc on the merged tree 9180338
#709 3: the modern tier's description 9180338 (+ a modern case in blockDescriptions.test.tsx)
#709 4: the textarea doesn't keep back the control-group flags ae99313
#709 5: the five surfaces get fill/size in a fill group ae99313 (and 7f6bd6c: size stays off every div/span)
#709 6: callout children with blocks sit in a span ae99313: both uses are a div
#709 7: plain-text descriptions change from p to div ae99313: text stays a <p>; only other content is a div
#709 8: document the behaviour change; as another tag drops its real attribute ae99313: a state attribute (disabled, readOnly, checked, selected) reaches the element a component is rendered as when that element has it; COMPONENTS.md "Props and the DOM" lists what left the DOM
#709 9: COMPONENTS.md for block descriptions ae99313
#709 tests still missing ae99313: textarea in a group, surfaces in a fill group, callout block children, panel as a fieldset and tag as a button; 9180338: the modern tier

Checks: typecheck (test and prod) and lint pass; 111 test files, 1458 tests pass locally. I rendered all 1126 stories in jsdom and read what React handed each element: nothing leaks except Slate's own internal zindex.

Visual. I captured all 75 story files (1111 snapshots) on develop (b1a555a) and on this branch, one file at a time, headless.

🤖 Generated with Claude Code

…mounts — 0.77.9

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.
@davidnich
davidnich requested a review from Foxhoundn as a code owner October 7, 2026 08:25
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

📦 Prerelease published for this PR:

@qoretechnologies/reqore@0.78.5-pr.708.g26327a4

Install it in a consumer (updates package.json + lockfile):

yarn add @qoretechnologies/reqore@0.78.5-pr.708.g26327a4

Published under the pr dist-tag — latest/beta are untouched. Repoint consumers to the released version once this PR merges.

@qlip-qore-technologies

qlip-qore-technologies Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

qlip visual regression

🔍 7 changed, 0 new of 1191 snapshots.
✅ All changes approved.

Review build 20261015-124747 in the dashboard →

… committed, so a key typed after it is kept

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.
@davidnich davidnich changed the title fix(popover): a list opened by focus opens for a field focused as it mounts — 0.77.9 fix: a focus list opens for a field focused as it mounts; a rich-text value is synced as it is committed — 0.77.9 Oct 7, 2026
@Foxhoundn

Copy link
Copy Markdown
Member

Review: one regression to fix before merging

The fix is right for a field focused as it mounts (qorus#646), but the new check runs every time the listener effect re-runs, not only when the listener is first attached. The effect depends on open, and open depends on isOpen, so the effect re-runs whenever the list opens or closes. When the user closes the list while the cursor is still in the field (Escape, or a click on the field with closeOnTargetClick), the effect re-runs, the field still has the focus, and open() reopens the list at once. Escape no longer closes a focus popover. The test below passes on develop and fails on this branch.

What needs to be done

  1. Check the focus once per trigger, not on every re-run of the effect, e.g. with a ref that remembers which trigger was checked (Popover/index.tsx L635-L637):

    @@ -216,6 +216,8 @@
           const [isOpen, setIsOpen] = React.useState(false);
    +      /* The trigger whose focus at attach time was already checked: once per trigger, not on every re-run. */
    +      const focusCheckedFor = useRef<HTMLElement | null>(null);
           const timeoutRef = useRef<number | null>(null);
    @@ -632,8 +634,12 @@
    -            if (handler === 'focus' && componentRef.contains(document.activeElement)) {
    -              open();
    +            if (handler === 'focus' && focusCheckedFor.current !== componentRef) {
    +              focusCheckedFor.current = componentRef;
    +
    +              if (componentRef.contains(document.activeElement)) {
    +                open();
    +              }
                 }
  2. Add the Escape case to __tests__/popoverFocusOpens.test.tsx. It fails on this branch and passes with the change above:

    it('stays closed after Escape while the field keeps the focus', () => {
      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);
    });

    With both changes, the four tests in this file and the 12 popover, dropdown and select test files (132 tests) pass.

  3. Version: feat: redesign knobs — fonts (buttons/inputs), transparent layout, Statistic valueAs/countUp, Tier price detail + modern appearance, ReqoreTierGroup (3D stack on phones), Panel media, FeatureCard actions, Timeline states, SegmentedControl item props — 0.78.0 #696 goes in first as 0.78.0, so this becomes 0.78.1. The package.json version line will conflict with feat: redesign knobs — fonts (buttons/inputs), transparent layout, Statistic valueAs/countUp, Tier price detail + modern appearance, ReqoreTierGroup (3D stack on phones), Panel media, FeatureCard actions, Timeline states, SegmentedControl item props — 0.78.0 #696 anyway. The description's "0.77.8 is taken by feat: redesign knobs — fonts (buttons/inputs), transparent layout, Statistic valueAs/countUp, Tier price detail + modern appearance, ReqoreTierGroup (3D stack on phones), Panel media, FeatureCard actions, Timeline states, SegmentedControl item props — 0.78.0 #696" needs the same update.

…s a focus list again — 0.78.1

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.
@davidnich davidnich changed the title fix: a focus list opens for a field focused as it mounts; a rich-text value is synced as it is committed — 0.77.9 fix: a focus list opens for a field focused as it mounts; a rich-text value is synced as it is committed — 0.78.1 Oct 7, 2026
@davidnich

Copy link
Copy Markdown
Member Author

Thanks, you're right, and the reasoning checks out. The listener effect depends on open, which depends on isOpen, so it re-runs whenever the list opens or closes. The attach-time focus check then reopened a list that Escape had just closed while the field kept the focus. I had seen Escape not close it in jsdom while writing the tests and took it for a test-harness quirk instead of this.

Changed in 9e025c6:

…croll pulls it with the mouse — 0.78.3

From David's review of qorus#646: on a phone, a swipe on a wide table's
header did not scroll it, while the same swipe on its rows did. The header
is scrolled WITH the body (the body syncs it) but is not a scroller of its
own (overflow hidden), so the browser has nothing to pan, and nothing
forwarded the swipe; only the wheel was forwarded.

- The header now takes sideways touch swipes (touch-action: pan-y; the page
  still pans up and down) and scrolls the body with them.
- New `dragToScroll` (default false): a mouse or pen presses and pulls the
  table by its body or its header. A press that does not move is still a
  click (a header cell opens its options), the click that ends a real drag
  is not delivered, and a press on a text field is the field's.
- The gesture is FadeScroller's dragToScroll, moved into a shared
  useDragToScroll hook that both use, so a fix to it reaches both. It
  rebinds when the element it scrolls appears after mount (a table renders
  no body until it has rows).

Tests: table-drag-to-scroll (4 of 6 fail on develop), the FadeScroller
drag tests unchanged and passing; story Collections/Table · Drag To
Scroll; COMPONENTS.md.
…e-header-scrolls-the-table: stacked on #708

# Conflicts:
#	package.json
… pullable body shows a grab cursor

The header forwarded only a wheel's deltaX to the body, so a plain wheel
with Shift held did nothing over it. With dragToScroll the body shows a
grab cursor, and grabbing with no text selection while pulled - what a
consumer otherwise had to style through reqore's class names.
…e's styling props

ReqoreButton rendered `as={Link}` handed the router link every styling
prop it had: `fluid`, `compact`, `flat`, `animate`, `maxWidth`,
`alignSelf`, `tooltip`, the stack-position flags a ControlGroup clones
onto its children, ... and the link wrote them onto its `<a>`. A
resizable, transparent ReqorePanel handed re-resizable `transparent`,
which it spread onto its wrapper div.

Both come from the same gap: a styled component's `shouldForwardProp`
named a few props by hand (Button: fill/color/wrap; Panel: eight), and
a component target receives every prop not named. The hand lists went
stale as props were added.

The styling props of Button, Panel, Tag and Textarea are now listed by
listReqoreStyleProps from a record typed against the component's prop
interface minus what its element takes (TReqoreStylePropKeys), so a new
prop that is not added fails the build instead of leaking. Panel keeps
re-resizable's own props (`size`, `enable`, ...) forwarded.

The sweep of every `as`-capable component found the same leak in Tag
(asBadge, labelKey, size, ...), Paragraph (`_size`, `block`), the
TextEffect base (`block`, `inline`), the Tabs list item (`theme`,
`active`, `size`, ...), the Textarea given Slate's editable (`flat`,
`minimal`, `rounded`, `_size`), the Icon's animated wrapper (`size`,
control-group flags) and the Breadcrumbs item (`interactive`). The
tooltip component no longer hands `tooltip` to any component; the
Timeline item no longer writes `disabled` onto its `li` (it sets
`aria-disabled`), nor the Textarea wrapper `width`/`height` onto its
div.
ReqoreEntityRow, ReqoreSeverityRow, ReqoreCallout, ReqoreFeatureCard and
ReqoreTier drew `description` (and ReqoreTestimonial its `quote`) in a
ReqoreP, a `<p>`. Consumers pass paragraphs and control groups there,
which a `<p>` cannot hold: React warned (validateDOMNesting) and the
browser's parser closes the paragraph at the first block, moving the
content out of it and its styling.

The description is now a ReqoreP rendered as a `div`: the same styled
classes, so a plain-text description looks exactly as before. The root
of each of these components, the effect's `span`, is rendered as a
`div` too, since a span may not hold blocks either. The prop docs say
that a description takes block content.
…r render

DeepScan REACT_INEFFICIENT_PURE_COMPONENT_PROP: the inline style handed to
the memoized ReqoreP defeated its memoization. It was there before; the
description's element moving to its own lines made DeepScan report it as new.
…t landed on losing its capture

A touch pointer is captured by the element it lands on (implicit touch
capture). Taking the capture for the header moved it away, the cell
reported lostpointercapture, and the window listener ended the gesture:
a real swipe on a phone moved the table 5px. Only the capture the gesture
itself took ends it. Found driving the IDE's review on a phone with real
touch events; the dispatched-event tests could not see it.
…description takes block content

#696 added the panel's media props (media, mediaAlt, mediaPosition,
mediaAspectRatio, mediaProps) and the modern Tier appearance. The panel's
compiler-checked style list caught the props: they are now in it, so
they do not reach a component the panel is rendered as. The modern
tier drew its description in a ReqoreP <p>; it is a div like the
classic tier's, with its effect memoized for the memoized paragraph.
… 0.78.4

From David's review of qorus#646: hovering an expression in reqraft's
builder and clicking its floating Remove did nothing - the click switched
the Visual / Text view underneath instead.

The actions float above the panel's top-right corner, so a pointer heading
for them leaves the panel through its top edge, usually left of the
actions, on a diagonal. The hover ended as it left the panel, the actions
went, and the click landed on what they had covered. While the pointer is
in the strip between the panel's top edge and the actions (across the
panel's width), the hover now stays; it ends when the pointer leaves the
strip for anything that is neither the panel nor the actions. Decided by
where the pointer is on each move, with no timer.

Test: panelFloatingActionsReachable (the crossing fails without the
change). Stacked on #710 (and #708).
…es only the elements it is an attribute of

`size='normal'` (a Reqore size) was written as `size="normal"` onto every
div and span it was handed to — the control group, panel title and
content, tag content, table rows and cells, accordion, timeline,
statistic, progress, ... A checkbox's `checked` reached its divs, a
table cell's and collection item's `selected` and `disabled` theirs, a
rating's and a checkbox's `readOnly` theirs, a dropdown button its
`placeholder`. React says nothing about these: each is an HTML attribute
name, so is-prop-valid, which styled-components filters a DOM tag's
props with, lets it through — it only asks whether a name is an
attribute of ANY element.

The root cause is that filter being element-blind, so the fix is made
there rather than in each of the ~60 styled components involved:

- REQORE_AMBIGUOUS_PROP_ELEMENTS maps each Reqore prop name that is also
  an attribute name (size, width, height, wrap, color, fill, opacity,
  direction, offset, spacing, disabled, readOnly, checked, selected,
  placeholder, label, type, value) to the elements it is an attribute
  of, typed against React's intrinsic elements. omitStyleProps lets
  such a name through to a DOM tag only when the tag is one of them
  (SVG elements keep their presentation attributes).
- src/helpers/styled.ts exports Reqore's `styled`: styled-components'
  own, with every styled component it makes filtered by
  omitStyleProps(). All of src imports it, and an ESLint rule forbids
  the default import of styled-components, so a new styled component
  cannot leak these again. A component's own withConfig filter is built
  with omitStyleProps and keeps the element check.

Rendering all 1125 stories and reading what React handed each element
now finds nothing but Slate's own `zindex` on its editable. The rich
text editor no longer hands Slate `disabled` either (Slate is disabled
through `readOnly`), which it wrote onto its div.
@davidnich davidnich changed the title fix: a focus list opens for a field focused as it mounts; a rich-text value is synced as it is committed — 0.78.1 fix: a focus list opens for a field focused as it mounts; a rich-text value is synced as it is committed; no styling prop reaches the DOM; descriptions take block content — 0.78.1 Oct 7, 2026
davidnich and others added 8 commits October 14, 2026 16:12
…re placed by when it resizes

A panel's floating actions are fixed to the screen and 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 own content widened
left them where they had been: mid-screen once the window widened (reqraft's
"Expression Can Be Wrapped", played at 1200px and captured at 1920px), or
reaching past the panel's right edge once they widened. They are now placed
again on a window resize and on a ResizeObserver report for the panel or for
the actions, as well as on a scroll, at most once per frame; nothing is
observed or listened to once they are hidden or the panel is gone.

An open popover is placed again when its target changes size, at the next
frame, as it already was when it changed size itself: Popper places it only
on a scroll or a window resize, and the mutation observer on its target sees
only changes inside it.
…pens no hover; only a pointer that hovers does

A tap is followed by the mouse events a browser emulates - mouseenter,
mouseover, mousemove - with nothing in them to tell them from a mouse's, so
whatever opened on them opened at a tap: a hover's tooltip until the tap's
click closed it (on a touch laptop, whose mouse is its primary pointer), a
hoverStay tooltip for good, a panel's floating actions, a notification's
pause, a rating's preview (over a value the tap had just cleared), a nav
rail's reveal and labels, a truncated label's marquee - each 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. A hover gate
(createHoverGate / useHoverGate / watchHoverGate) notes the pointer of the
pointer events and the mouse event after them asks isHover(), which reads
it once: a mouse, or a pen held above the screen (isHoveringPointer) is a
hover, a finger is not. Decided per event, not per device, so a touch
laptop's mouse still hovers. A mouse event no pointer event came before is
a mouse's, so a test that fires mouseenter alone still opens a tooltip.
Click- and focus-opened popovers, closing, and isHoverCapable are as they
were. A notification's leave releases only a hold a hover made: a tap's
leave restarted its running timer at the beat a hover leaves.
…d empties itself

Two next() calls from one render at the page before the last - two
auto-load observers answering one scroll - took the page past the last
one: the page read as undefined, the collection showed no rows and still
offered "Load more" and "Load all" (found by the Qorus IDE's story run,
qorus#646: App Connections Manager, under load).

- useReqorePaging keeps the page in its own state, always within the
  list: a step reads the page it is taken from (a functional update) and
  stops at either end, and setPage with a page outside the list opens the
  nearest one (it threw). react-use-pagination-hook, whose window index
  went past the end, is no longer used and is removed.
- Paging's auto-load-more observer is disconnected when it is made again
  (every observer made stayed connected and answered every scroll), and
  calls the next() of the render it fires in, not the one it was made in.

Tests that fail before the fix: useReqorePaging never goes past the last
page however many steps one render takes, and takes a page outside the
list as the nearest one; a Pagination with auto load more loads one page
each time its end comes into view (it loaded two after the observer was
made again).
… and last child

useCloneThroughFragments kept its count of the children with the clone
it memoizes, so a render that reused the clone - a control group's own
state on a resize, or a context it reads - went on counting from where
the last render stopped: a stacked group's first child was numbered the
fourth and lost its rounded end, its last lost the other. The count
starts from the first child on every call. (Found when the contrast work
had a group read the theme, qorus#646.)

Test that fails before the fix: the hook numbers the children from the
first on every render, through fragments and past a null.
…wn on (qorus#646)

David's review found black text on a dark gray background: the muted
intent (#f2f2f230) picked its text from the near-white it is made of, but
is drawn at 19% over a dark page. Text and icon colors are now picked
against what is actually drawn, to WCAG AA: 4.5:1 for text, 3:1 for icons
and large text.

- getReadableColorFrom / getReadableColor choose the candidate with the
  higher contrast (not a luminance threshold), draw a translucent
  background over what is under it (the theme it was made from), and give
  up the dimmed #f2f2f2 / #0d0d0d for pure white / black where the dimmed
  one is under 4.5:1 (#f2f2f2 on the info blue is 4.2:1).
- New: getDrawnColor, getContrastRatio (unrounded: polished rounds 4.495
  up to 4.5), getReadableColorOn (one color for every color of a
  gradient), getReadableAlpha / getReadableFadedColor (fade only as far
  as it reads), getReadableAccentColor(On) (keep the hue, move toward the
  side that can read on every background), getReadableIntentColor,
  getReadableThemeColor, getReadableIconLook, getReadableTextOn,
  getReadableGroupOpacity (how far a group drawn as a whole can fade and
  every mark in it still read), getThemeSurface(s), fadeColor (a
  translucent color at a panel's opacity stays translucent; rgba()
  replaced its alpha).
- A one-color gradient (buildGradientColorsFromString) keeps its text
  readable at both ends, and a step lighter as it is drawn hovered or
  pressed: info's light end was 3.25:1 with white.
- ReqoreSurfaceProvider / useReqoreSurface (exported, with
  TReqoreSurface): what a component paints - one color, or each color of
  its gradient - is handed to what it holds, which reads against it
  rather than the page. An app can tell Reqore about a background of its
  own the same way.

Tests that fail before: __tests__/helpers/readableColors.test.tsx (the
helpers missing, black picked for the muted intent, #f2f2f2 kept on the
info blue, info's gradient 3.86:1 a step lighter) and
__tests__/surfaceContext.test.tsx.
…orus#646)

Each component picks its text and icon colors against the background it
actually draws (a minimal wash, a translucent intent, a gradient, a
lightened header), hands that background to what it holds, and fades
secondary text and icons only as far as they stay readable (4.5:1 text,
3:1 icons and large text; WCAG 1.4.3 / 1.4.11).

- Button: text, description and icons against its drawn fill at rest,
  hovered and active; a minimal or transparent button keeps its color in
  a readable shade; badges read on the button, every color of it.
- Tag: solid, minimal, soft and text tags, against every color they are
  drawn on; the key is darkened as far as its text needs (over a light
  color 30% black left it in the mid-tones, where nothing reads); icons
  and actions; label and key effects read on the tag.
- Input / Textarea / RichTextEditor: text, placeholder, icons and the clear
  button against the field's own wash.
- Span / Paragraph / Heading / Icon: an intent, or a theme color named by
  name, in a shade of it that reads where it is drawn; text drawn in a
  gradient is drawn in colors that read, hovered and pressed too; a text
  glow's letters sit in a halo of the glow and are drawn in the color
  that reads on it (white letters in a white halo inside a red glow were
  3.85:1).
- An effect with several gradients is read as the layers they are drawn
  as, each over the ones under it.
- Panel (content, the title bar over the content's gradient, description,
  title icon, a title rendered as a link), Message, notifications,
  Callout, EntityRow, Statistic, Testimonial, FeatureCard, Accordion,
  Timeline, Table cells, NavRail, Breadcrumbs (the separator too),
  SegmentedControl, CollapsibleContent's reveal button, Rating, Tree,
  StackedBar, DescriptionList, Bubble timestamps: each reads against its
  own surface; one with a gradient hands every color of it to what it
  holds. A ControlGroup or a Menu drawn in a gradient does too, and a
  menu divider's label reads on the menu.
- Checkbox: the box against every color under it; a switch's word on the
  knob reads on the knob and the other on the track (one color for both
  was white on the light info knob, 2.56:1), a gradient track and a knob
  in the effect's colors are read as drawn, and an unset knob is drawn
  faint without fading its icon, which still stands out by 3:1.
- Notifications: the text, icon, close button and pending spinner read on
  the notification as drawn over every color under it; content of the
  caller's own (a tag) is no longer faded with the text under a title; an
  iconColor is drawn in a shade of it that stands out from its tile.
- NavRail: an idle rail rests at idleOpacity, or as little less faded as
  its icons need to stand out by 3:1 - the marks' and the active mark's on
  its fill, faded into the page with the rail (it rested at 0.34: 2.3:1).
- A translucent theme drawn at a panel's opacity stays translucent (the
  muted EmptyState was white on white).
- Breadcrumbs no longer force the bar's text color onto a crumb with its
  own intent (dark text on the warning crumb).
- StackedBar writes each segment in what reads on it (white on a light
  segment was 1.9:1).
- Disabled components that are not form controls say so (aria-disabled):
  Tag, Callout, Panel, Accordion item, EntityRow, Statistic,
  CollapsibleContent, the Checkbox row, Progress, SeverityRow, StackedBar,
  Testimonial, the Textarea wrapper, a disabled row's table cells. WCAG
  exempts disabled controls from contrast; assistive technology is told.

Tests that fail before: __tests__/componentSurfaces.test.tsx (a group or
a menu drawn in a gradient hands its colors on), __tests__/tagColors.test.tsx
(a tag's key darkened as far as it takes, a tag reads on every color it
is drawn on). The story run's contrast gate (the last commit) failed 524
of 1186 story tests before these and the story colors, and none after.
…us#646)

Some stories put text or icons on colors no text color reads on: a
cyan-to-pink badge gradient, a purple-to-teal item, purple-to-pink groups
and menus, a pink message, an orange badge under a darkened key, a light
green backdrop blob, a magenta sphere, dark icon colors on a dark panel, a
red spinner and a red bold leaf on dark gray, a muted wash on a mid-blue
picker. Each keeps the same kind of color in a shade the text reads on,
so the demos show readable choices. The notification stories' busy
backdrop tells the notifications on it what it is drawn in
(ReqoreSurfaceProvider), as an app would for a background of its own,
and its row labels are a shade less faint.
…orus#646)

After each story settles (its transitions and finite animations
finished), in its theme:
- text by axe-core's color-contrast rule (4.5:1, 3:1 when large);
- text on a gradient, which axe cannot decide, against the gradient's
  colors where the text is drawn on it, and text drawn in a gradient
  (background-clip: text) in each of its colors;
- icons (an svg in .reqore-icon) at 3:1 against what they are drawn on
  (WCAG 1.4.11), which axe does not check.
What a mark is drawn on is composed as the browser draws it: each
ancestor's paint over what is under it (a translucent color blended, a
gradient as its colors along the part the mark covers, a fill over a ring
counting alone), an element at an opacity drawn as a whole - its paint
and everything in it - then faded into what is under it, and a floating
layer (a popover, a modal) over the app layout's surface. Disabled
content is exempt (WCAG), as is anything not drawn and decoration the
page marks as such (data-decorative).

Fail-first: the first gated run failed 524 of 1186 story tests in 62 of
75 story files (Button 22/25, Tag 18/22, Table 49/71, Panel 32/59, ...);
with the previous commits every story passes. Unit tests:
__tests__/contrastGate.test.ts (the gradient position, faded group and
settling cases fail on the gate before them). axe-core 4.10.2 is a dev
dependency.

src/components/COMPONENTS.md has a section on how Reqore picks text and
icon colors now: against what is drawn, the surface handed down
(ReqoreSurfaceProvider), the floors on fades, what stays the caller's.
@davidnich

Copy link
Copy Markdown
Member Author

@Foxhoundn: this is the contrast pass for qorus#646. It comes from David's review: "black text on a dark grey background — extremely difficult to read". It changes how Reqore picks text and icon colours everywhere, so qlip will show broad changes. Below is what changed and why each kind of diff is expected. The work is in six commits:

  1. paging fix
  2. control-group clone fix
  3. colours helpers
  4. components
  5. story colours
  6. the story run's contrast gate

1. Colours are picked against what is actually drawn, not against the colour a surface is made from (helpers/colors.ts)

  • getReadableColorFrom and getReadableColor choose by WCAG contrast instead of a luminance threshold.
    • They blend a translucent background over what is under it. The muted intent is #f2f2f230. Its opaque hue is near-white, so it used to pick black text even though it is drawn at 19% over a dark page.
    • Where the dimmed #f2f2f2/#0d0d0d does not reach 4.5:1, they fall back to pure white or black. For example, #f2f2f2 on the info blue is 4.2:1 and white is 4.7:1.
    • qlip: on mid-tone intents (info, warning, the lightened accordion and active colours), text switches between near-white and white. It flips to black where black is the colour that reads.
  • New helpers:
    • getDrawnColor
    • getContrastRatio: unrounded, because polished rounds 4.495 up to 4.5.
    • getReadableColorOn: one colour that reads on every colour of a gradient.
    • getReadableAlpha and getReadableFadedColor: fade only as far as the result stays readable.
    • getReadableAccentColor(On): keeps the hue and moves the lightness toward the side that can read on every background.
    • getReadableIntentColor, getReadableThemeColor, getReadableIconLook, getReadableTextOn
    • getReadableGroupOpacity: how far something drawn as a whole can fade before a mark in it stops reading.
    • getThemeSurface(s), fadeColor
  • One-colour gradients (gradient: 'info') keep their text readable at both ends, including one step lighter when hovered or pressed. qlip: such gradients span a little less on the light side.

2. Surface context (ReqoreSurfaceProvider / useReqoreSurface, exported)

  • Components that paint a background hand what they are drawn in (one colour, or every colour of a gradient) to what they hold. These are:
    • Panel content and bar, Button, Message, Callout, EntityRow, Statistic, Testimonial, FeatureCard
    • the Accordion body, NavRail, the Breadcrumbs bar, Table cells, notifications
    • a ControlGroup or Menu drawn in a gradient
  • So an intent span inside a message, a badge on a button, or a tag in a coloured panel now reads against that, not against the page.
  • An app can describe its own background the same way. The notification stories' busy backdrop now does.

3. Faded or secondary text and icons have a floor: 4.5:1 for text and 3:1 for icons (WCAG 1.4.3 / 1.4.11).

  • Covered: descriptions, metadata, timestamps, uppercase labels, placeholders, control icons (CONTROL_ICON_OPACITY), the clear-input button, checkbox and switch words, breadcrumb separators, and the knob icon of an unset switch.
  • qlip: these get somewhat brighter on dark themes and darker on light themes, but only where they were under the floor.

4. Intent-coloured text and icons use a readable shade of the intent

  • Covered:
    • intent on Span, P, Heading and Icon
    • intent-named effect colours
    • text drawn in a gradient
    • Tree, Rating, Callout, EntityRow, Testimonial, Timeline and FeatureCard markers
    • a notification's iconColor
  • qlip: on dark surfaces these are a lighter shade of the same hue.

5. Behaviour changes worth a look

  • NavRail idle: an idleReveal rail rests at idleOpacity (0.34) or less faded, as far as its icons need to still stand out by 3:1. In the default theme it now rests at about 0.64. The idle stories now assert "dimmed but readable" instead of exactly 0.34.
  • Switch: the word on the knob is read against the knob and the other word against the track. Before, one colour was used for both, which put white text on the light info knob at 2.56:1. An unset knob is now faint (stripes at 20%) while its icon stays readable. The whole knob used to fade, icon included.
  • Tag key: the key is darkened only as far as its text needs. Over a light colour, the fixed 30% black left the key in mid-tones where no text colour reaches 4.5:1.
  • Text glow: the letters sit in a halo of the glow colour and are drawn in the colour that reads on it. Before, white letters sat in a white halo inside the glow.
  • Notifications: content you pass yourself, such as a tag, is no longer faded together with the body text under a title. The close button and the pending spinner read on the notification.

6. Bugs found along the way

  • Paging (from the IDE's story run): two next() calls from one render at the page before the last left currentPage undefined. The collection showed nothing while still offering "Load more".
    • Fix: useReqorePaging now keeps its own page state, always within the list. react-use-pagination-hook is removed.
    • Fix: the auto-load observer is disconnected when it is recreated, and it always calls the latest next.
  • useCloneThroughFragments: it kept counting children across renders that reused its memoized clone. A stacked ControlGroup that re-rendered on its own (for example on resize) lost its rounded ends.
  • A translucent theme drawn at a panel's opacity became opaque, because rgba(color, opacity) replaced the alpha. EmptyState "Muted" was white on white.
  • Breadcrumbs forced the bar's text colour onto every crumb, including a crumb with its own intent.
  • StackedBar wrote white on every segment.
  • Disabled components that are not form controls now say so with aria-disabled: Tag, Callout, Panel, the Accordion item, EntityRow, Statistic, CollapsibleContent, the Checkbox row, Progress, SeverityRow, StackedBar, Testimonial, the Textarea and RichTextEditor wrappers, and the table cells of a disabled row.

7. The story run's contrast gate (src/stories/utils/contrastGate.ts, an afterEach in .storybook/vitest.setup.ts, and axe-core 4.10.2 as a dev dependency). It runs after transitions finish.

  • Text is checked by axe's color-contrast.
  • Text on a gradient is measured against the gradient's colours where the text actually sits.
  • Text drawn in a gradient is measured in each of its colours.
  • Icons are checked at 3:1.
  • The background is composed the way the browser draws it:
    • translucent layers are blended;
    • a fill over a ring counts alone;
    • an element with an opacity is drawn as a whole and then faded, so a mark and what it is on fade together;
    • a floating layer is drawn over the app layout's surface.
  • Exempt: disabled content, content that is not drawn, and decoration marked data-decorative.
  • Fail-first: the first gated run failed 524 of 1186 story tests in 62 of 75 files. With these commits, every story passes.

Some stories put colours together that no text colour can read on. Examples:

  • a cyan→pink badge gradient
  • purple→pink groups and menus
  • a light green backdrop blob
  • dark icons on a dark panel
  • a muted wash on a mid-blue picker

Those stories now use the same kind of colour in a shade the text reads on, so the demos model readable choices.

davidnich pushed a commit to qoretechnologies/toolkit-react that referenced this pull request Oct 15, 2026
…rus#646)

reqraft drew some of its text and icons in the intents themselves and
faded others to fixed opacities. On a dark page many did not read (the
Qorus IDE's contrast gate, qorus#646): the read-first summary's
"Incomplete" / "Ready" 4.0-4.1:1, a danger reason 2.7:1 on the
attention box, a code value's size note 4.1:1, the help mark 2.7:1, a
refused value's reason 3.1:1, a ticket's metadata 2.3:1, a log's
timestamps 2.6:1.

- The read-first summary ("Ready" / "Incomplete", "N need attention ->")
  is drawn in a shade of its intent that reads on the form's background,
  and a shade darker or lighter as the page or panel around it may be;
  the "· 4/6 set" count is as soft as it still reads.
- A read-first row's reasons, its "Moves to ..." note and the required
  asterisk are drawn in a shade of their intent that reads on every
  surface a row is drawn on: the page, the attention and set boxes'
  tints, the optional box, each hovered. Its size and line notes, a
  label's short description, the type tag, an empty value's dash and the
  help mark fade only as far as they still read.
- A refused value in a pick list: only its name is dimmed, as far as it
  still reads; the reason under it is left as reqore draws a row's
  description. Dimming the whole row through `effect` took the reason to
  3.1:1, and reqore 0.77.2 fills a menu row that has an `effect`, so a
  refused row with an intent turned solid.
- Colors reqraft hands reqore itself are picked to read where reqore
  draws them: an invalid option's mark and a log's status dot (reqore
  draws a panel's title icon at 70%), the expression builder's marks
  beside an argument, the A/B test card's quiet buttons on a light theme
  (on their own wash of the intent), a ticket message's links.
- Everywhere else reqraft fades its own text - a ticket's metadata
  (faded twice: by the entity row and again by reqraft), number, notes
  and empty state, a log's timestamps, the reference picker's "nothing
  matches" note and its unmatched kinds, a structured value's field
  names, the schema catalogue's empty cells and notes, the expression
  builder's hints, an option's description - it fades only as far as the
  text still reads (4.5:1; an icon 3:1).
- An image reference's name, shown while its thumbnail loads, was a
  button's default black on the dark chip (1.1:1); it takes the page's
  text color.
- reqraft builds on reqore 0.77.2, which has no readable-color helpers:
  src/helpers/readableColors.ts, useReadableOpacity and
  useReadableIntentShade pick these until reqraft moves to the reqore
  release with qoretechnologies/reqore#708. The tracking UI ships on its
  own and imports nothing of the rest of reqraft, so it has its own copy
  of the one helper it needs (src/tracking/ui/readable.ts).

Tests that fail before: compactToolbarInks and readFirstInks (the
intents and the fixed opacities fail in every theme tried),
readableColors (a shade drawn faded), and the refused-value tests (the
name dimmed, the row not filled, the reason left alone). New tests for
the new helpers: unavailableRowOpacity, tracking/readable.

The story run's contrast gate (it comes with the reqore upgrade) found
795 findings in 31 story files before (373 of 717 story tests failed).
After, 208 are left in 28 files, all drawn by reqore 0.77.2 itself:
intent text and icons, input and tree icons, the switch's words, menu
descriptions, bubble timestamps, soft tags. reqore#708 fixes those.
davidnich and others added 9 commits October 15, 2026 04:33
isHoveringPointer let through only a mouse and a pen held above the
screen, so a pointer event with pointerType '' - which the specification
allows where the type cannot be told, and which user-event sends under
some jsdom setups - was taken for a finger, and its mouseenter opened
nothing: five of qorus-ide's unit tests that open a tooltip with
userEvent.hover (MiniBarChart x2, IncompleteCasesStrip, valueControlAndChips
x2) found none.

Only what cannot hover is turned away now: a finger, and a pen pressed to
the screen. A pointer of no named type, or no pointer type at all, hovers,
as a mouseenter no pointer event came before does.
…city the button draws it at (qorus#646)

A button draws its icons at CONTROL_ICON_OPACITY and picks an icon's own
color (leftIconColor, iconColor) in the shade and at the opacity that
stand out by 3:1. A color given in the icon's own props instead -
leftIconProps={{ intent: 'warning' }}, as the Qorus IDE's readiness rows
give it - was spread over that look: the icon made it readable at full
opacity and the button drew it at 70%, a warning icon at 2.4:1 (814
findings in the IDE's contrast gate).

The button now takes the intent or color in leftIconProps /
rightIconProps as the icon's own color (they still win over leftIconColor,
as they did when spread last) and picks it as it does leftIconColor; the
rest of the props are passed on as before.

Test (fails before): an icon colored through its props is drawn exactly
like one colored through leftIconColor, left and right; that look stands
out by 3:1 at rest and hovered, on the dark theme and a light one; the
other icon props (its size) still reach it.
…ld holds (qorus#646)

The contrast work (85f6099..98c46c3) computes each faded description,
label and icon opacity in the render and hands it to a memo component
(ReqoreIcon, ReqoreP, ReqoreSpan, ReqoreButton) as a new object every
time, and DeepScan reported 21 REACT_INEFFICIENT_PURE_COMPONENT_PROP and one
INSUFFICIENT_NULL_CHECK as new on #708 (FAILURE at 98c46c3, 6e203e6 and
5960623).

Each such effect, style and handler is memoized on what it is made of:
Button, Input and Tag's icon effects; Checkbox's knob and box icons;
Callout, EntityRow and FeatureCard's descriptions (and EntityRow's
metadata, Callout's close handler); RichTextEditor's placeholder and
paragraph styles; SegmentedControl's item refs (one callback per item);
Statistic's label; Timeline's timestamp and content styles. Panel reads
the description effect's color and opacity null-safely, as it reads its
textSize below.
…ks for it (qorus#646)

A user who asks for reduced motion (WCAG 2.3.3) still got every spring
Reqore runs - an input's clear button fading in, a drawer sliding, a
reveal - and every CSS transition. A contrast check measured them on
their way: the clear button, which rests at 4.8:1, at 1.4-2.6:1 in the
IDE's gate (a react-spring fade, which no gate can wait on).

- ReqoreUIProvider turns react-spring's skipAnimation on while the user
  prefers reduced motion (set as it renders, so the first render's
  springs settle too, and again when the preference changes; it turns
  off only what it turned on): every spring settles at once, at its end.
- Its global style takes every CSS transition inside the layout and the
  portal to 0.01ms under the preference (not none: a transition of no
  time sends no transitionend, which some components wait for).
  Keyframe animations (a spinner, a pulse) are as they were.
- The story run's contrast gate asks for reduced motion while it
  measures (a browser command, emulateReducedMotion), so it measures
  what stays on screen, never a frame of a fade. A story tagged
  `reduced-motion` runs under it; the others run with the motion a user
  sees, as their checks of a marquee, a count-up or a sheet expect.

Tests (fail before): under reduced motion an input's clear button is at
its settled opacity on first paint and Reqore's springs skip; the story
Form/Input "Transitions Under Reduced Motion" finds the clear button's
transition at 0.01ms (0.1s without the preference).
…put, a tag or a panel draws it faded (qorus#646)

As with the button (5960623): an input's icons, a tag's and a panel's
title icon are drawn at CONTROL_ICON_OPACITY, and the component picks an
icon's own color and that opacity together so that it stands out by
3:1. A color given in the icon's props - leftIconProps={{ intent }},
iconProps={{ color }} - was spread over the pick: the icon made it
readable at full opacity, and drawn at 70% it fell short (a collection's
filter icon given leftIconProps={{ intent: 'muted' }} at 2.2:1, the IDE's
gate). Each now takes it as the icon's own color (it still wins over the
component's own, as it did when spread last) and passes the rest on. The
split is one helper, splitIconColor (helpers/iconProps), the button uses
it too.

Test (fails before): an input's left and right icon, a tag's and a
panel's title icon colored through their props are drawn exactly like
ones colored through the component's own prop.
…#646)

A link with an intent was written in the shade of it that reads on the
theme's page, wherever it was: on a table cell tinted with the warning
intent 3.5:1, over an entity row's gradient 2.8:1 (the IDE's gate). It
now reads the surface a component around it hands down, as a span does.

Test (fails before): an info link reads 4.5:1 on a warning-tinted cell
and on every color of a gradient, in a shade of blue.
…646)

An active button (a selected menu item, a sidebar section on the active
path) is filled with its color, but its description, its badges and its
icons were picked for the button at rest - its color's wash over the
page: a light blue description 2.0:1 and badge 2.7:1 on the info blue
of the IDE's account menu, a grey description 4.4:1 on a sidebar's
section toggle. The description is now picked on the active fill, the
button hands that fill to what it holds, and its icons are picked on it.
The nav rail reads its active mark as the button now draws it (its icon
on the fill, in the rail's activeEffect), so it still rests only as
faded as that icon stands out by 3:1.

Tests (fail before): an active button with a color writes its
description and draws its badge on its fill; one without a color writes
its description on its fill; at rest the description reads as before.
The NavRail idle stories fail with the old rail and this button.
…at they hold (qorus#646)

An entity row faded its description and metadata as a whole, so what
they hold with a color of its own was faded a second time: a minimal
warning tag in the metadata at 4.0:1, a link in the description at
2.8:1 (the IDE's gate). Their text is now written in the faded color
(the readable floor as before; an opacity a caller gives is still that
floor, a color a caller gives is the caller's), and a tag or a link in
them is drawn as it is.

Test (fails before): both slots are drawn whole in a faded color, a tag
and a link in them are not faded, and a caller's opacity is kept.
…through it (qorus#646)

A gradient effect paints its fill over a lighter ring of the same
gradient (border-box). With an opaque fill the ring is hidden; with a
translucent one - a glow of 'info:lighten:1:0.5' over the page - it
shows through, but the colors a component read its text against left
it out: a statistic's label faded for the blue over the page read 3.6:1
on what was painted (the IDE's support hero). The effect's own readable
colors and getEffectBackgrounds now draw the fill over the ring (one
fill layer stop by stop, as they share the gradient's geometry).

Test (fails before): a statistic's label stands out by 4.5:1 from the
corner of a translucent radial glow as it is painted, ring included.
@Foxhoundn

Copy link
Copy Markdown
Member

Heads-up @davidnich: I'm fixing review findings on top of the current head. Please hold pushes until I comment that it's in; I'll rebase on anything that lands meanwhile. The fixes:

  • Touch: hoverStay overflow menus (Tabs, SegmentedControl, Breadcrumbs), keepOpenOnHover + click, and Panel hover floatingActions open by tap again.
  • Crashes: a colour the helpers can't parse (descriptionEffect.color: 'red', CSS variables, #f00a) no longer throws.
  • Wrong text colours:
    • a transparent or minimal element with its own customTheme picks its text for that colour again;
    • 3- and 4-digit hex parses correctly (#fdc was read as #fdcfdc);
    • theme.breadcrumbs.item.color reaches the crumbs again.
  • Render cost: cached colour helpers, a stable surface provider value, and colours memoised per component (Button, Tag, Effect, Input).
  • Explicit props honoured: idleOpacity, an explicit opacity, explicit hex icon colours, and a caller's own ifLight/ifDark pair.
  • Popovers and drawers provide their own surface.
  • aria-disabled: a consumer's own value is kept.
  • Reduced motion is scoped to Reqore's own springs and elements, not the app's.

The version (these are changed defaults in 0.78.5) is left to you.

…ws only its own changes

The front-end lead asked, and David approved, that the qorus#646 contrast
work - text and icons picked against what they are drawn on, the story
run's contrast gate, and the reduced-motion support that came with it -
be reviewed and released in a pull request of its own: inside this one
it made the Qlip review enormous. This takes it out of this branch; the
same changes are re-applied, as new commits, on the branch of their own
PR (based on this one).

Reverted, newest first: 0fba3d8, a7194b5, 2b84953, bd20c18,
2dcd352, de9b9cd, b5f7a66 (its DeepScan fixes are all to code the
contrast commits added), 5960623, 98c46c3 (axe-core goes with the
gate), 76de251, 3edf31d, 85f6099.

Kept: everything else, among it the paging fix (03ddb5d), the control
group fix (d11a941) and the hover fix (6e203e6), whose files are as
they were. The tree is exactly the one before 85f6099 plus 6e203e6.
… the server's media answer

Review of d45a15b ("a tap opens no hover"; Foxhoundn's review of #708): the hover gate turned away
the mouseenter a browser emulates after a tap, and some popovers opened on nothing else.
- The More menus of Tabs, SegmentedControl and Breadcrumbs (hoverStay) could not be opened on a
  phone, so the tabs, segments and crumbs they hide were unreachable. A hover popover that is a way
  in (the new opensOnTap, which ReqoreDropdown, the tabs' and the segmented control's More menus
  set) opens at the tap's click, at once, and closes as a click popover does. A plain tooltip,
  hoverStay or not, still opens at no tap (TapOpensNoHoverTooltip).
- keepOpenOnHover popovers open at a tap too (their content is there to be reached), and one opened
  by a click or by focus listens to its own event as well as the hover: the IDE's
  `handler = isHoverCapable ? 'hover' : 'click'` with keepOpenOnHover opened nothing at a tap.
- A panel's floatingActions float only where the device can hover; on one that cannot, its
  show: 'hover' actions stay in the bar, shown, so a tap reaches them (IDE collections, reqraft's
  form engine).

9ef15cc: useReqoreMedia answered matchMedia on the client's first render while a server drew the
default (the docs SSR the provider): it now hydrates the server's answer and then the browser's
(useSyncExternalStore), and still answers the browser's on a first render without a server.

Tests (fail before): popoverOpensAtATap (hoverStay menus, keepOpenOnHover with hover, click and
focus, a tab bar's More menu; a hoverStay tooltip still opens at no tap), panelFloatingActionsTouch,
reqoreMediaHydration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Foxhoundn

Foxhoundn commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

1 new commit (7279d2e → 26327a4): the touch fixes for d45a15b and the hydration fix for 9ef15cc. My earlier heads-up listed colour fixes too; those now go to #717, where the contrast work lives.

  • Tap opens what is a way in:
    • The tabs', segmented control's and breadcrumbs' More menus (hoverStay) couldn't be opened on a phone. A hover popover marked as a way in opens at the tap's click. The new opensOnTap is set by ReqoreDropdown and by the Tabs and SegmentedControl More menus.
    • keepOpenOnHover popovers open at a tap too. One with handler: 'click'/'focus' also listens to its own event; the IDE's isHoverCapable ? 'hover' : 'click' + keepOpenOnHover opened nothing at a tap.
    • A plain tooltip, hoverStay or not, still opens at no tap, as TapOpensNoHoverTooltip asserts.
  • Panel floatingActions: they float only where the device can hover. Otherwise the show: 'hover' actions stay in the bar, so a tap reaches them.
  • useReqoreMedia: it hydrates the server's default, then the browser's answer (useSyncExternalStore). A first render without a server still answers the browser's.
  • Tests (fail before): popoverOpensAtATap, panelFloatingActionsTouch, reqoreMediaHydration.
  • Docs: COMPONENTS.md, "A hover is a pointer that hovers" and the provider row.
  • Checks: lint and tsc pass; unit tests 1675/1675; all stories 1191/1191 locally (desktop and touch, no upload).

@Foxhoundn
Foxhoundn merged commit 0183173 into develop Oct 15, 2026
7 checks passed
@Foxhoundn
Foxhoundn deleted the bugfix/focus-popover-opens-when-focused branch October 15, 2026 13:45
Foxhoundn added a commit that referenced this pull request Oct 16, 2026
Brings in develop 0.78.5 (#708: popover/panel fixes, helpers/styled prop
filtering, the touch story project). Develop no longer carries the qorus#646
contrast work (7279d2e moved it to its own PR), so nothing of it reaches here.

Conflicts, each resolved keeping both sides:
- package.json: version stays 0.78.6 (this PR's single bump, above develop's 0.78.5).
- Accordion, Callout, Checkbox, DataView, Link, Notifications/styles, Paragraph,
  Progress, Span, Tabs/list, Testimonial, Tier: imports only - develop's
  `styled` from helpers/styled (and its omitStyleProps / control-group props)
  together with this branch's typeScale helpers in place of the raw
  TEXT_FROM_SIZE / CONTROL_TEXT_FROM_SIZE imports.
- Collection/item, Menu, NavRail, Table/cell: imports only - develop's
  helpers/styled and hoveringPointer imports with this branch's panel-surface
  colour helpers.
- Panel: develop's REQORE_PANEL_STYLE_PROPS record and this branch's
  theme.panel header/border helpers, both kept.
- Table/index: the wrapper class carries both the hover-reveal class
  (actionsRevealOn) and develop's draggable class (dragToScroll).
- COMPONENTS.md (Panel, Statistic, Table, UIProvider rows): develop's 0.78.5
  text followed by this branch's 0.78.6 text in each row.
- Drawer, EntityRow, Statistic, Table stories: develop's new stories and this
  branch's themed-surface stories, both appended.

Develop added no component size read, so the only TEXT_FROM_SIZE reads left in
src/components are Spacer's default height.
davidnich pushed a commit that referenced this pull request Oct 16, 2026
#717 now targets develop: #708 merged as 0183173. The only commit new to this
branch is 26327a4 (a way in opens at a tap; hydration keeps the server's media
answer). It merged without conflicts in Panel, SegmentedControl and
COMPONENTS.md.
davidnich pushed a commit that referenced this pull request Oct 16, 2026
…story taps them on a phone (qorus#646)

A Qonsole panel on a phone folds its whole trail into the dropdown named
for the current chat. In the Qorus IDE a tap opened nothing, so a finger
reached neither the chat's full name nor "Back to chats". The IDE was on a
#708 prerelease from before 26327a4. That commit made every ReqoreDropdown
open at a tap (opensOnTap), including both of the breadcrumbs' fold menus,
and it is in 0.78.5. Nothing tested it for the breadcrumbs.

- Both fold menus (the current-page dropdown and the "…") now say what they
  are and whether they are open: aria-haspopup="menu" and aria-expanded,
  as the row actions menu does.
- The "…" dropdown has a class of its own,
  reqore-breadcrumbs-overflow-menu.

Story Navigation/Breadcrumbs › Folded Trail Opens At A Tap (touch, 390 px):
- A finger taps the folded current crumb. Its menu names the chat in full,
  and "Back to chats" in it is reached by a tap.
- A finger taps the "…" of folded ancestors. It opens, and "Back to chats"
  is reached there too.
- Both buttons say they are menus, closed and then open.
Before this change the aria checks fail. With the dropdown's opensOnTap
taken out, the tap checks fail (the menu never opens), which was the IDE's
case. Hover is unchanged: the menus are still hoverStay for a mouse.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants