CODAP-1470: update formula editor UI to match latest design spec - #2678
CODAP-1470: update formula editor UI to match latest design spec#2678kswenson wants to merge 8 commits into
Conversation
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>
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
codap-v3
|
||||||||||||||||||||||||||||
| Project |
codap-v3
|
| Branch Review |
CODAP-1470-formula-editor-redesign
|
| Run status |
|
| Run duration | 03m 05s |
| Commit |
|
| Committer | Kirk Swenson |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
4
|
| View all changes introduced in this branch ↗︎ | |
There was a problem hiding this comment.
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
EditFormulaModallayout/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.
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>
There was a problem hiding this comment.
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
/(\.\.\.|…)\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, butisScrollabletestsscrollHeight > 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.
canScrollDowntests scrollHeight − scrollTop + 20 > 470, a proxy forscrollHeight − 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 - 1Exact 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
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-212 — as 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.
| $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; |
There was a problem hiding this comment.
There are color variables for these hex codes in v3/src/components/vars.scss. Would it be worth incorporating those here?
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,TooltipandButtonare replaced with plain markup and SCSS that reproduces thev3/v3Clear/v3Defaultbutton faces fromtheme.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
Accessibility
An audit confirmed all six keyboard behaviours from CODAP-1136 survive, including the two couplings most at risk:
cmMoveFocus'sbutton.insert-valueselector, and.formula-modal-footer button:last-of-typestill resolving to Apply with Clear inserted before it.Fixed: the dialog announced as "Edit Formula Close" (the close button sat inside the
aria-labelledbytarget); the formula field had no accessible name at all (a<label>can't name acontenteditablediv — now wired viaEditorView.contentAttributes); control borders were ~1.3:1 against the modal body, now#767676at 4.02:1; 24×24 targets for the close button and resize grip, with the icons unchanged in size.Out of scope
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