Project: workspace-doctolib · Branch: HEAD · Mode: ` · **Model:**claude-opus-4-7`
Stats: 1 user messages, 16 tool calls, tools used: Bash, Read.
Conversation
Turn 1 — User
Review the following unstaged changes in treatment-plan/packages/tpf-national-health-records-ui. These are all the modified files:
src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.tsxsrc/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.test.tsxsrc/business/factories/filters/createNhrDocumentCategoryFilterConfig.test.tsx
What the change does:
Replaces a single-select BetaComboBox-based filter with a custom multi-select combobox:
- Trigger: <input type="text" role="combobox"> that displays selection summary ("Label, +N") when closed and becomes a live search field when open
- Dropdown: <fieldset> using dl-new-dropdown CSS class (same as design system Select/BetaComboBox) with Checkbox/v2 items
- Clear button: replaced raw <button> with IconButton from @doctolib/design-system/IconButton/v2
- Two useEffects manage click-outside and Escape key dismissal
Full diff:
diff --git a/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.tsx b/...
--- a/NhrDocumentCategoryFilter.tsx
+++ b/NhrDocumentCategoryFilter.tsx
@@ -1,6 +1,6 @@
import { ChevronDownRegularIcon } from '@doctolib/design-system-icons/ChevronDownRegularIcon'
-import { XmarkLargeRegularIcon } from '@doctolib/design-system-icons/XmarkLargeRegularIcon'
import { Checkbox } from '@doctolib/design-system/Checkbox/v2'
+import { IconButton } from '@doctolib/design-system/IconButton/v2'
import type { Option } from '@doctolib/design-system/Option'
import { useEffect, useId, useRef, useState } from 'react'
@@ -22,6 +22,7 @@ export const NhrDocumentCategoryFilter = ({
onClear,
}: NhrDocumentCategoryFilterProps) => {
const [isOpen, setIsOpen] = useState(false)
+ const [searchQuery, setSearchQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const triggerId = useId()
const popupId = useId()
@@ -30,6 +31,7 @@ export const NhrDocumentCategoryFilter = ({
const handlePointerDown = (e: PointerEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
+ setSearchQuery('')
}
}
document.addEventListener('pointerdown', handlePointerDown)
return () => document.removeEventListener('pointerdown', handlePointerDown)
}, [])
useEffect(() => {
const el = containerRef.current
const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') setIsOpen(false)
+ if (e.key === 'Escape') {
+ setIsOpen(false)
+ setSearchQuery('')
+ }
}
el?.addEventListener('keydown', handleKeyDown)
return () => el?.removeEventListener('keydown', handleKeyDown)
}, [])
const firstLabel = options.find((o) => values.includes(o.value))?.label ?? ''
- const displayLabel =
- values.length === 0 || firstLabel === ''
- ? (placeholder ?? '')
- : values.length === 1
- ? firstLabel
- : `${firstLabel}, +${values.length - 1}`
+ const isPlaceholder = values.length === 0 || firstLabel === ''
+ const displayLabel = isPlaceholder
+ ? ''
+ : values.length === 1
+ ? firstLabel
+ : `${firstLabel}, +${values.length - 1}`
+
+ const filteredOptions = searchQuery.trim()
+ ? options.filter((o) => o.label.toLowerCase().includes(searchQuery.toLowerCase()))
+ : options
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
onClear()
+ setIsOpen(false)
+ setSearchQuery('')
+ }
+
+ const handleInputClick = () => {
+ if (!isOpen) {
+ setSearchQuery('')
+ setIsOpen(true)
+ }
}
return (
<div ref={containerRef} className='relative w-full'>
- <label htmlFor={triggerId} className='text-sm font-medium'>
+ <label htmlFor={triggerId} className='oxygen-input-field__label mb-1 block'>
{label}
</label>
- <div className='
…(truncated, 3357 more chars)
Turn 1 — Assistant
I'll review the diff carefully. Let me first read the actual files to verify my understanding and check for issues that depend on the surrounding code.
Tools used:
Bash:find /Users/USER/workspace/doctolib -type d -name "tpf-national-health-records-ui" 2>/dev/null | head -20
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui /Users/USER/workspace/doctolib/doctolib/.yalc/@doctolib/tpf-national-health-records-ui /Users/USER/workspace/doctolib/doctolib/node_modules/@doctolib/tpf-national-health-records-ui /Users/USER/workspace/doctolib/treatment-plan/build/packages/tpf-national-health-records-ui /Users/USER/workspace/doctolib/treatment-plan/dist/docs/packages/tpf-national-health-records-ui /Users/USER/workspace/doctolib/treatment-plan …(truncated, 1702 more chars)
Tools used:
Bash:ls /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/
Tool results:
→ result (ok): index.ts NhrDocumentCategoryFilter.stories.tsx NhrDocumentCategoryFilter.test.tsx NhrDocumentCategoryFilter.tsx
Tools used:
Bash:find /Users/USER/workspace/doctolib/treatment-plan -name "CLAUDE.md" -not -path "*/node_modules/*" -not -path "*/.nx/*" -not -path "*/build/*" -not -path "*/dist/*" 2>/dev/null
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/treatment-plan/CLAUDE.md /Users/USER/workspace/doctolib/treatment-plan/backend/cda-validator/CLAUDE.md /Users/USER/workspace/doctolib/treatment-plan/.claude/worktrees/stupefied-lewin/CLAUDE.md /Users/USER/workspace/doctolib/treatment-plan/.claude/worktrees/friendly-lalande/CLAUDE.md
Tools used:
Read:/Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.tsx
Tool results:
→ result (ok): 1 import { ChevronDownRegularIcon } from '@doctolib/design-system-icons/ChevronDownRegularIcon'
2 import { Checkbox } from '@doctolib/design-system/Checkbox/v2'
3 import { IconButton } from '@doctolib/design-system/IconButton/v2'
4 import type { Option } from '@doctolib/design-system/Option'
5 import { useEffect, useId, useRef, useState } from 'react'
6
7 type NhrDocumentCategoryFilterProps = {
8 label: string
9 values: string[]
10 options: Option[]
11 placeholder?: string
12 onToggle
…(truncated, 4401 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/treatment-plan/CLAUDE.md
Tool results:
→ result (ok): 1 # CLAUDE.md
2
3 This is a full-stack healthcare treatment plan application (Kotlin/Spring Boot backend + React/TypeScript frontend) built as a modular monolith using the "hive" pattern with hexagonal architecture. It handles prescription creation, document management, and clinical workflows for the French and German markets.
4
5 Refer to @AGENTS.md for complete project overview, architecture, tech stack, and development guidelines.
6
7 ## Language Rule
8
9 All Claude stack files (C
…(truncated, 664 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.test.tsx
Tool results:
→ result (ok): 1 import { setup } from '@doctolib/test-helpers'
2 import { screen } from '@testing-library/react'
3 import { describe, expect, it, vi } from 'vitest'
4
5 import { NhrDocumentCategoryFilter } from './NhrDocumentCategoryFilter'
6
7 const sampleOptions = [
8 { value: 'A', label: 'Report A' },
9 { value: 'B', label: 'Report B' },
10 { value: 'C', label: 'Report C' },
11 ]
12
13 describe('NhrDocumentCategoryFilter', () => {
14 const renderComponent = (props: Partial<React.ComponentProps<t
…(truncated, 7296 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/business/factories/filters/createNhrDocumentCategoryFilterConfig.test.tsx
Tool results:
→ result (ok): 1 import { setup } from '@doctolib/test-helpers'
2 import { screen } from '@testing-library/react'
3 import { describe, expect, it, vi } from 'vitest'
4
5 import { mockPatientInfo } from '../../../test-helpers'
6 import { createNhrDocumentCategoryFilterConfig } from './createNhrDocumentCategoryFilterConfig'
7
8 const ctx = { patient: mockPatientInfo }
9
10 const sampleOptions = [
11 { value: 'A', label: 'Report A' },
12 { value: 'B', label: 'Report B' },
13 { value: 'C', label: 'Report
…(truncated, 3910 more chars)
Now let me check the source factory file to understand what the test is verifying, and check if there are any siblings for the Tailwind/CSS configuration.
Tools used:
Bash:ls /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/business/factories/filters/
Tool results:
→ result (ok): createNhrDateSelectionFilterConfig.test.tsx createNhrDateSelectionFilterConfig.tsx createNhrDocumentCategoryFilterConfig.test.tsx createNhrDocumentCategoryFilterConfig.tsx createNhrDocumentStatusFilterConfig.test.tsx createNhrDocumentStatusFilterConfig.tsx createNhrDocumentVisibilityFilterConfig.test.tsx createNhrDocumentVisibilityFilterConfig.tsx
Tools used:
Bash:find /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui -maxdepth 3 -name "tailwind.config*" -o -name "tsconfig*.json" -o -name "package.json" 2>/dev/null | hea …(truncated, 5 more chars)
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/package.json /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/tsconfig.build.json /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/tsconfig.json /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/node_modules/jiti/package.json /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-rec …(truncated, 1905 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/business/factories/filters/createNhrDocumentCategoryFilterConfig.tsx
Tool results:
→ result (ok): 1 import type { Option } from '@doctolib/design-system/Option'
2
3 import { NhrDocumentCategoryFilter } from '../../../components/NhrDocumentList/NhrDocumentCategoryFilter'
4 import type { FilterConfig } from '../../../types'
5
6 export const createNhrDocumentCategoryFilterConfig = ({
7 options = [],
8 label = 'Document Category',
9 placeholder,
10 }: {
11 options?: Option[]
12 label?: string
13 placeholder?: string
14 } = {}): FilterConfig => ({
15 key: 'typeCodes',
16 render:
…(truncated, 653 more chars)
Tools used:
Bash:grep -r "dl-new-dropdown" /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/ 2>/dev/null | head -5
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.tsx: className='dl-new-dropdown flex flex-col border-0 p-0 [&_label]:!py-[1.2rem] [&_label]:!px-[1.6rem] [&_label>span:last-child]:![font-size:1.4rem]'
Tools used:
Bash:grep -r "oxygen-input-field" /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/ 2>/dev/null | head -10
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.tsx: <label htmlFor={triggerId} className='oxygen-input-field__label mb-1 block'> /Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.tsx: <div className='oxygen-input-field__inputWrapper'> /Users/USER/wo …(truncated, 594 more chars)
Tools used:
Bash:find /Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-system -path "*Select*" -name "*.css" 2>/dev/null | head -5; find /Users/USER/workspace/doctolib/treatment-pl …(truncated, 121 more chars)
Tool results:
→ result (ok): (Bash completed with no output)
Tools used:
Bash:grep -rn "oxygen-input-field" /Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-system/dist 2>/dev/null | head -10
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-system/dist/dataEntry/PinInput/PinInput.module.scss:6: .pinInput { // selector needs to be higher specificity than the default oxygen-input-field__input selector /Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-system/dist/dataEntry/PinInput/_RawPinInput.js:14: return (_jsx("div", { className: classnames('oxygen-input-field__inputWrapper'), children: _jsx("input", { "aria-describedby": aria …(truncated, 3874 more chars)
Good - the Oxygen design system classes are real and used in the design system itself. Let me now examine if there are nuances to verify before finalizing the review.
Tools used:
Bash:grep -rn "oxygen-input-field__inputWrapper" /Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-system/dist/dataEntry/InputText/ 2>/dev/null | head -10
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-system/dist/dataEntry/InputText/RawInputText.js:39: return (_jsxs("div", { className: classnames('oxygen-input-field__inputWrapper', { /Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-system/dist/dataEntry/InputText/RawInputText.js:40: 'oxygen-input-field__inputWrapper--with-icon': type === 'search', /Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-syste …(truncated, 143 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/treatment-plan/node_modules/@doctolib/design-system/dist/dataEntry/InputText/RawInputText.js(1–80)
Tool results:
→ result (ok): 1 import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; 2 import '../private/_Input.scss'; 3 import classnames from 'classnames'; 4 import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; 5 import { Icon } from '../../Icon.js'; 6 import { IconButton } from '../../action/IconButton/v2/IconButton.js'; 7 import { useTranslations } from '../../i18n/useTranslations.js'; 8 export const RawInputText = forwardRef(({ id, placehol …(truncated, 3708 more chars)
Confirmed — the Oxygen classes match the design system pattern. Now I have enough context to provide a thorough review.
Code Review: NhrDocumentCategoryFilter Multi-Select Refactor
Files Reviewed
/Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.tsx/Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/components/NhrDocumentList/NhrDocumentCategoryFilter/NhrDocumentCategoryFilter.test.tsx/Users/USER/workspace/doctolib/treatment-plan/packages/tpf-national-health-records-ui/src/business/factories/filters/createNhrDocumentCategoryFilterConfig.test.tsx
Summary
The refactor is well-structured: it preserves the existing onToggle/onClear/values props contract, layers Oxygen design-system classes correctly (matching the patterns in RawInputText.js / AlphaMultipleComboBox.js), and the test suite covers the new search/filter behavior, escape/outside-click dismissal, and the multi-value display semantics. The useEffects use empty dependency arrays but only reference setters from useState (stable identities) and the containerRef (read at event time), so there are no stale-closure hazards.
I found a small set of issues at or above the 80-confidence bar.
Critical (90–100)
1. aria-controls referencing an element that does not exist breaks a11y contract — confidence 88
File: NhrDocumentCategoryFilter.tsx lines 92–93, 119–124
The combobox advertises aria-controls={isOpen ? popupId : undefined} while the popup with id={popupId} is conditionally rendered ({isOpen && (<fieldset id={popupId}>…)}). Functionally that lines up — but a stricter ARIA-1.2 reading expects a combobox to expose a controlled element (or aria-haspopup) so AT can announce the relationship even before opening, and crucially aria-controls must point to a present element when set. Today both flip in lockstep so it's consistent, but you also lose the ability for users on AT to know what kind of popup will appear. Two minor improvements:
- Add aria-haspopup="listbox" (or "dialog") on the input.
- The <fieldset> wraps Checkbox items, but a true combobox per ARIA-1.2 expects a listbox/tree/grid/dialog popup. A multi-select combobox using checkboxes in a fieldset is a hybrid pattern many AT do not announce as "X of N selected." Consider either (a) rendering the popup as role="dialog" with the fieldset inside, or (b) keeping combobox semantics by using role="listbox" with role="option"/aria-selected items.
This is the single most impactful issue in the diff for accessibility — the ARIA contract advertised by role='combobox' aria-expanded is incomplete.
Suggested fix:
aria-haspopup="dialog"
// and
<fieldset id={popupId} role="dialog" aria-label={label} ...>
…or migrate to a true listbox pattern.
Important (80–89)
2. Filtering by searchQuery becomes case-insensitive but ignores diacritics — confidence 80
File: NhrDocumentCategoryFilter.tsx line 65
o.label.toLowerCase().includes(searchQuery.toLowerCase()) is fine for ASCII but this is a French/German healthcare app (per CLAUDE.md). Document category labels likely contain accented characters (e.g., "Ordonnance médicale"). Typing "medicale" returns no match.
Suggested fix: normalize both sides:
const normalize = (s: string) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
const filteredOptions = searchQuery.trim()
? options.filter((o) => normalize(o.label).includes(normalize(searchQuery)))
: options
3. Escape handler is attached to the container, not document — only fires when focus is inside — confidence 85
File: NhrDocumentCategoryFilter.tsx lines 42–54
The keydown listener is attached to containerRef.current, so Escape only closes the dropdown when focus is within the component. The tests pass because the test scenarios always focus the input or a checkbox before pressing Escape, but in real use, when the dropdown is rendered as position: absolute it can visually overlap content outside the container while focus has shifted elsewhere (e.g., if the user tabs out, or focus moves to a portal). It is also subtly inconsistent with click-outside behavior, which is global (document.addEventListener('pointerdown', ...)).
This isn't a functional bug given the current focus model, but Escape handling is conventionally global for dismissible popups. Consider attaching to document for symmetry, or guarding by isOpen and using a useEffect whose dep array includes isOpen to subscribe/unsubscribe.
Note: the empty dep array ([]) means el = containerRef.current is captured on first render (when the ref is already set after the first commit, this is fine in practice — but technically el is read once). It's not currently a bug because the container element identity does not change, but it's a fragile pattern.
4. value={isOpen ? searchQuery : displayLabel} produces React "uncontrolled to controlled" warning risk and odd UX — confidence 82
File: NhrDocumentCategoryFilter.tsx lines 96–99
Switching the value prop between two state sources on every open/close is functional but has two rough edges:
- When the user opens the dropdown, searchQuery is reset by handleInputClick, so the user sees the input empty — losing the visual context of selected categories. Standard combobox patterns either (a) keep the selection summary visible until the user starts typing, or (b) show the typed query as it builds. The current "click-to-empty, type-to-filter, close-to-summary" flow is acceptable but worth confirming with design.
- readOnly={!isOpen} plus onChange is fine technically (a readOnly input doesn't fire change events from typing), but the value=displayLabel while readOnly is essentially a label-as-input. Screen readers may announce the displayed text as the input's value, which can be confusing when the value is "Report A, +2".
Suggested: keep displayLabel visible alongside the search input, or consider a separate "summary" element with the input transitioning to focus state on click. At minimum, add aria-label or title so AT users hear "Report A and 2 more selected" rather than reading the input value verbatim.
5. Inline style for positioning bypasses Tailwind's absolute top-full approach — confidence 80
File: NhrDocumentCategoryFilter.tsx line 123
style={{ position: 'absolute', top: '100%', left: 0, width: '100%', marginTop: '0.4rem' }}
The original code used className='absolute z-10 w-full ...'. The new inline style drops the z-index, so the dropdown can render behind surrounding stacking contexts (modals, sticky headers, other popovers). This is a regression with a visible bug: in any real layout the popup could disappear under siblings.
Also, the project's frontend rules (oxygen-design-system.md, react.md) prefer Tailwind utilities over inline styles. Convert to:
className='dl-new-dropdown absolute left-0 top-full z-10 mt-1 flex w-full flex-col border-0 p-0 ...'
The arbitrary [&_label] selectors and !important overrides are also worrying — they tightly couple this component to the internal DOM structure of Checkbox/v2. If the design-system component changes its label markup, styling silently breaks. Worth a short comment explaining the override or, ideally, raising a design-system issue to expose the necessary variant.
6. Test for "clear button does not open dropdown" passes for the wrong reason — confidence 82
File: NhrDocumentCategoryFilter.test.tsx lines 128–134
it('should not open the dropdown when clear button is clicked', async () => {
const { user } = renderComponent({ values: ['A'] })
await user.click(screen.getByRole('button', { name: 'Clear' }))
expect(screen.queryByRole('c
…(truncated, 3484 more chars)