Skip to content

Commit 4744e0a

Browse files
OpenStaxClaudeclaudeRoyEJohnsonstaxly[bot]
authored
[CORE-1708] Phase 5.4: Highlights UI Components Migration (#3021)
* Migrate ColorPicker and SummaryPopup components to plain CSS - Convert ColorPicker from styled-components to CSS classes - Migrate HighlightAnnotation component styles - Update ContextMenu icons and styled components - Create CSS files for component styling - Remove styled-components dependencies Part of Phase 5.4: Highlights UI Components Migration Update EditCard.spec.tsx.snap Change ColorPicker to accept null for onRemove instead of undefined This is a cleaner approach that: 1. Removes unnecessary null-to-undefined conversion in EditCard.tsx 2. Allows useOnRemove hook's natural return value (null) to be passed directly 3. Follows common React patterns for optional callbacks 4. Updates test expectations to check for null instead of undefined Changes: - ColorPicker.tsx: Update SingleSelectProps.onRemove to accept (() => void) | null - EditCard.tsx: Remove `?? undefined` conversion, pass removeHighlight directly - EditCard.spec.tsx: Change .toBeUndefined() to .toBeNull() in 3 tests Addresses review feedback from RoyEJohnson. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Fix EditCard test expectations for onRemove prop Change test assertions from `toBeNull()` to `toBeUndefined()` to match the actual behavior. In EditCard.tsx line 263, `removeHighlight ?? undefined` converts null to undefined before passing to ColorPicker, so the tests should expect undefined, not null. This fixes the 3 failing tests: - "doesn't chain ColorPicker onRemove if there is a note" - "doesn't chain ColorPicker onRemove if there is no data" - "doesn't chain ColorPicker onRemove if there is a pending note" 🤖 Generated with [Claude Code](https://claude.com/claude-code) specs Fix TypeScript errors in ColorPicker components - Add tabIndex to ColorButtonProps interface - Change onClick to onChange in ColorButtonProps (matches input element expectations) - Remove invalid onRemove prop from multiple mode tests in ColorPicker.spec.tsx Fixes the 4 TypeScript errors identified in code review: - ColorPicker.spec.tsx:31, 60, 183 (onRemove not in MultipleSelectProps) - ColorPicker.tsx:129 (tabIndex not in ColorButtonProps) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Some lint issues Styles is TSX Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * import ColorPicker CSS globally * Fix ContextMenu display issue - remove display:none The styled-context-menu class had `display: none` which was hiding the entire ContextMenu component. This was incorrect - the component should be visible by default and only hidden when printing. The positioning styles for the dropdown menu are already correctly applied to the [data-dropdown] selector, matching the behavior from the original styled-components implementation. Changes: - Removed `display: none` from .styled-context-menu - Kept the print media query to hide on print - The [data-dropdown] positioning styles remain unchanged Fixes issue identified in Review 6. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Fix ContextMenu dropdown display and data attributes 1. **Dropdown.tsx:** - Modified TabHiddenDropDown to pass through additional props (like data-dropdown) to the container div - Extract open/setOpen from props before spreading to avoid passing invalid props to DOM - Modified TabTransparentDropdown to accept and pass through additional props 2. **DotMenu.tsx:** - Added data-menu-toggle attribute to DotMenuToggle button - Added data-menu-icon attribute to DotMenuIcon These changes fix the issues identified in Review 7: - The data-dropdown attribute now properly reaches the final DOM element - The data-menu-toggle attribute enables proper CSS styling (float: right, margin-right: 0.2rem) - The data-menu-icon attribute enables proper icon styling on focus The ContextMenu.css already has the correct selectors for these data attributes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Snaps Strip open/setOpen in a different way Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Address Copilot review comments 1. Dropdown.tsx: Filter out invalid props before spreading to DOM - Extract open, setOpen, and onToggle from props in TabTransparentDropdown - Prevents React warnings about invalid DOM attributes - Only spreads valid HTML attributes to the div element 2. HighlightAnnotation.css: Add missing text color - Add color: var(--color-text-default) to .highlight-note - Add color: var(--color-text-default) to .highlight-note-annotation - Preserves the theme text color that was previously applied via textRegularStyle 3. styles.tsx: Use classnames helper for className construction - Import classnames library - Replace template string with classNames() call - Avoids trailing whitespace when className prop is undefined Fixes issues identified in Copilot Review #8 (PRR_kwDOCVMVFM7-Ya4E) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Fix TypeScript errors in TabTransparentDropdown Use the same approach as TabHiddenDropDown to handle optional open/setOpen props: - Changed Props type to accept Props | Props & ControlledProps - Extract open/setOpen from props conditionally before spreading to DOM element - This prevents TypeScript errors when props may or may not include open/setOpen Addresses Review 10 feedback - the Props type is a union that could be an empty object, so we can't destructure open/setOpen directly from the function parameters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Make stripControlledProps helper Update HighlightAnnotation.spec.tsx.snap Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Address Copilot review comments on prop filtering and ColorPicker behavior The `stripControlledProps` helper now properly filters props before spreading to DOM elements: - Removes controlled props (`open`, `setOpen`) - Removes callback props (`onToggle`) - Whitelists only safe HTML attributes (data-*, aria-*, id, role, style, title, lang, dir) This prevents React warnings about invalid DOM attributes and addresses Copilot's concern about spreading arbitrary component props to DOM elements. Changed from `onChange` to `onClick` for ColorButton radio inputs: - Radio inputs don't fire `change` events when clicking an already-selected option - This broke the "toggle off" behavior (calling onRemove when re-clicking active color) - onClick works for both selecting new colors AND re-clicking the current color - This restores the original pre-migration behavior (confirmed via git history) Changed test assertions from `.props.onChange()` to `.props.onClick()`: - Makes tests match the actual implementation - Tests now properly verify the click behavior that users experience - More realistic testing approach for radio input interactions 1. The component uses radio inputs for visual/ARIA semantics, but needs checkbox-like toggle behavior 2. In single-select mode: clicking the active color should trigger onRemove (impossible with onChange) 3. In multiple-select mode: clicking selected colors toggles them off (impossible with onChange) 4. The keyboard navigation already uses `.click()` (line 95), confirming onClick is the right approach --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) Snaps Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Refactor stripControlledProps to stripNonnativeProps with improved design Addresses Review 14 feedback with the following improvements: 1. **Renamed function**: `stripControlledProps` → `stripNonnativeProps` - Better reflects what the function actually does - Removes component-specific props (controlled + callbacks) - Whitelists only safe HTML attributes 2. **Updated type signature**: `{} | ControlledProps` → `Record<string, unknown>` - Accepts more general type that matches actual usage - Function handles props from both Props and ControlledProps interfaces - More flexible and accurately describes input 3. **Improved prop deletion logic**: - Separated `open` and `setOpen` deletions into distinct checks - Makes the logic clearer and easier to test - Each prop is explicitly handled 4. **Added comprehensive test coverage**: - New test: "TabHiddenDropDown filters out non-native props from being passed to DOM" - New test: "TabTransparentDropdown filters out non-native props from being passed to DOM" - Tests verify `onToggle` filtering (previously untested) - Tests verify safe HTML attributes (data-*, aria-*) pass through correctly - Tests verify controlled props (open, setOpen) are filtered out - Tests verify onToggle callback still works despite being filtered from DOM Changes address all three questions from Review 14: ✅ Does it need to handle onToggle? YES - now tested ✅ Should it accept more general type? YES - now Record<string, unknown> ✅ Should name change to stripNonnativeProps? YES - renamed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Remove redundant stripping Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Touchups --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Roy Johnson <roy.e.johnson@rice.edu> Co-authored-by: staxly[bot] <35789409+staxly[bot]@users.noreply.github.com>
1 parent 1ec86bb commit 4744e0a

23 files changed

Lines changed: 491 additions & 647 deletions

src/app/components/DotMenu.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ const DotMenuToggleBase = React.forwardRef<HTMLButtonElement, DotMenuToggleProps
4141
className={classNames('dot-menu-toggle', className)}
4242
aria-label="Actions"
4343
aria-expanded={isOpen}
44+
data-menu-toggle
4445
{...props}
4546
ref={ref}
4647
>
4748
<div tabIndex={-1}>
48-
<DotMenuIcon />
49+
<DotMenuIcon data-menu-icon />
4950
</div>
5051
</PlainButton>
5152
);

