Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 75 additions & 34 deletions client/src/components/Tooltip/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// TODO: Consider adopting a floating-ui-based positioning approach and add
// snapshot tests
import { useState, useRef, useId } from 'react';
import { Transition } from '@headlessui/react';
import { Portal, Transition } from '@headlessui/react';
import { InfoIcon } from './InfoIcon';
import classNames from 'classnames';

Expand All @@ -10,13 +12,6 @@ interface TooltipProps {
position?: TooltipPosition;
}

const positionClasses: Record<TooltipPosition, string> = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
};

const arrowClasses: Record<TooltipPosition, string> = {
top: 'top-full left-1/2 -translate-x-1/2 border-l-transparent border-r-transparent border-b-transparent border-t-[#1b1b1b]',
bottom:
Expand All @@ -26,12 +21,57 @@ const arrowClasses: Record<TooltipPosition, string> = {
'right-full top-1/2 -translate-y-1/2 border-t-transparent border-b-transparent border-l-transparent border-r-[#1b1b1b]',
};

function getTooltipStyle(
position: TooltipPosition,
rect: DOMRect | null
): React.CSSProperties {
if (!rect) return { position: 'fixed', visibility: 'hidden' };

const gap = 8;

switch (position) {
case 'top':
return {
position: 'fixed',
left: rect.left + rect.width / 2,
top: rect.top - gap,
transform: 'translate(-50%, -100%)',
};
case 'bottom':
return {
position: 'fixed',
left: rect.left + rect.width / 2,
top: rect.bottom + gap,
transform: 'translateX(-50%)',
};
case 'left':
return {
position: 'fixed',
left: rect.left - gap,
top: rect.top + rect.height / 2,
transform: 'translate(-100%, -50%)',
};
case 'right':
return {
position: 'fixed',
left: rect.right + gap,
top: rect.top + rect.height / 2,
transform: 'translateY(-50%)',
};
}
}

export function Tooltip({ label, position = 'top' }: TooltipProps) {
const [visible, setVisible] = useState(false);
const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null);
const tooltipId = useId();
const showTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);

function show() {
if (triggerRef.current) {
setTriggerRect(triggerRef.current.getBoundingClientRect());
}
showTimeout.current = setTimeout(() => setVisible(true), 300);
}

Expand All @@ -41,8 +81,9 @@ export function Tooltip({ label, position = 'top' }: TooltipProps) {
}

return (
<span className="relative inline-flex items-center">
<span className="inline-flex items-center">
<button
ref={triggerRef}
type="button"
aria-describedby={tooltipId}
onMouseEnter={show}
Expand All @@ -57,33 +98,33 @@ export function Tooltip({ label, position = 'top' }: TooltipProps) {
<span className="sr-only">More information</span>
</button>

<Transition
show={visible}
enter="transition-opacity duration-150 ease-in"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100 ease-out"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<span
id={tooltipId}
role="tooltip"
className={classNames(
'bg-gray-90 pointer-events-none absolute z-50 w-max max-w-xs rounded-sm p-2 font-normal text-white',
positionClasses[position]
)}
<Portal>
<Transition
show={visible}
enter="transition-opacity duration-150 ease-in"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100 ease-out"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{label}
<span
aria-hidden
className={classNames(
'absolute h-0 w-0 border-4 border-solid',
arrowClasses[position]
)}
/>
</span>
</Transition>
id={tooltipId}
role="tooltip"
style={getTooltipStyle(position, triggerRect)}
className="bg-gray-90 pointer-events-none z-50 w-max max-w-xs rounded-sm p-2 font-normal text-white"
>
{label}
<span
aria-hidden
className={classNames(
'absolute h-0 w-0 border-4 border-solid',
arrowClasses[position]
)}
/>
</span>
</Transition>
</Portal>
</span>
);
}
212 changes: 107 additions & 105 deletions client/src/pages/Configurations/ConfigBuild/Sections/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function Sections({
};

return (
<section className="flex w-full flex-col gap-6">
<section className="flex min-h-0 w-full flex-1 flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-gray-cool-90 text-xl font-bold">eICR Sections</h3>
Expand Down Expand Up @@ -97,97 +97,101 @@ export function Sections({
</p>
</div>

<table className="w-full table-fixed">
<thead>
<tr className="border-gray-cool-20 text-gray-cool-60 border-b">
<th scope="col" className="w-32 pb-3">
Include
</th>
<th scope="col" className="w-auto pb-3 text-left">
Section name
</th>
<th scope="col" className="align-right w-2/6 pb-3">
<div className="flex items-center justify-center gap-1">
<span>Data handling approach</span>
<Tooltip
position="left"
label={`Set to "Refine & optimize" if you'd like to filter the
content of this section down to coded elements matching the
codes in your configuration in your refined output. Set to
"Preserve & retain" if you'd like to keep the information in
this section in its entirety in the refined output.`}
/>
</div>
</th>
<th scope="col" className="w-1/6 pb-3">
<div className="flex items-center justify-center gap-1">
<span>Narrative</span>
<Tooltip
position="left"
label="Enable to retain the narrative block for this section in the refined output or disable to omit it."
/>
</div>
</th>
</tr>
</thead>
<tbody className="divide-gray-cool-20 divide-y">
{sectionProcessing.map((section) => (
<tr key={section.code} className="text-gray-cool-60">
<td>
<div className="flex justify-center p-8">
<IncludeCheckbox
configurationId={configurationId}
currentSection={section}
sections={sectionProcessing}
disabled={disabled || isDisabledSection(section.code)}
<div className="min-h-0 flex-1 overflow-y-scroll">
{/* TODO: Revisit table layout for Refiner 2.0 UI migration (see Section 2 - Refiner 2.0 Readiness in ../tech-lead-feedback-2026/front-end-component-refactoring-action-plan.md). Evaluate whether a virtualized list is appropriate for large section counts. */}
<table className="w-full table-fixed">
Copy link
Copy Markdown
Collaborator

@fzhao99 fzhao99 Jun 4, 2026

Choose a reason for hiding this comment

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

breaking things into two tables like this will cause accessibility issues, since the bottom table doesn't have any semantic labeling and will get lost for screenreader users without associated header columns.

Is there a concern for keeping things in one semantic table and using sticky here like we were previously? Otherwise, this will come back to bite us when we need to submit accessibility scans at the next CDC review

Copy link
Copy Markdown
Collaborator Author

@rogeruiz rogeruiz Jun 4, 2026

Choose a reason for hiding this comment

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

Yeah fair. I did think about this but wanted to see if this tradeoff would work well. We shouldn't degrade a11y for the sake of design. I have another option that should be more accessible using CSS grid or possibly using a different technique to render a tooltip. The breaking apart was done to allow the tooltip to escape the overflow-hidden on the table.

<thead className="sticky top-0 z-10 bg-white">
<tr className="border-gray-cool-20 text-gray-cool-60 border-b">
<th scope="col" className="w-[10%] pt-3 pb-3">
Include
</th>
<th scope="col" className="w-[40%] pt-3 pb-3 text-left">
Section name
</th>
<th scope="col" className="align-right w-[33%] pt-3 pb-3">
<div className="flex items-center justify-center gap-1">
<span>Data handling approach</span>
<Tooltip
position="left"
label={`Set to "Refine & optimize" if you'd like to filter the
content of this section down to coded elements matching the
codes in your configuration in the refined output. Set to
"Preserve & retain" if you'd like to keep the information in
this section in its entirety in the refined output.`}
/>
</div>
</td>
<td>
<SectionName
configurationId={configurationId}
section={section}
disabled={disabled}
setSelectedSection={() => onSelectedSection(section)}
/>
</td>
<td>
{section.include ? (
<div className="flex justify-center">
{isNarrativeSection(section.code) ? (
<span
className="text-gray-cool-50 text-center italic lg:text-right"
aria-hidden
>
Not applicable for this section
</span>
) : (
<RefineSwitch
configurationId={configurationId}
currentSection={section}
sections={sectionProcessing}
disabled={disabled || isDisabledSection(section.code)}
/>
)}
</div>
) : null}
</td>
<td>
{section.include ? (
<div className="flex justify-center">
<NarrativeSwitch
</th>
<th scope="col" className="w-[17%] pt-3 pb-3">
<div className="flex items-center justify-center gap-1">
<span>Narrative</span>
<Tooltip
position="left"
label="Enable to retain the narrative block for this section in the refined output or disable to omit it."
/>
</div>
</th>
</tr>
</thead>
<tbody className="divide-gray-cool-20 divide-y">
{sectionProcessing.map((section) => (
<tr key={section.code} className="text-gray-cool-60">
<td className="w-[10%]">
<div className="flex justify-center p-8">
<IncludeCheckbox
configurationId={configurationId}
currentSection={section}
sections={sectionProcessing}
disabled={disabled || isDisabledSection(section.code)}
/>
</div>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</td>
<td className="w-[40%]">
<SectionName
configurationId={configurationId}
section={section}
disabled={disabled}
setSelectedSection={() => onSelectedSection(section)}
/>
</td>
<td className="w-[33%]">
{section.include ? (
<div className="flex justify-center">
{isNarrativeSection(section.code) ? (
<span
className="text-gray-cool-50 text-center italic lg:text-right"
aria-hidden
>
Not applicable for this section
</span>
) : (
<RefineSwitch
configurationId={configurationId}
currentSection={section}
sections={sectionProcessing}
disabled={disabled || isDisabledSection(section.code)}
/>
)}
</div>
) : null}
</td>

<td className="w-[17%]">
{section.include ? (
<div className="flex justify-center">
<NarrativeSwitch
configurationId={configurationId}
currentSection={section}
sections={sectionProcessing}
disabled={disabled || isDisabledSection(section.code)}
/>
</div>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
Expand Down Expand Up @@ -220,27 +224,25 @@ function SectionName({
</span>
{isCustom ? <CustomSectionBadge /> : null}
</div>
{isCustom ? (
<div className="flex items-center gap-2">
<span className="truncate text-sm">{section.code}</span>
{disabled ? null : (
<div className="flex items-center gap-1">
<EditButton
name={section.name}
setSelectedSection={setSelectedSection}
/>
<span className="text-sm" aria-hidden>
|
</span>
<DeleteButton
configurationId={configurationId}
code={section.code}
name={section.name}
/>
</div>
)}
</div>
) : null}
<div className="flex items-center gap-2">
<span className="truncate text-sm">{section.code}</span>
{isCustom && !disabled ? (
<div className="flex items-center gap-1">
<EditButton
name={section.name}
setSelectedSection={setSelectedSection}
/>
<span className="text-sm" aria-hidden>
|
</span>
<DeleteButton
configurationId={configurationId}
code={section.code}
name={section.name}
/>
</div>
) : null}
</div>
</div>
);
}
Expand Down
Loading
Loading