Skip to content

CODAP-1470: update formula editor UI to match latest design spec - #2678

Open
kswenson wants to merge 8 commits into
mainfrom
CODAP-1470-formula-editor-redesign
Open

CODAP-1470: update formula editor UI to match latest design spec#2678
kswenson wants to merge 8 commits into
mainfrom
CODAP-1470-formula-editor-redesign

Conversation

@kswenson

@kswenson kswenson commented Jul 28, 2026

Copy link
Copy Markdown
Member

Re-skins the formula editor modal to the published design spec, and fixes two long-standing bugs in it.

Fixes CODAP-1470
Fixes CODAP-1207

What changed

Modal shell. A header bar with the dialog title and a close button; the title tracks "Add Filter Formula" → "Edit Filter Formula", derived from the same strings the menu item uses. A Clear button between Cancel and Apply that empties the formula without applying it or dismissing the dialog. Field labels stacked above their fields without their trailing colons. Select-style insert buttons with a caret segment. Noto Sans Mono for the formula text. Callers that edit only a formula no longer get a disabled attribute-name row.

Chakra reduced (not eliminated). Box, Flex, FormControl, FormLabel, Tooltip and Button are replaced with plain markup and SCSS that reproduces the v3/v3Clear/v3Default button faces from theme.ts. Only the shell composition (CodapModal, ModalHeader/Body/Footer) is still Chakra, to be migrated separately for all ten of its consumers.

Sizing is driven by content, not arithmetic. The editor's height came from subtracting fixed header, footer and button-row heights from the modal height, which a taller header or a wrapped label could invalidate. The formula field now carries a two-row minimum and the modal takes its height from its content until resized. The corner resize handle moves onto the formula field, where sizing the field sizes the modal that sizes it.

Insert menus. Re-clicking an open menu's button now closes it. Both menus share one placement rule — flush below the button, above it when it doesn't fit below, capped to the space available so neither runs off-screen.

Bugs fixed along the way

  • CODAP-1207 — the attribute-name label had a fixed 97px width, so longer translations wrapped and pushed the insert buttons out of the modal body, where the footer clipped them. Because the editor height was computed by subtraction, resizing fed every extra pixel to the formula field and never uncovered them. Verified in German and Greek.
  • The modal froze a default size derived from a prop that callers resolve asynchronously, so the editor could collapse on first open.
  • A timing-fragile bin-width test that races the display config palette's auto-select. Unrelated to this story, but this branch's added tests tipped it over.

Accessibility

An audit confirmed all six keyboard behaviours from CODAP-1136 survive, including the two couplings most at risk: cmMoveFocus's button.insert-value selector, and .formula-modal-footer button:last-of-type still resolving to Apply with Clear inserted before it.