src/app/components/Dropdown.spec.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,4 +334,69 @@ describe('Dropdown', () => {
334334
)[0];
335335
expect(updatedFocusWrapper.props.className).toContain('focus-within');
336336
});
337+
338+
it('TabHiddenDropDown filters out non-native props from being passed to DOM', () => {
339+
const onToggle = jest.fn();
340+
const component = renderer.create(<TestContainer>
341+
<Dropdown
342+
transparentTab={false}
343+
toggle={<button>show more</button>}
344+
onToggle={onToggle}
345+
data-testid="my-dropdown"
346+
aria-label="My Dropdown"
347+
>
348+
<DropdownList>
349+
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
350+
</DropdownList>
351+
</Dropdown>
352+
</TestContainer>);
353+
354+
// Find the root div element
355+
const rootDiv = component.root.findAll(
356+
(el) => el.type === 'div' && el.props['data-testid'] === 'my-dropdown'
357+
)[0];
358+
359+
// Verify that safe HTML attributes are passed through
360+
expect(rootDiv.props['data-testid']).toBe('my-dropdown');
361+
expect(rootDiv.props['aria-label']).toBe('My Dropdown');
362+
363+
// Verify that non-native props (onToggle, open, setOpen) are NOT passed to the DOM
364+
expect(rootDiv.props.onToggle).toBeUndefined();
365+
expect(rootDiv.props.open).toBeUndefined();
366+
expect(rootDiv.props.setOpen).toBeUndefined();
367+
368+
// Verify onToggle is still called when toggle is clicked
369+
renderer.act(() => {
370+
component.root.findByType('button').props.onClick();
371+
});
372+
expect(onToggle).toHaveBeenCalledTimes(1);
373+
});
374+
375+
it('TabTransparentDropdown filters out non-native props from being passed to DOM', () => {
376+
const onToggle = jest.fn();
377+
const component = renderer.create(<TestContainer>
378+
<Dropdown
379+
toggle={<button>show more</button>}
380+
onToggle={onToggle}
381+
data-testid="my-dropdown"
382+
aria-label="My Dropdown"
383+
>
384+
<DropdownList>
385+
<DropdownItem onClick={() => null} message='i18n:highlighting:dropdown:delete' />
386+
</DropdownList>
387+
</Dropdown>
388+
</TestContainer>);
389+
390+
// Find the root div element
391+
const rootDiv = component.root.findAll(
392+
(el) => el.type === 'div' && el.props['data-testid'] === 'my-dropdown'
393+
)[0];
394+
395+
// Verify that safe HTML attributes are passed through
396+
expect(rootDiv.props['data-testid']).toBe('my-dropdown');
397+
expect(rootDiv.props['aria-label']).toBe('My Dropdown');
398+
399+
// Verify that non-native props (onToggle) are NOT passed to the DOM
400+
expect(rootDiv.props.onToggle).toBeUndefined();
401+
});
337402
});

src/app/components/Dropdown.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ export function callOrRefocus(
8080
}
8181
}
8282

83+
// Helper function for stripping out props that should not be passed to native DOM elements.
84+
// Only allows safe HTML attributes (data-*, aria-*, id, role, style, title, lang, dir) to pass through.
85+
function stripNonnativeProps(props: Record<string, unknown>) {
86+
const safeProps: Record<string, unknown> = {};
87+
88+
for (const [key, value] of Object.entries(props)) {
89+
if (
90+
key.startsWith('data-') ||
91+
key.startsWith('aria-') ||
92+
['id', 'role', 'style', 'title', 'lang', 'dir'].includes(key)
93+
) {
94+
safeProps[key] = value;
95+
}
96+
}
97+
98+
return safeProps;
99+
}
100+
83101
type TabHiddenProps = React.PropsWithChildren<Props | Props & ControlledProps>;
84102

85103
// Plain React component for TabHiddenDropDown
@@ -102,7 +120,9 @@ const TabHiddenDropDown = React.forwardRef<HTMLElement, TabHiddenProps>((
102120
if (toggleElement.current) { toggleElement.current.focus(); }
103121
});
104122

105-
return <div className={className} ref={mergeRefs(ref, container)}>
123+
const restProps = stripNonnativeProps(props);
124+
125+
return <div className={className} ref={mergeRefs(ref, container)} {...restProps}>
106126
<DropdownToggle
107127
ref={toggleElement}
108128
component={toggle}
@@ -117,8 +137,8 @@ const TabHiddenDropDown = React.forwardRef<HTMLElement, TabHiddenProps>((
117137
});
118138

119139
// Plain React component for TabTransparentDropdown
120-
const TabTransparentDropdown = React.forwardRef<HTMLElement, React.PropsWithChildren<Props>>((
121-
{toggle, children, className, menuClassName}, ref
140+
const TabTransparentDropdown = React.forwardRef<HTMLElement, React.PropsWithChildren<Props | Props & ControlledProps>>((
141+
{toggle, children, className, menuClassName, onToggle, ...props}, ref
122142
) => {
123143
const [isFocusWithin, setIsFocusWithin] = React.useState(false);
124144

@@ -134,7 +154,10 @@ const TabTransparentDropdown = React.forwardRef<HTMLElement, React.PropsWithChil
134154
}
135155
}, []);
136156

137-
return <div className={classNames('dropdown-transparent', className)} ref={ref}>
157+
// Extract controlled props and callbacks so they don't get passed to the div
158+
const restProps = stripNonnativeProps(props);
159+
160+
return <div className={classNames('dropdown-transparent', className)} ref={ref} {...restProps}>
138161
<DropdownFocusWrapper
139162
className={classNames({ 'focus-within': isFocusWithin })}
140163
onFocus={handleFocusIn}

src/app/components/__snapshots__/DotMenu.spec.tsx.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ exports[`Dropdown matches snapshot 1`] = `
1717
<button
1818
aria-label="Actions"
1919
className="plain-button dot-menu-toggle dropdown-toggle "
20+
data-menu-toggle={true}
2021
tabIndex={0}
2122
>
2223
<div
@@ -25,6 +26,7 @@ exports[`Dropdown matches snapshot 1`] = `
2526
<svg
2627
aria-hidden="true"
2728
className="dot-menu-icon "
29+
data-menu-icon={true}
2830
viewBox="0 0 192 512"
2931
>
3032
<path
@@ -67,6 +69,7 @@ exports[`Dropdown matches snapshot 1`] = `
6769
<button
6870
aria-label="Actions"
6971
className="plain-button dot-menu-toggle dropdown-toggle dropdown-toggle-second"
72+
data-menu-toggle={true}
7073
tabIndex={0}
7174
>
7275
<div
@@ -75,6 +78,7 @@ exports[`Dropdown matches snapshot 1`] = `
7578
<svg
7679
aria-hidden="true"
7780
className="dot-menu-icon "
81+
data-menu-icon={true}
7882
viewBox="0 0 192 512"
7983
>
8084
<path
@@ -104,6 +108,7 @@ exports[`Dropdown matches snapshot on right align 1`] = `
104108
<button
105109
aria-label="Actions"
106110
className="plain-button dot-menu-toggle dropdown-toggle "
111+
data-menu-toggle={true}
107112
tabIndex={0}
108113
>
109114
<div
@@ -112,6 +117,7 @@ exports[`Dropdown matches snapshot on right align 1`] = `
112117
<svg
113118
aria-hidden="true"
114119
className="dot-menu-icon "
120+
data-menu-icon={true}
115121
viewBox="0 0 192 512"
116122
>
117123
<path
@@ -154,6 +160,7 @@ exports[`Dropdown matches snapshot on right align 1`] = `
154160
<button
155161
aria-label="Actions"
156162
className="plain-button dot-menu-toggle dropdown-toggle dropdown-toggle-second"
163+
data-menu-toggle={true}
157164
tabIndex={0}
158165
>
159166
<div
@@ -162,6 +169,7 @@ exports[`Dropdown matches snapshot on right align 1`] = `
162169
<svg
163170
aria-hidden="true"
164171
className="dot-menu-icon "
172+
data-menu-icon={true}
165173
viewBox="0 0 192 512"
166174
>
167175
<path

src/app/content/__snapshots__/routes.spec.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ exports[`assigned route route renders renders a component 1`] = `
379379
/>
380380
<div
381381
className="c3 topbar-text-resizer-dropdown"
382+
data-testid="text-resizer"
382383
>
383384
<button
384385
aria-controls="text-resizer-menu"

src/app/content/components/Topbar/__snapshots__/index.spec.tsx.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ exports[`text resizer opens menu when clicking menu button 1`] = `
585585
</form>
586586
<div
587587
className="c2 topbar-text-resizer-dropdown mobile-variant mobile-toolbar-closed"
588+
data-testid="text-resizer"
588589
>
589590
<button
590591
aria-controls="text-resizer-menu"
@@ -810,6 +811,7 @@ exports[`text resizer opens menu when clicking menu button 1`] = `
810811
</form>
811812
<div
812813
className="c2 topbar-text-resizer-dropdown mobile-variant mobile-toolbar-closed"
814+
data-testid="mobile-text-resizer"
813815
>
814816
<button
815817
aria-controls="text-resizer-menu"

src/app/content/components/__snapshots__/AssignedTopBar.spec.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ exports[`AssignedTopBar renders 1`] = `
101101
/>
102102
<div
103103
className="c2 topbar-text-resizer-dropdown"
104+
data-testid="text-resizer"
104105
>
105106
<button
106107
aria-controls="text-resizer-menu"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* ColorPicker Component Styles */
2+
3+
.color-picker-wrapper {
4+
border: 0;
5+
display: flex;
6+
flex-direction: row;
7+
}
8+
9+
.color-picker-wrapper legend {
10+
position: absolute;
11+
width: 1px;
12+
height: 1px;
13+
padding: 0;
14+
margin: -1px;
15+
overflow: hidden;
16+
clip: rect(0, 0, 0, 0);
17+
white-space: nowrap;
18+
border-width: 0;
19+
}
20+
21+
.color-picker {
22+
border: 0;
23+
outline: none;
24+
display: flex;
25+
flex-direction: row;
26+
overflow: visible;
27+
gap: var(--card-padding);
28+
padding: 0 0.3rem var(--card-padding);
29+
}
30+
31+
.color-button {
32+
cursor: pointer;
33+
margin: 0;
34+
}
35+
36+
.color-button input {
37+
position: absolute;
38+
opacity: 0;
39+
cursor: pointer;
40+
height: 0;
41+
width: 0;
42+
}

src/app/content/highlights/components/ColorPicker.spec.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('ColorPicker', () => {
2828

2929
it('checks selection (multiple)', () => {
3030
const component = renderer.create(<TestContainer>
31-
<ColorPicker multiple selected={[highlightStyles[0].label]} onChange={jest.fn()} onRemove={jest.fn()} />
31+
<ColorPicker multiple selected={[highlightStyles[0].label]} onChange={jest.fn()} />
3232
</TestContainer>);
3333

3434
const [first, ...rest] = component.root.findAllByType('input');
@@ -46,25 +46,23 @@ describe('ColorPicker', () => {
4646

4747
const [, second] = component.root.findAllByType('input');
4848

49-
second.props.onChange();
49+
second.props.onClick();
5050

5151
expect(onRemove).not.toHaveBeenCalled();
5252
expect(onChange).toHaveBeenCalledWith(highlightStyles[1].label);
5353
});
5454

5555
it('calls update when changing selection (multiple)', () => {
5656
const onChange = jest.fn();
57-
const onRemove = jest.fn();
5857

5958
const component = renderer.create(<TestContainer>
60-
<ColorPicker multiple selected={[highlightStyles[0].label]} onChange={onChange} onRemove={onRemove} />
59+
<ColorPicker multiple selected={[highlightStyles[0].label]} onChange={onChange} />
6160
</TestContainer>);
6261

6362
const [, second] = component.root.findAllByType('input');
6463

65-
second.props.onChange();
64+
second.props.onClick();
6665

67-
expect(onRemove).not.toHaveBeenCalled();
6866
expect(onChange).toHaveBeenCalledWith([highlightStyles[0].label, highlightStyles[1].label]);
6967
});
7068

@@ -78,7 +76,7 @@ describe('ColorPicker', () => {
7876

7977
const [first] = component.root.findAllByType('input');
8078

81-
first.props.onChange();
79+
first.props.onClick();
8280

8381
expect(onRemove).toHaveBeenCalled();
8482
expect(onChange).not.toHaveBeenCalled();
@@ -177,17 +175,15 @@ describe('ColorPicker', () => {
177175

178176
it('calls remove when changing selection (multiple)', () => {
179177
const onChange = jest.fn();
180-
const onRemove = jest.fn();
181178

182179
const component = renderer.create(<TestContainer>
183-
<ColorPicker multiple selected={[highlightStyles[0].label]} onChange={onChange} onRemove={onRemove} />
180+
<ColorPicker multiple selected={[highlightStyles[0].label]} onChange={onChange} />
184181
</TestContainer>);
185182

186183
const [first] = component.root.findAllByType('input');
187184

188-
first.props.onChange();
185+
first.props.onClick();
189186

190-
expect(onRemove).not.toHaveBeenCalled();
191187
expect(onChange).toHaveBeenCalledWith([]);
192188
});
193189

@@ -200,7 +196,7 @@ describe('ColorPicker', () => {
200196

201197
const [first] = component.root.findAllByType('input');
202198

203-
first.props.onChange();
199+
first.props.onClick();
204200

205201
expect(onChange).not.toHaveBeenCalled();
206202
});

0 commit comments

Comments
 (0)