Merge pull request 'feat(ui): viewport-aware InfoPopover replaces broken metric tooltips' (#81) from feat/info-popover-tooltip into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 5m2s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 5m2s
Reviewed-on: #81
This commit was merged in pull request #81.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfoPopover } from '@/components/InfoPopover';
|
||||
|
||||
describe('InfoPopover', () => {
|
||||
it('renders nothing when there is no plain content', () => {
|
||||
const { container } = render(<InfoPopover label="X" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders a labelled, collapsed trigger button', () => {
|
||||
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
|
||||
const btn = screen.getByRole('button', { name: 'Reading score' });
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens on click and shows label, plain and detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<InfoPopover
|
||||
label="Reading, Writing & Maths"
|
||||
plain="% reaching the expected standard"
|
||||
detail="National average ~60%"
|
||||
ariaLabel="RWM"
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'RWM' }));
|
||||
const tip = await screen.findByRole('tooltip');
|
||||
expect(tip).toHaveTextContent('Reading, Writing & Maths');
|
||||
expect(tip).toHaveTextContent('% reaching the expected standard');
|
||||
expect(tip).toHaveTextContent('National average ~60%');
|
||||
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('closes again on a second click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoPopover plain="body" ariaLabel="Info" />);
|
||||
const btn = screen.getByRole('button', { name: 'Info' });
|
||||
await user.click(btn);
|
||||
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
|
||||
await user.click(btn);
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoPopover plain="body" ariaLabel="Info" />);
|
||||
await user.click(screen.getByRole('button', { name: 'Info' }));
|
||||
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults the accessible name when no ariaLabel is given', () => {
|
||||
render(<InfoPopover plain="body" />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'More information' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MetricTooltip } from '@/components/MetricTooltip';
|
||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||
|
||||
describe('MetricTooltip', () => {
|
||||
it('resolves content from a metricKey', async () => {
|
||||
const key = Object.keys(METRIC_EXPLANATIONS)[0];
|
||||
const exp = METRIC_EXPLANATIONS[key];
|
||||
const user = userEvent.setup();
|
||||
render(<MetricTooltip metricKey={key} />);
|
||||
// Collapsed by default — the popover content is not in the DOM until opened
|
||||
// (the old component left an always-present role="tooltip" span behind).
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: `What does ${exp.label} mean?` }));
|
||||
const tip = await screen.findByRole('tooltip');
|
||||
expect(tip).toHaveTextContent(exp.plain);
|
||||
});
|
||||
|
||||
it('renders nothing for an unknown metricKey with no explicit content', () => {
|
||||
const { container } = render(<MetricTooltip metricKey="__nope__" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('lets explicit props override the looked-up explanation', async () => {
|
||||
const key = Object.keys(METRIC_EXPLANATIONS)[0];
|
||||
const user = userEvent.setup();
|
||||
render(<MetricTooltip metricKey={key} plain="custom text" />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { RowLabel } from '@/components/compare/sectionShared';
|
||||
|
||||
describe('RowLabel', () => {
|
||||
it('renders its label text', () => {
|
||||
render(<RowLabel>Attainment 8</RowLabel>);
|
||||
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no help affordance when no tip is given', () => {
|
||||
render(<RowLabel>Attainment 8</RowLabel>);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the tip in a popover on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RowLabel tip="Average GCSE score across 8 subjects">Attainment 8</RowLabel>);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'Average GCSE score across 8 subjects',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin: -6px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
/* font-size:0 hides the button's own "?" text node; the ::before glyph
|
||||
below carries the visible circled "?" at its own explicit size. */
|
||||
font-size: 0;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
cursor: help;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
/* The visible affordance: a small circled "?" centred in the 24px target. */
|
||||
.icon::before {
|
||||
content: '?';
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.icon:hover,
|
||||
.icon[aria-expanded='true'],
|
||||
.icon:focus-visible {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 9999;
|
||||
width: max-content;
|
||||
max-width: min(260px, calc(100vw - 24px));
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--bg-primary, #faf7f2);
|
||||
stroke: var(--border-color, #e8ddd4);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
}
|
||||
|
||||
.plain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5a4a44);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
arrow,
|
||||
useHover,
|
||||
useFocus,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
FloatingPortal,
|
||||
FloatingArrow,
|
||||
} from '@floating-ui/react';
|
||||
import styles from './InfoPopover.module.css';
|
||||
|
||||
export interface InfoPopoverProps {
|
||||
label?: string;
|
||||
plain?: string;
|
||||
detail?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const arrowRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: 'top',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ fallbackAxisSideDirection: 'start' }),
|
||||
shift({ padding: 8 }),
|
||||
arrow({ element: arrowRef, padding: 8 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
|
||||
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
|
||||
// so tap and hover never double-fire.
|
||||
const hover = useHover(context, { delay: { open: 100, close: 0 } });
|
||||
const focus = useFocus(context);
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context, { role: 'tooltip' });
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
hover,
|
||||
focus,
|
||||
click,
|
||||
dismiss,
|
||||
role,
|
||||
]);
|
||||
|
||||
if (!plain) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={refs.setReference}
|
||||
className={styles.icon}
|
||||
aria-label={ariaLabel ?? 'More information'}
|
||||
aria-expanded={open}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{open && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
className={styles.tooltip}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
<span className={styles.plain}>{plain}</span>
|
||||
{detail && <span className={styles.detail}>{detail}</span>}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.icon {
|
||||
/* A real button: 24px tap target (WCAG 2.5.8) drawn as the small glyph. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin: -6px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
cursor: help;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.wrapper:hover .icon,
|
||||
.icon[aria-expanded="true"] {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
width: 220px;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
}
|
||||
|
||||
/* Reveal on hover (desktop), keyboard focus, or explicit tap/click toggle. */
|
||||
.wrapper:hover .tooltip,
|
||||
.wrapper:focus-within .tooltip,
|
||||
.tooltipOpen {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltipOpen {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Small arrow pointing down */
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--border-color, #e8ddd4);
|
||||
}
|
||||
|
||||
.tooltipLabel {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
}
|
||||
|
||||
.tooltipPlain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5a4a44);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tooltipDetail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tooltip {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Anchor the bubble to open rightward on phones — icons follow their labels,
|
||||
which start at the left edge, so centring pushed the bubble off-screen. */
|
||||
@media (max-width: 640px) {
|
||||
.tooltip {
|
||||
left: -12px;
|
||||
right: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
left: 16px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||
import styles from './MetricTooltip.module.css';
|
||||
import { InfoPopover } from './InfoPopover';
|
||||
|
||||
interface MetricTooltipProps {
|
||||
metricKey?: string;
|
||||
@@ -13,51 +12,13 @@ interface MetricTooltipProps {
|
||||
|
||||
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
||||
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
||||
const tooltipLabel = label ?? explanation?.label;
|
||||
const tooltipPlain = plain ?? explanation?.plain;
|
||||
const tooltipDetail = detail ?? explanation?.detail;
|
||||
|
||||
// Tap/click/keyboard toggle so the definition is reachable on touch devices
|
||||
// and by keyboard, not just mouse hover (hover still works on desktop).
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const dismiss = (e: Event) => {
|
||||
if (wrapperRef.current && e.target instanceof Node && !wrapperRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', dismiss);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('click', dismiss);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!tooltipPlain) return null;
|
||||
|
||||
const resolvedLabel = label ?? explanation?.label;
|
||||
return (
|
||||
<span className={styles.wrapper} ref={wrapperRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.icon}
|
||||
aria-expanded={open}
|
||||
aria-label={`What does ${tooltipLabel ?? 'this metric'} mean?`}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
<span className={`${styles.tooltip}${open ? ` ${styles.tooltipOpen}` : ''}`} role="tooltip">
|
||||
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
|
||||
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
|
||||
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
|
||||
</span>
|
||||
</span>
|
||||
<InfoPopover
|
||||
label={resolvedLabel}
|
||||
plain={plain ?? explanation?.plain}
|
||||
detail={detail ?? explanation?.detail}
|
||||
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,20 +132,6 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.help {
|
||||
display: inline-flex;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.65rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: help;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
|
||||
import { InfoPopover } from '@/components/InfoPopover';
|
||||
import styles from './compareSections.module.css';
|
||||
|
||||
export function Section({
|
||||
@@ -52,11 +53,7 @@ export function RowLabel({ children, tip }: { children: ReactNode; tip?: string
|
||||
return (
|
||||
<div className={styles.rowLabel}>
|
||||
{children}
|
||||
{tip && (
|
||||
<span className={styles.help} title={tip} aria-label={tip}>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
{tip && <InfoPopover plain={tip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+60
@@ -8,6 +8,7 @@
|
||||
"name": "nextjs-app",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.20",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -832,6 +833,59 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.27.20",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
|
||||
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.9",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0",
|
||||
"react-dom": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
|
||||
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -9136,6 +9190,12 @@
|
||||
"url": "https://opencollective.com/synckit"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
|
||||
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.20",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
Reference in New Issue
Block a user