Fixed: the dialog announced as "Edit Formula Close" (the close button sat inside the aria-labelledby target); the formula field had no accessible name at all (a <label> can't name a contenteditable div — now wired via EditorView.contentAttributes); control borders were ~1.3:1 against the modal body, now #767676 at 4.02:1; 24×24 targets for the close button and resize grip, with the icons unchanged in size.

Out of scope

  • The Insert Function dropdown's cascading-flyout restructure is CODAP-1473.
  • Syntax-highlight colours are unresolved and deliberately untouched: the spec's legend specifies magenta for values, which is 3.14:1 on white and fails AA. Needs a designer decision.
  • The resize grip remains pointer-only (WCAG 2.1.1), pre-existing.

Testing

Full suite green at 3679. New tests cover the Clear button, the menu toggle, the dialog's accessible name, and the placement rule (8 unit tests, including the near-the-bottom flip and the never-overflow guarantee).

CODAP-1205 ([Safari] Cancel/Apply buttons not tabbable) was verified on Safari and closed as a browser setting rather than a defect — see the issue for details.

🤖 Generated with Claude Code

kswenson and others added 6 commits July 27, 2026 15:55
The attribute-name label had a fixed 97px width, so longer translations
("Name des Merkmals:", "Όνομα Χαρακτηριστικού:") wrapped onto a second line.
That grew the form control and pushed the Insert Value / Insert Function
buttons past the bottom of the modal body, where the footer clipped them.
Because the editor height is computed by subtracting fixed header, footer,
and button-row heights from the modal height, resizing the modal fed every
extra pixel to the formula field and never uncovered the buttons — matching
the report that resizing did not help.

The label now sizes to its text and the name input absorbs the remaining
width, so the label cannot wrap, and the insert-buttons row no longer
shrinks.

Also drop the decorative dashes from the English button labels to match the
design spec. The translations are owned by POEditor and still carry the
dashes, so they are stripped at render time until the translators catch up;
stripLabelDecoration and its two call sites can be removed at that point.

Fixes CODAP-1207

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the modal's Chakra contents with plain markup and SCSS: Box, Flex,
FormControl, FormLabel, Tooltip and Button are gone, and the SCSS reproduces
the v3/v3Clear/v3Default button faces from theme.ts. Only the shell
composition (CodapModal, ModalHeader/Body/Footer) is still Chakra, to be
migrated separately for all ten of its consumers.

Per the spec:
- a 34px header bar carrying the dialog title and a close button, the title
  tracking "Add Filter Formula" / "Edit Filter Formula" via the same strings
  the menu item uses, minus the ellipsis
- a Clear button between Cancel and Apply that empties the formula without
  applying it or dismissing the dialog; Apply stays last, since cmMoveFocus()
  shift-tabs to `.formula-modal-footer button:last-of-type`
- field labels stacked above their fields, without their trailing colons
- select-style insert buttons with a caret segment
- Noto Sans Mono for the formula text

Callers that edit only a formula no longer get a disabled attribute-name row;
the row is omitted and the modal opens shorter.

Sizing is now driven by content rather than arithmetic. The editor's height
came from subtracting fixed header, footer and button-row heights from the
modal height, which a taller header or a wrapped label could invalidate,
pushing the controls below it out of view. Instead the formula field carries
a two-row minimum and the modal takes its height from its content until the
user resizes it, so no total has to be kept in sync with its parts.

The corner resize handle moves to the formula field, whose grip sizes the
modal that sizes the field. It keeps the component-resize-handle class, which
is how CodapModal knows not to start a drag from it; dragging and resizing at
once moves the corner at twice the pointer's speed. The old handle reused the
tile-corner SVG, whose paths carry their own fills and so ignored the styling
applied to it.

modalContentRef is now attached to the modal. It was read when a resize began
but never wired to an element, so resizing started from a guessed size rather
than the measured one.

The modal also no longer stores a default size. It derived one from
titleInput, which callers read from a model that may still be resolving, so
the first render could freeze the shorter no-name-row height, and the editor
would absorb the shortfall and collapse.

The body and footer style rules are qualified to outrank codap-modal.scss's
two-class `.chakra-modal__content .chakra-modal__body`, which was silently
winning over their padding.

CODAP-1470

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dialog announced as "Edit Formula Close": Chakra points aria-labelledby
at ModalHeader, and an accessible name is computed from that element's whole
subtree, so the close button contributed its label. The button now sits
outside the header, positioned over it, and the title is an h2.

The formula field had no accessible name at all. A <label> only names
labelable elements, and CodeMirror's editable region is a contenteditable
div, so the field announced as "edit, multi-line". It is now named by id via
EditorView.contentAttributes.

Control borders move to #767676, 4.02:1 against the #f0f0f0 modal body. The
button, input, editor and caret-divider borders were around 1.3:1, leaving
the controls indistinguishable from the dialog surface (WCAG 1.4.11).

The close button and the resize grip get 24x24 targets (WCAG 2.2 SC 2.5.8).
The glyph and icon keep their sizes; only the transparent hit area grows, and
the grip's icon stays pinned to the field's corner.

The attribute input's aria-label is gone, since the label that wraps it is a
valid implicit association. That label also wraps the decorative "=", which
would otherwise become part of the input's name, so the glyph is aria-hidden.

Clear returns focus to the emptied field, so its effect is evident without
sight of it.

Deferred, both pre-existing: the resize grip is still pointer-only (2.1.1),
and three syntax-highlight colours fall below 4.5:1, the worst being magenta
attribute names at 3.14:1 — which the design spec itself specifies.

CODAP-1470

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shrinking the modal to its minimum collapsed the gap between the formula
field and the insert buttons. The margin was not collapsing: the modal was
being dragged shorter than its content. The form control may shrink, but the
formula field inside it has a two-row minimum, so the field overflowed its
container and covered the space below it.

The resize floor came from the modal's nominal minimum height, which is
smaller than the height the content actually needs. It is now measured
instead: the first resize begins while the modal is still sized by its
content, so it records that height and later drags clamp to it. The nominal
minimum remains only as a fallback, and the recorded height is cleared when
the modal closes.

CODAP-1470

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The palette focuses the bin width field and selects its text two animation
frames after mount. The test typed before that landed, so under load the
select() would swallow the digits already typed and Enter committed 0.

It now waits for the selection, then types over it with an explicit initial
selection, since user.type() clicks first and would otherwise collapse it.
Dropping the clear() also keeps fromValue at 10, as the test intends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking an insert button while its menu was open did nothing: the handlers
always opened, and they stop propagation so the modal's own click handler
could not close the menu either. They now toggle.

Both menus are placed by a shared rule, so the two cannot drift apart as they
had: flush below the button, above it when the menu does not fit below but
fits above, and on the roomier side when it fits neither, with maxHeight
capped to the space available. getMenuPlacement is a pure function with unit
tests; useFormulaMenuPlacement wires it to the refs.

The values menu previously computed a position during render, which bailed
out while its ref was still null and so never applied — leaving the menu at
its static position, correct by accident, and liable to jump the first time a
scroll re-rendered it. The function menu's own gap and 20px left offset are
gone, the latter having compensated for a button margin that is now a flex
gap.

Placement is measured after mount and again when the content resizes, since
react-aria builds a menu's items in a later pass and the first measurement
can be of a list that is not there yet. Where a menu clips inside a child
rather than itself, the child is measured: the container never overflows, so
its own scrollHeight collapses to the last maxHeight applied, which would
always read as a fit and keep the menu below.

CODAP-1470

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kswenson kswenson added the v3 CODAP v3 label Jul 28, 2026
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.92929% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.73%. Comparing base (6fc144a) to head (4f9ec88).

Files with missing lines Patch % Lines
v3/src/components/common/edit-formula-modal.tsx 80.55% 7 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #2678       +/-   ##
===========================================
+ Coverage   72.82%   87.73%   +14.90%     
===========================================
  Files         810      814        +4     
  Lines       46933    46969       +36     
  Branches    11999    11570      -429     
===========================================
+ Hits        34179    41207     +7028     
+ Misses      12721     5748     -6973     
+ Partials       33       14       -19     
Flag Coverage Δ
cypress 69.55% <85.54%> (+31.39%) ⬆️
jest 62.89% <83.83%> (+0.23%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cypress

cypress Bot commented Jul 28, 2026

Copy link
Copy Markdown

codap-v3    Run #12097

Run Properties:  status check passed Passed #12097  •  git commit 4f9ec8831c: test: update the formula editor tab order for the new buttons
Project codap-v3
Branch Review CODAP-1470-formula-editor-redesign
Run status status check passed Passed #12097
Run duration 03m 05s
Commit git commit 4f9ec8831c: test: update the formula editor tab order for the new buttons
Committer Kirk Swenson
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 4
View all changes introduced in this branch ↗︎

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the v3 formula editor modal to match the latest design spec while addressing several UX/accessibility issues (menu behavior/placement, sizing driven by content, and improved labeling for screen readers).

Changes:

  • Reworked EditFormulaModal layout/styling (reduced Chakra usage, new header/close, Clear button, content-driven sizing, resize grip on editor).
  • Standardized and hardened insert-menu behavior/placement with shared measurement logic and added unit tests.
  • Added translation label helpers (strip colon/ellipsis/dash decoration) and updated callers/tests to use them.

Reviewed changes

Copilot reviewed 27 out of 28 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
v3/src/utilities/translation/strip-label-decoration.ts New helper to remove decorative leading/trailing dash runs from translated labels.
v3/src/utilities/translation/strip-label-decoration.test.ts Unit tests for dash-decoration stripping across multiple locales/scripts.
v3/src/utilities/translation/lang/en-US.json5 Adjusts insert menu titles and adds new formula-modal strings (close/clear).
v3/src/utilities/translation/label-utils.ts New helpers to strip trailing ellipsis/colon for reusing existing translations in new UI contexts.
v3/src/utilities/translation/label-utils.test.ts Unit tests for ellipsis/colon stripping behavior.
v3/src/models/formula/lezer/formula-highlight.scss Applies Noto Sans Mono to CodeMirror formula editor styling.
v3/src/hooks/use-inspector-formula-string.ts Adds useInspectorFormulaTitle() derived from existing menu strings (minus ellipsis).
v3/src/components/map/components/inspector-panel/hide-show-menu-list.tsx Passes computed modalTitle into EditFormulaModal for filter formula editing.
v3/src/components/graph/plots/bar-chart/bar-chart.tsx Supplies modalTitle when opening the formula editor from bar chart UI.
v3/src/components/graph/components/inspector-panel/hide-show-menu-list.tsx Passes computed modalTitle into EditFormulaModal for graph filter formula editing.
v3/src/components/graph/components/inspector-panel/display-config-palette.tsx Documents the existing RAF/selection race in bin-width focus/selection behavior.
v3/src/components/graph/components/inspector-panel/display-config-palette.test.tsx Stabilizes bin-width test by waiting for focus/selection before typing.
v3/src/components/graph/adornments/univariate-measures/plotted-value/plotted-value-adornment-banner.tsx Supplies modalTitle for plotted value formula editor.
v3/src/components/graph/adornments/plotted-function/plotted-function-adornment-banner.tsx Supplies modalTitle for plotted function formula editor.
v3/src/components/common/use-formula-menu-placement.ts New hook using shared placement logic + ResizeObserver to keep menus on-screen.
v3/src/components/common/formula-insert-values-menu.tsx Replaces bespoke positioning with shared placement hook.
v3/src/components/common/formula-insert-menus.scss Aligns function menu styling with new positioning and improves scrolling behavior.
v3/src/components/common/formula-insert-menu-position.ts New shared placement utility (getMenuPlacement) + placement equality helper.
v3/src/components/common/formula-insert-menu-position.test.ts Unit tests for placement/flip/maxHeight behavior.
v3/src/components/common/formula-insert-function-menu.tsx Uses shared placement hook for consistent menu positioning/flipping.
v3/src/components/common/formula-editor.tsx Adds aria-labelledby wiring for contenteditable editor region; editor now flex-fills container.
v3/src/components/common/edit-formula-modal.tsx Major modal shell redesign, Clear behavior, sizing/resizing changes, and improved accessibility.
v3/src/components/common/edit-formula-modal.test.tsx Expands unit tests for new behaviors (Clear, toggle menus, dialog name, resize grip).
v3/src/components/common/edit-formula-modal.scss.d.ts Exposes new SCSS export for filter-only min height.
v3/src/components/common/edit-formula-modal.scss New SCSS implementing the redesigned modal UI and layout constraints.
v3/src/components/common/edit-attribute-formula-modal.tsx Derives modal title from existing menu label (stripping ellipsis).
v3/src/components/case-tile-common/hide-show-menu-list.tsx Passes derived filter-formula modal title.
v3/src/components/case-tile-common/filter-formula-bar.tsx Passes derived filter-formula modal title.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread v3/src/utilities/translation/lang/en-US.json5
Comment thread v3/src/components/common/formula-insert-menus.scss Outdated
The comment described a gap between the button and the menu. There is none:
getMenuPlacement sets top or bottom to the button's height, so both menus sit
flush against their button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The modal gained a close button in the header and a Clear button between
Cancel and Apply, so the cycle is now close → attribute name → formula →
insert value → insert function → cancel → clear → apply.

The buttons are matched by test id rather than by their text, so the
assertions do not depend on the locale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kswenson
kswenson requested a review from emcelroy July 28, 2026 18:00
@kswenson
kswenson marked this pull request as ready for review July 28, 2026 18:05

@emcelroy emcelroy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍 I tried out the new modal in a browser and it looks and works nicely.

I left one question about possibly reusing existing SASS variables. The report below was generated with Claude and points out some other relatively minor issues you may want to consider addressing before merging.

Verdict

Approve with the fixes below. Replacing height-by-subtraction with content-driven flex sizing, and consolidating the two divergent menu-placement calculations into one pure, tested function, is the right structural call and is what actually fixes CODAP-1207. Nothing found is a blocker. Findings 1–3 are user-visible defects — a decoration leaking into a localized header, scroll arrows that mislead, and menus that can run off-screen after a window resize; 4–6 are cleanups.

Findings

1. stripTrailingEllipsis misses the two-dot form

label-utils.ts:8-10

/(\.\.\.|…)\s*$/ requires exactly three dots. he.json has "DG.TableController.headerMenuItems.editFormula": "ערוך נוסחה.." — two — so the Hebrew formula-editor header renders the menu-item decoration this helper exists to strip. It is the only such case across the 18 locale files.

return label.replace(/\s*(\.{2,}|)\s*$/, "")

Also picks up whitespace before the ellipsis. Existing test expectations stay green ("N.B. Edit Formula", "A...B" — the match is end-anchored). Worth adding "ערוך נוסחה.." to label-utils.test.ts.

Editing he.json instead would work, but per the note in strip-label-decoration.ts:4-6 these files are POEditor-owned and a local edit can be overwritten on the next sync.

2. Values-menu scroll arrows compare against the constant, not the applied cap

formula-insert-values-menu.tsx:98-101

Both arrow conditions test against the 470px constant rather than the height the list is actually given, which produces two distinct failures:

  • No arrows at all. The placement hook can cap the menu below kMaxHeight, and the list scrolls at that cap, but isScrollable tests scrollHeight > kMaxHeight. Content between the cap and 470px scrolls with no arrows. Narrow — needs both sides of the button constrained (≈600px viewport) and a short list (2–4 attributes).
  • The down arrow hides early, at the default cap. canScrollDown tests scrollHeight − scrollTop + 20 > 470, a proxy for scrollHeight − scrollTop > clientHeight. With 0px of container padding and both arrows drawn, clientHeightis 420, not 450 — so with a 600px list the arrow disappears atscrollTop ≥ 150` while the bottom is at 180. Roughly 30px of content sits below the fold with nothing indicating it, on any list taller than the visible area, with no constrained viewport required.

One change fixes both:

const scrollEl = scrollableContainerRef.current
const isScrollable = !!scrollEl && scrollEl.scrollHeight > scrollEl.clientHeight
const canScrollUp = !!scrollEl && scrollEl.scrollTop > 0
const canScrollDown = !!scrollEl && scrollEl.scrollTop + scrollEl.clientHeight < scrollEl.scrollHeight - 1

Exact at any cap, and it retires the unexplained + 20. No oscillation risk: showing an arrow shrinks clientHeight, which can only keep isScrollable true.

These expressions read refs during render, so they rely on the placement hook's setPlacement and the scroll handler's setScrollPosition to re-render. That holds, but a comment saying so would help — the arrows' correctness rests on a state update in another module.

3. Menu placement is not recomputed on window resize

use-formula-menu-placement.ts:41-44

The ResizeObserver watches the menu and its scroll child. A window resize changes neither — width is content-driven under a 480px cap, height is pinned by the maxHeight already in state — so the window.innerHeight baked into the last getMenuPlacement call goes stale and the menu can run off-screen (also on rotation, zoom, devtools opening).

window.addEventListener("resize", place)
return () => {
  observer.disconnect()
  window.removeEventListener("resize", place)
}

Correctly scoped: both menus unmount when closed, and isSamePlacement suppresses no-op re-renders. Fixing it in the hook covers both consumers.

Dragging or resizing the modal also moves the button with no window resize, but that looks unreachable — the modal's onClick (edit-formula-modal.tsx:224) closes both menus and a header or corner drag still fires a click. Worth one manual check rather than adding tracking for it.

4. Dead closure state in the resize handler

edit-formula-modal.tsx:184

resizingWidth / resizingHeight are read by the setUserSize call on the same line and nowhere else — onPointerUp only detaches listeners — so their mutable, handler-spanning lifetime buys nothing. (In use-tile-resize.ts:82-83, where this pattern comes from, they are read on pointer-up to commit the final size.)

const onPointerMove = (pointerMoveEvent: { pageX: number; pageY: number }) => {
  const xDelta = pointerMoveEvent.pageX - startPosition.x
  const yDelta = pointerMoveEvent.pageY - startPosition.y
  const width = Math.max(startWidth + xDelta, widthFloor)
  const height = Math.max(startHeight + yDelta, heightFloor)
  setUserSize({width: Math.round(width), height: Math.round(height)})
}

5. Lint warning introduced by the branch

display-config-palette.test.tsx:211-212as HTMLInputElement is flagged as redundant. CI still passes (warnings don't fail lint:build), but it is the only warning in the tree.

Note that simply deleting the assertion breaks the build: TypeScript uses the asserted type as the contextual type for the generic call, so the assertion is what infers T = HTMLInputElement in the first place — remove it and T falls back to HTMLElement, and selectionStart no longer exists. Pass the type argument instead:

const binWidthInput =
  within(screen.getByTestId("graph-bin-width-setting")).getByRole<HTMLInputElement>("textbox")

Verified: build:tsc clean, lint:build down to zero warnings, palette suite 12/12.

6. Dead SCSS variable

edit-formula-modal.scss:8$edit-formula-modal-min-width-px lost its only consumer when .formula-modal body's min-width was dropped. The unitless form is still exported and used.

Comment on lines +17 to +24
$formula-header-face: #a5e3f6;
// button faces, matching the Chakra `v3*` variants these buttons replaced
$formula-button-face: #d3f4ff;
$formula-button-face-hover: #bbefff;
$formula-button-face-active: #a5e3f6;
$formula-button-default-face: #bbefff;
$formula-button-default-hover: #93d5e4;
$formula-button-default-active: #72bfca;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are color variables for these hex codes in v3/src/components/vars.scss. Would it be worth incorporating those here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants