From 3a3c17a3d2806ace453645c3fc40de26e8bb8fb1 Mon Sep 17 00:00:00 2001 From: AShatsila <319486855+AShatsila@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:20:54 +0400 Subject: [PATCH 1/5] fix: badge and label overflow borders --- src/taxonomy/competency-management/CompetencyTree.scss | 10 ++++++++++ .../course-search/CourseSearchBrowse.scss | 2 ++ 2 files changed, 12 insertions(+) diff --git a/src/taxonomy/competency-management/CompetencyTree.scss b/src/taxonomy/competency-management/CompetencyTree.scss index 4f8457ddc2..162bdc7a3e 100644 --- a/src/taxonomy/competency-management/CompetencyTree.scss +++ b/src/taxonomy/competency-management/CompetencyTree.scss @@ -36,7 +36,15 @@ height: calc(100vh - 260px); } + .competency-row__label { + min-width: 0; + overflow-wrap: anywhere; + } + .competency-row__badge { + // Paragon's `.badge` sets `nowrap`, which otherwise cancels + // `overflow-wrap` for a long Competency ID. + white-space: normal; overflow-wrap: anywhere; // Figma calls for a small, squared radius, not Paragon's full `pill` // shape. @@ -112,6 +120,8 @@ padding-left: 5px; .btn-icon-sm { + // Keeps a wrapping label from squeezing the chevron's hit area. + flex-shrink: 0; // `content-box` makes `width`/`height` define the glyph only, so the // padding below grows the hit box without shrinking the glyph. box-sizing: content-box; diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss index e2e41b3e45..add5633e56 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss @@ -39,6 +39,7 @@ height: 100%; .competency-row__badge { + white-space: normal; overflow-wrap: anywhere; border-radius: var(--pgn-size-badge-border-radius-base); @@ -152,6 +153,7 @@ // competency name (its own `` in the JSX) is bold. &__associations-mastery { margin-top: 8px; + overflow-wrap: anywhere; font-size: 15.8px; line-height: 24.5px; letter-spacing: normal; From 9d513cd6c9c427eb5b639679a515d5cfe1f388c3 Mon Sep 17 00:00:00 2001 From: AShatsila <319486855+AShatsila@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:11:04 +0400 Subject: [PATCH 2/5] fix: competency tree style --- src/taxonomy/competency-management/CompetencyTree.scss | 4 ++++ .../associations/CompetencyAssociationsPanel.tsx | 4 +++- .../course-search/CourseSearchBrowse.scss | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/taxonomy/competency-management/CompetencyTree.scss b/src/taxonomy/competency-management/CompetencyTree.scss index 162bdc7a3e..bffe9ea5eb 100644 --- a/src/taxonomy/competency-management/CompetencyTree.scss +++ b/src/taxonomy/competency-management/CompetencyTree.scss @@ -46,6 +46,10 @@ // `overflow-wrap` for a long Competency ID. white-space: normal; overflow-wrap: anywhere; + // A shrinkable badge would be squeezed by a long label (and vice versa), + // so it keeps its own width up to half the row and the label gets the rest. + flex-shrink: 0; + max-width: 50%; // Figma calls for a small, squared radius, not Paragon's full `pill` // shape. border-radius: var(--pgn-size-badge-border-radius-base); diff --git a/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx index f2327e87d5..4aca5a93f5 100644 --- a/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx +++ b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx @@ -50,7 +50,9 @@ const CompetencyAssociationsPanel = ({ taxonomyId, taxonomyName }: CompetencyAss {tree} {selectedCompetency && ( -
+ // A zero basis keeps a long competency name's unwrapped width from + // making flex shrink the tree column below its resizable width. +
)} diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss index add5633e56..3d8acbdab1 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss @@ -145,15 +145,17 @@ // Wraps `&__associations-label` and the "Demonstrate Mastery For"/empty- // state text under it. + // Both the heading and the empty state repeat the competency name, so a + // long one has to wrap in each of them. &__associations { padding: $container-padding-y $container-padding-x; + overflow-wrap: anywhere; } // Figma gives regular weight for this text layer - only the interpolated // competency name (its own `` in the JSX) is bold. &__associations-mastery { margin-top: 8px; - overflow-wrap: anywhere; font-size: 15.8px; line-height: 24.5px; letter-spacing: normal; From efd30f05ec0b8c6f5819941d8a9610e7c8e0210a Mon Sep 17 00:00:00 2001 From: AShatsila <319486855+AShatsila@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:19:32 +0400 Subject: [PATCH 3/5] feat: add tooltip for oveflow ids --- .../competency-management/CompetencyTree.scss | 20 ++- .../CompetencyTree.test.tsx | 130 ++++++++++++++++++ .../CompetencyTreeItem.tsx | 88 +++++++++++- .../CompetencyAssociationsPanel.scss | 6 + .../CompetencyAssociationsPanel.tsx | 5 +- .../course-search/CourseSearchBrowse.scss | 10 +- .../course-search/CourseSearchBrowse.test.tsx | 88 ++++++++++++ .../course-search/CourseSearchBrowse.tsx | 104 +++++++++++++- 8 files changed, 422 insertions(+), 29 deletions(-) create mode 100644 src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.scss diff --git a/src/taxonomy/competency-management/CompetencyTree.scss b/src/taxonomy/competency-management/CompetencyTree.scss index bffe9ea5eb..52d914c954 100644 --- a/src/taxonomy/competency-management/CompetencyTree.scss +++ b/src/taxonomy/competency-management/CompetencyTree.scss @@ -6,6 +6,13 @@ // name used below. @use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css"; +.competency-tooltip { + // Top-level, not under `.competency-tree`: the tooltip renders in a portal + // under ``. Paragon's 200px default makes a long name tall enough to + // run off the top of the viewport. + --pgn-size-tooltip-max-width: 400px; +} + .competency-tree { $line-color: rgb(0 0 0 / .08); $shadow-color: rgb(0 0 0 / .12); @@ -36,18 +43,9 @@ height: calc(100vh - 260px); } - .competency-row__label { - min-width: 0; - overflow-wrap: anywhere; - } - .competency-row__badge { - // Paragon's `.badge` sets `nowrap`, which otherwise cancels - // `overflow-wrap` for a long Competency ID. - white-space: normal; - overflow-wrap: anywhere; - // A shrinkable badge would be squeezed by a long label (and vice versa), - // so it keeps its own width up to half the row and the label gets the rest. + // Keeps its own width up to half the row so a long label can't squeeze + // the ID to nothing; past that, `text-truncate` ellipsizes it. flex-shrink: 0; max-width: 50%; // Figma calls for a small, squared radius, not Paragon's full `pill` diff --git a/src/taxonomy/competency-management/CompetencyTree.test.tsx b/src/taxonomy/competency-management/CompetencyTree.test.tsx index 5d4da911c3..09866f8b12 100644 --- a/src/taxonomy/competency-management/CompetencyTree.test.tsx +++ b/src/taxonomy/competency-management/CompetencyTree.test.tsx @@ -1,8 +1,10 @@ import { + act, fireEvent, initializeMocks, render, screen, + waitFor, within, } from '@src/testUtils'; import { apiUrls } from '@src/taxonomy/data/api'; @@ -399,4 +401,132 @@ describe('', () => { expect(screen.getByRole('treeitem', { name: 'Group A1' })) .toBe(screen.getByText('Group A1').closest('li')); }); + + describe('truncation tooltip', () => { + // jsdom has no layout, so every element reports the same widths. + const stubWidths = (scrollWidth: number, clientWidth: number) => { + jest.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(scrollWidth); + jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(clientWidth); + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const renderExpandedTree = async () => { + axiosMock.onGet(tagListUrl).reply(200, nestedTagsResponse); + renderTree({ onSelectCompetency: jest.fn() }); + await screen.findByText(taxonomyName); + fireEvent.click(screen.getByRole('button', { name: 'Expand All' })); + await screen.findByText('Leaf A1a'); + }; + + const rowOf = (name: string) => screen.getByText(name).closest('.competency-row') as HTMLElement; + + it('shows the full name and Competency ID in a tooltip on hover of a truncated row', async () => { + await renderExpandedTree(); + stubWidths(200, 100); + + fireEvent.mouseEnter(rowOf('Root A')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Root A'); + expect(tooltip).toHaveTextContent('Competency ID: EXT-001'); + }); + + it('shows the tooltip on keyboard focus of a truncated treeitem', async () => { + await renderExpandedTree(); + stubWidths(200, 100); + + fireEvent.focus(screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' })); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Root A'); + expect(tooltip).toHaveTextContent('Competency ID: EXT-001'); + }); + + it('does not show an ancestor group\'s tooltip when a nested treeitem gets focus', async () => { + await renderExpandedTree(); + stubWidths(200, 100); + + fireEvent.focus(screen.getByRole('treeitem', { name: 'Leaf A1a Competency ID: EXT-003' })); + + const tooltips = await screen.findAllByRole('tooltip'); + expect(tooltips).toHaveLength(1); + expect(tooltips[0]).toHaveTextContent('Leaf A1a'); + }); + + it('shows no tooltip for a row that is not truncated', async () => { + await renderExpandedTree(); + stubWidths(100, 100); + + fireEvent.mouseEnter(rowOf('Root A')); + fireEvent.focus(screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' })); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('hides the tooltip on mouse leave and on blur', async () => { + await renderExpandedTree(); + stubWidths(200, 100); + + const row = rowOf('Root A'); + fireEvent.mouseEnter(row); + await screen.findByRole('tooltip'); + fireEvent.mouseLeave(row); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + + const rootItem = screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' }); + fireEvent.focus(rootItem); + await screen.findByRole('tooltip'); + fireEvent.blur(rootItem); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('closes a hover-opened tooltip on Escape', async () => { + await renderExpandedTree(); + stubWidths(200, 100); + + fireEvent.mouseEnter(rowOf('Root A')); + await screen.findByRole('tooltip'); + fireEvent.keyDown(document.body, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('closes a focus-opened tooltip on Escape while keeping focus on the treeitem', async () => { + await renderExpandedTree(); + stubWidths(200, 100); + + const rootItem = screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' }); + rootItem.focus(); + await screen.findByRole('tooltip'); + fireEvent.keyDown(rootItem, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + expect(rootItem).toHaveFocus(); + }); + + it('keeps the tooltip open while the pointer moves from the row onto it, and closes it on leaving', async () => { + await renderExpandedTree(); + stubWidths(200, 100); + + const row = rowOf('Root A'); + fireEvent.mouseEnter(row); + const tooltip = await screen.findByRole('tooltip'); + + fireEvent.mouseLeave(row); + fireEvent.mouseEnter(tooltip); + // Longer than the hide delay, so a missed cancel would have closed it. + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 250); + }); + }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + fireEvent.mouseLeave(tooltip); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + }); }); diff --git a/src/taxonomy/competency-management/CompetencyTreeItem.tsx b/src/taxonomy/competency-management/CompetencyTreeItem.tsx index 37dce35922..a12ad8969e 100644 --- a/src/taxonomy/competency-management/CompetencyTreeItem.tsx +++ b/src/taxonomy/competency-management/CompetencyTreeItem.tsx @@ -1,14 +1,22 @@ -import type { KeyboardEvent, MouseEvent } from 'react'; -import { useId } from 'react'; +import type { FocusEvent, KeyboardEvent, MouseEvent } from 'react'; +import { + useEffect, + useId, + useRef, + useState, +} from 'react'; import classNames from 'classnames'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Badge } from '@openedx/paragon'; +import { Badge, Overlay, Tooltip } from '@openedx/paragon'; import ExpandCollapseIconButton from '@src/generic/ExpandCollapseIconButton'; import type { CompetencyTreeNode } from './CompetencyTree'; import messages from './messages'; +// Lets the pointer travel from the row onto its tooltip (WCAG 1.4.13, hoverable). +const TOOLTIP_HIDE_DELAY_MS = 100; + export interface CompetencyTreeItemProps { node: CompetencyTreeNode; expandedIds: Set; @@ -47,6 +55,12 @@ const CompetencyTreeItem = ({ const intl = useIntl(); const labelId = useId(); const externalIdLabelId = useId(); + const tooltipId = useId(); + const rowRef = useRef(null); + const labelRef = useRef(null); + const badgeRef = useRef(null); + const hideTimer = useRef>(); + const [showTooltip, setShowTooltip] = useState(false); const nodeId = String(node.id); const hasChildren = !!node.subRows?.length; const isExpanded = expandedIds.has(nodeId); @@ -80,6 +94,48 @@ const CompetencyTreeItem = ({ } }; + const cancelHide = () => clearTimeout(hideTimer.current); + const closeTooltip = () => { + cancelHide(); + setShowTooltip(false); + }; + const closeTooltipSoon = () => { + cancelHide(); + hideTimer.current = setTimeout(closeTooltip, TOOLTIP_HIDE_DELAY_MS); + }; + + // Measured when hover or focus starts, so a width changed since the last + // render (e.g. by dragging the divider) is accounted for. + const openTooltipIfTruncated = () => { + cancelHide(); + const isTruncated = [labelRef.current, badgeRef.current].some( + (el) => !!el && el.scrollWidth > el.clientWidth, + ); + setShowTooltip(isTruncated); + }; + + useEffect(() => () => clearTimeout(hideTimer.current), []); + + // Document-level so Escape dismisses a hover-opened tooltip too, without + // moving focus (WCAG 1.4.13, dismissible). + useEffect(() => { + if (!showTooltip) { return undefined; } + const handleEscape = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { setShowTooltip(false); } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [showTooltip]); + + // React focus events bubble, so a nested row's focus would also reach this + // handler; only this row's own `
  • ` may open its tooltip. + const handleFocus = (event: FocusEvent) => { + if (event.target === event.currentTarget) { openTooltipIfTruncated(); } + }; + const handleBlur = (event: FocusEvent) => { + if (event.target === event.currentTarget) { closeTooltip(); } + }; + return (
  • -
    +
    - {node.value} + {node.value} {externalId ? ( <> @@ -112,7 +175,8 @@ const CompetencyTreeItem = ({
    {isExpanded && hasChildren && (
      diff --git a/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.scss b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.scss new file mode 100644 index 0000000000..565416fe37 --- /dev/null +++ b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.scss @@ -0,0 +1,6 @@ +.competency-associations__course-column { + // A zero basis keeps a long competency name's unwrapped width from + // making flex shrink the tree column below its resizable width. + flex-basis: 0; + min-width: 0; +} diff --git a/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx index 4aca5a93f5..2b9d73f5e3 100644 --- a/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx +++ b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx @@ -6,6 +6,7 @@ import { ResizableBox } from '@src/generic/resizable/Resizable'; import { useIsDesktop } from '@src/utils'; import CompetencyTree, { type CompetencyTreeNode } from '../CompetencyTree'; import { CourseSearchBrowse } from '../course-search'; +import './CompetencyAssociationsPanel.scss'; export interface CompetencyAssociationsPanelProps { taxonomyId: number; @@ -50,9 +51,7 @@ const CompetencyAssociationsPanel = ({ taxonomyId, taxonomyName }: CompetencyAss {tree} {selectedCompetency && ( - // A zero basis keeps a long competency name's unwrapped width from - // making flex shrink the tree column below its resizable width. -
      +
      )} diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss index 3d8acbdab1..b12ba66b73 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss @@ -39,8 +39,10 @@ height: 100%; .competency-row__badge { - white-space: normal; - overflow-wrap: anywhere; + // Keeps its own width up to half the heading; past that, `text-truncate` + // ellipsizes it. + flex-shrink: 0; + max-width: 50%; border-radius: var(--pgn-size-badge-border-radius-base); --pgn-spacing-badge-padding-y: 1.5px; @@ -145,8 +147,8 @@ // Wraps `&__associations-label` and the "Demonstrate Mastery For"/empty- // state text under it. - // Both the heading and the empty state repeat the competency name, so a - // long one has to wrap in each of them. + // The empty state repeats the competency name, so a long one has to wrap + // there. &__associations { padding: $container-padding-y $container-padding-x; overflow-wrap: anywhere; diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx index 1faefe3841..e0a44df679 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx @@ -113,6 +113,94 @@ describe(' and ', () => { expect(screen.getByText('Competency ID: EXT-042')).toBeInTheDocument(); }); + describe('"Demonstrate Mastery For" heading truncation', () => { + const competencyWithExternalId: CompetencyTreeNode = { ...activeCompetency, externalId: 'EXT-042' }; + + // jsdom has no layout, so every element reports the same widths. + const stubWidths = (scrollWidth: number, clientWidth: number) => { + jest.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(scrollWidth); + jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(clientWidth); + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const renderAndGetHeading = async () => { + axiosMock.onGet(coursesApiUrl).reply(200, buildResponse([buildCourse()])); + render(); + await screen.findByText('Intro to Testing'); + return screen.getAllByText('Test Competency')[0] + .closest('.course-search-browse__associations-mastery') as HTMLElement; + }; + + it('is focusable and shows the full name and Competency ID in a tooltip on focus and hover when truncated', async () => { + stubWidths(200, 100); + const heading = await renderAndGetHeading(); + + expect(heading).toHaveAttribute('tabindex', '0'); + + fireEvent.focus(heading); + let tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Test Competency'); + expect(tooltip).toHaveTextContent('Competency ID: EXT-042'); + + fireEvent.blur(heading); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + + fireEvent.mouseEnter(heading); + tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Test Competency'); + }); + + it('closes the tooltip on Escape without moving focus', async () => { + stubWidths(200, 100); + const heading = await renderAndGetHeading(); + + heading.focus(); + await screen.findByRole('tooltip'); + fireEvent.keyDown(heading, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + expect(heading).toHaveFocus(); + + fireEvent.mouseEnter(heading); + await screen.findByRole('tooltip'); + fireEvent.keyDown(document.body, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('keeps the tooltip open while the pointer moves from the heading onto it, and closes it on leaving', async () => { + stubWidths(200, 100); + const heading = await renderAndGetHeading(); + + fireEvent.mouseEnter(heading); + const tooltip = await screen.findByRole('tooltip'); + + fireEvent.mouseLeave(heading); + fireEvent.mouseEnter(tooltip); + // Longer than the hide delay, so a missed cancel would have closed it. + act(() => { + jest.advanceTimersByTime(250); + }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + fireEvent.mouseLeave(tooltip); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('has no tabindex and shows no tooltip when not truncated', async () => { + stubWidths(100, 100); + const heading = await renderAndGetHeading(); + + fireEvent.mouseEnter(heading); + fireEvent.focus(heading); + + expect(heading).not.toHaveAttribute('tabindex'); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + }); + it('renders no Competency ID badge when externalId is falsy', async () => { axiosMock.onGet(coursesApiUrl).reply(200, buildResponse([buildCourse()])); render(); diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx index f34d1a6799..2a3cfbab5d 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx @@ -4,7 +4,9 @@ import { type ReactNode, useCallback, useEffect, + useId, useMemo, + useRef, useState, } from 'react'; @@ -13,10 +15,12 @@ import { Badge, Button, IconButton, + Overlay, Pagination, Row, SearchField, Stack, + Tooltip, } from '@openedx/paragon'; import { Calendar, Close } from '@openedx/paragon/icons'; import { debounce } from 'lodash'; @@ -33,6 +37,9 @@ import messages from './messages'; // @ts-ignore import './CourseSearchBrowse.scss'; +// Lets the pointer travel from the heading onto its tooltip (WCAG 1.4.13, hoverable). +const TOOLTIP_HIDE_DELAY_MS = 100; + export interface CourseSearchBrowseProps { /** The competency currently selected in the competency tree. The parent * only mounts this component once a competency is active - see @@ -245,6 +252,60 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { ); } + const tooltipId = useId(); + const headingRef = useRef(null); + const nameRef = useRef(null); + const badgeRef = useRef(null); + const [isHeadingTruncated, setIsHeadingTruncated] = useState(false); + const hideTimer = useRef>(); + const [showTooltip, setShowTooltip] = useState(false); + + const measureHeading = useCallback( + () => [nameRef.current, badgeRef.current].some((el) => !!el && el.scrollWidth > el.clientWidth), + [], + ); + + // Observing the heading catches width changes from dragging the + // tree/course divider as well as window resizes. + useEffect(() => { + const heading = headingRef.current; + if (!heading) { return undefined; } + setIsHeadingTruncated(measureHeading()); + const resizeObserver = new ResizeObserver(() => setIsHeadingTruncated(measureHeading())); + resizeObserver.observe(heading); + return () => resizeObserver.disconnect(); + }, [measureHeading, activeCompetency]); + + const cancelHide = () => clearTimeout(hideTimer.current); + const closeTooltip = () => { + cancelHide(); + setShowTooltip(false); + }; + const closeTooltipSoon = () => { + cancelHide(); + hideTimer.current = setTimeout(closeTooltip, TOOLTIP_HIDE_DELAY_MS); + }; + + const openTooltipIfTruncated = () => { + cancelHide(); + const isTruncated = measureHeading(); + setIsHeadingTruncated(isTruncated); + setShowTooltip(isTruncated); + }; + + useEffect(() => () => clearTimeout(hideTimer.current), []); + + // Document-level so Escape dismisses a hover-opened tooltip too, without + // moving focus (WCAG 1.4.13, dismissible). + useEffect(() => { + if (!showTooltip) { return undefined; } + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { setShowTooltip(false); } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [showTooltip]); + return (
      @@ -291,10 +352,20 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => {
      {intl.formatMessage(messages.associationsSectionLabel)}
      -
      - {intl.formatMessage(messages.demonstrateMasteryForLabel, { - competencyName: {activeCompetency.value}, - })} +
      + + {intl.formatMessage(messages.demonstrateMasteryForLabel, { + competencyName: {activeCompetency.value}, + })} + {activeCompetency.externalId && ( <> @@ -302,11 +373,34 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { externalId: activeCompetency.externalId, })} -

      From 4f4c09334f3e3d2ff7bd818efcbd0a6a55c922f9 Mon Sep 17 00:00:00 2001 From: AShatsila <319486855+AShatsila@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:30:31 +0400 Subject: [PATCH 4/5] refactor: tooltip behavior --- .../CompetencyTooltip.scss | 6 + .../CompetencyTooltip.test.tsx | 167 ++++++++++++++++++ .../CompetencyTooltip.tsx | 54 ++++++ .../competency-management/CompetencyTree.scss | 14 +- .../CompetencyTree.test.tsx | 102 +++-------- .../CompetencyTreeItem.tsx | 83 +++------ .../course-search/CourseSearchBrowse.scss | 5 +- .../course-search/CourseSearchBrowse.test.tsx | 81 ++++----- .../course-search/CourseSearchBrowse.tsx | 76 ++------ .../useCompetencyTooltip.ts | 68 +++++++ 10 files changed, 399 insertions(+), 257 deletions(-) create mode 100644 src/taxonomy/competency-management/CompetencyTooltip.scss create mode 100644 src/taxonomy/competency-management/CompetencyTooltip.test.tsx create mode 100644 src/taxonomy/competency-management/CompetencyTooltip.tsx create mode 100644 src/taxonomy/competency-management/useCompetencyTooltip.ts diff --git a/src/taxonomy/competency-management/CompetencyTooltip.scss b/src/taxonomy/competency-management/CompetencyTooltip.scss new file mode 100644 index 0000000000..1d9b064bfa --- /dev/null +++ b/src/taxonomy/competency-management/CompetencyTooltip.scss @@ -0,0 +1,6 @@ +.competency-tooltip { + // Top-level, not under `.competency-tree`: the tooltip renders in a portal + // under ``. Paragon's 200px default makes a long name tall enough to + // run off the top of the viewport. + --pgn-size-tooltip-max-width: 400px; +} diff --git a/src/taxonomy/competency-management/CompetencyTooltip.test.tsx b/src/taxonomy/competency-management/CompetencyTooltip.test.tsx new file mode 100644 index 0000000000..fff96e73ef --- /dev/null +++ b/src/taxonomy/competency-management/CompetencyTooltip.test.tsx @@ -0,0 +1,167 @@ +import { useRef } from 'react'; + +import { + act, + fireEvent, + initializeMocks, + render, + screen, + waitFor, +} from '@src/testUtils'; +import CompetencyTooltip from './CompetencyTooltip'; +import { useCompetencyTooltip } from './useCompetencyTooltip'; + +interface HostProps { + isTruncated?: boolean; +} + +const Host = ({ isTruncated = true }: HostProps) => { + const anchorRef = useRef(null); + const tooltip = useCompetencyTooltip(() => isTruncated); + return ( +

      +
      + Row + +
      +
      + ); +}; + +const advance = (ms: number) => + act(() => { + jest.advanceTimersByTime(ms); + }); + +const pressEscape = () => fireEvent.keyUp(document, { key: 'Escape', keyCode: 27 }); + +describe(' with useCompetencyTooltip', () => { + beforeEach(() => { + jest.useFakeTimers(); + initializeMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const renderHost = (props: HostProps = {}) => { + render(); + return screen.getByTestId('trigger'); + }; + + it('opens on hover only after the show delay, with the name and Competency ID', () => { + const trigger = renderHost(); + + fireEvent.mouseEnter(trigger); + advance(299); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + advance(1); + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Full name'); + expect(tooltip).toHaveTextContent('Competency ID: EXT-1'); + }); + + it('does not open when the pointer leaves before the show delay', () => { + const trigger = renderHost(); + + fireEvent.mouseEnter(trigger); + advance(200); + fireEvent.mouseLeave(trigger); + advance(500); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('opens immediately on keyboard focus', () => { + const trigger = renderHost(); + + fireEvent.focus(trigger); + + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + it('does not open when the trigger is not truncated', () => { + const trigger = renderHost({ isTruncated: false }); + + fireEvent.focus(trigger); + fireEvent.mouseEnter(trigger); + advance(500); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('closes on mouse leave', async () => { + const trigger = renderHost(); + fireEvent.mouseEnter(trigger); + advance(300); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + fireEvent.mouseLeave(trigger); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('closes on blur', async () => { + const trigger = renderHost(); + fireEvent.focus(trigger); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + fireEvent.blur(trigger); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('closes on Escape without moving focus', async () => { + const trigger = renderHost(); + act(() => trigger.focus()); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + pressEscape(); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + expect(trigger).toHaveFocus(); + }); + + it('closes a hover-opened tooltip on Escape', async () => { + const trigger = renderHost(); + fireEvent.mouseEnter(trigger); + advance(300); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + pressEscape(); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('stays open while the pointer moves from the trigger onto the tooltip, and closes on leaving it', async () => { + const trigger = renderHost(); + fireEvent.mouseEnter(trigger); + advance(300); + const tooltip = screen.getByRole('tooltip'); + + // React derives enter/leave from mouseout, so the portaled tooltip counts as inside the trigger. + fireEvent.mouseOut(trigger, { relatedTarget: tooltip }); + // Longer than the hide delay, so a missed cancel would have closed it. + advance(250); + expect(tooltip).toHaveClass('show'); + + fireEvent.mouseOut(tooltip, { relatedTarget: document.body }); + advance(150); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); +}); diff --git a/src/taxonomy/competency-management/CompetencyTooltip.tsx b/src/taxonomy/competency-management/CompetencyTooltip.tsx new file mode 100644 index 0000000000..38ce8df837 --- /dev/null +++ b/src/taxonomy/competency-management/CompetencyTooltip.tsx @@ -0,0 +1,54 @@ +import { useId, type RefObject } from 'react'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Overlay, Tooltip } from '@openedx/paragon'; + +import messages from './messages'; +import './CompetencyTooltip.scss'; + +export interface CompetencyTooltipProps { + anchorRef: RefObject; + show: boolean; + name: string; + externalId?: string | null; + onHide: () => void; +} + +/** + * Full name and Competency ID of a truncated competency, shown above `anchorRef`. + * Pair with `useCompetencyTooltip`, which supplies `show` and `onHide`. + * Render it inside the trigger element, so React treats the portaled tooltip as part of the trigger for mouse enter/leave. + */ +const CompetencyTooltip = ({ + anchorRef, + show, + name, + externalId, + onHide, +}: CompetencyTooltipProps) => { + const intl = useIntl(); + const id = useId(); + + // `rootClose` dismisses on Escape (keyup) and on an outside click, without moving focus (WCAG 1.4.13). + return ( + anchorRef.current} + show={show} + onHide={onHide} + rootClose + placement="top" + flip + > + +
      {name}
      + {externalId &&
      {intl.formatMessage(messages.competencyIdAccessibleLabel, { externalId })}
      } +
      +
      + ); +}; + +export default CompetencyTooltip; diff --git a/src/taxonomy/competency-management/CompetencyTree.scss b/src/taxonomy/competency-management/CompetencyTree.scss index 52d914c954..c1654736e4 100644 --- a/src/taxonomy/competency-management/CompetencyTree.scss +++ b/src/taxonomy/competency-management/CompetencyTree.scss @@ -6,13 +6,6 @@ // name used below. @use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css"; -.competency-tooltip { - // Top-level, not under `.competency-tree`: the tooltip renders in a portal - // under ``. Paragon's 200px default makes a long name tall enough to - // run off the top of the viewport. - --pgn-size-tooltip-max-width: 400px; -} - .competency-tree { $line-color: rgb(0 0 0 / .08); $shadow-color: rgb(0 0 0 / .12); @@ -44,10 +37,9 @@ } .competency-row__badge { - // Keeps its own width up to half the row so a long label can't squeeze - // the ID to nothing; past that, `text-truncate` ellipsizes it. + // Keeps its width up to 6rem so a long label can't squeeze the ID; past that, `text-truncate` ellipsizes it. flex-shrink: 0; - max-width: 50%; + max-width: 6rem; // Figma calls for a small, squared radius, not Paragon's full `pill` // shape. border-radius: var(--pgn-size-badge-border-radius-base); @@ -122,7 +114,7 @@ padding-left: 5px; .btn-icon-sm { - // Keeps a wrapping label from squeezing the chevron's hit area. + // Keeps a long label from shrinking the chevron along with it. flex-shrink: 0; // `content-box` makes `width`/`height` define the glyph only, so the // padding below grows the hit box without shrinking the glyph. diff --git a/src/taxonomy/competency-management/CompetencyTree.test.tsx b/src/taxonomy/competency-management/CompetencyTree.test.tsx index 09866f8b12..9e0f641f40 100644 --- a/src/taxonomy/competency-management/CompetencyTree.test.tsx +++ b/src/taxonomy/competency-management/CompetencyTree.test.tsx @@ -4,7 +4,6 @@ import { initializeMocks, render, screen, - waitFor, within, } from '@src/testUtils'; import { apiUrls } from '@src/taxonomy/data/api'; @@ -82,6 +81,21 @@ const buildDuplicateValueResponse = () => ({ ], }); +const longName = + 'Demonstrate the ability to analyze and communicate complex quantitative findings to non-technical stakeholders'; +const longExternalId = 'COMPETENCY-FRAMEWORK-2024-QUANTITATIVE-REASONING-0042'; + +const buildLongTextResponse = () => ({ + ...nestedTagsResponse, + count: 1, + results: [{ + ...tagDefaults, + id: 20, + value: longName, + external_id: longExternalId, + }], +}); + const taxonomyName = 'Test Taxonomy'; const renderTree = (extraProps: { @@ -423,26 +437,17 @@ describe('', () => { const rowOf = (name: string) => screen.getByText(name).closest('.competency-row') as HTMLElement; - it('shows the full name and Competency ID in a tooltip on hover of a truncated row', async () => { - await renderExpandedTree(); - stubWidths(200, 100); - - fireEvent.mouseEnter(rowOf('Root A')); - - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent('Root A'); - expect(tooltip).toHaveTextContent('Competency ID: EXT-001'); - }); - - it('shows the tooltip on keyboard focus of a truncated treeitem', async () => { - await renderExpandedTree(); + it('shows the complete name and Competency ID in a tooltip on hover of a truncated row', async () => { + axiosMock.onGet(tagListUrl).reply(200, buildLongTextResponse()); + renderTree({ onSelectCompetency: jest.fn() }); + await screen.findByText(longName); stubWidths(200, 100); - fireEvent.focus(screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' })); + fireEvent.mouseEnter(rowOf(longName)); const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent('Root A'); - expect(tooltip).toHaveTextContent('Competency ID: EXT-001'); + expect(within(tooltip).getByText(longName, { exact: true })).toBeInTheDocument(); + expect(within(tooltip).getByText(`Competency ID: ${longExternalId}`, { exact: true })).toBeInTheDocument(); }); it('does not show an ancestor group\'s tooltip when a nested treeitem gets focus', async () => { @@ -462,71 +467,14 @@ describe('', () => { fireEvent.mouseEnter(rowOf('Root A')); fireEvent.focus(screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' })); - - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); - }); - - it('hides the tooltip on mouse leave and on blur', async () => { - await renderExpandedTree(); - stubWidths(200, 100); - - const row = rowOf('Root A'); - fireEvent.mouseEnter(row); - await screen.findByRole('tooltip'); - fireEvent.mouseLeave(row); - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - - const rootItem = screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' }); - fireEvent.focus(rootItem); - await screen.findByRole('tooltip'); - fireEvent.blur(rootItem); - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - }); - - it('closes a hover-opened tooltip on Escape', async () => { - await renderExpandedTree(); - stubWidths(200, 100); - - fireEvent.mouseEnter(rowOf('Root A')); - await screen.findByRole('tooltip'); - fireEvent.keyDown(document.body, { key: 'Escape' }); - - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - }); - - it('closes a focus-opened tooltip on Escape while keeping focus on the treeitem', async () => { - await renderExpandedTree(); - stubWidths(200, 100); - - const rootItem = screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' }); - rootItem.focus(); - await screen.findByRole('tooltip'); - fireEvent.keyDown(rootItem, { key: 'Escape' }); - - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - expect(rootItem).toHaveFocus(); - }); - - it('keeps the tooltip open while the pointer moves from the row onto it, and closes it on leaving', async () => { - await renderExpandedTree(); - stubWidths(200, 100); - - const row = rowOf('Root A'); - fireEvent.mouseEnter(row); - const tooltip = await screen.findByRole('tooltip'); - - fireEvent.mouseLeave(row); - fireEvent.mouseEnter(tooltip); - // Longer than the hide delay, so a missed cancel would have closed it. + // Past the hover show delay, so a tooltip that was going to open has. await act(async () => { await new Promise((resolve) => { - setTimeout(resolve, 250); + setTimeout(resolve, 400); }); }); - expect(screen.getByRole('tooltip')).toBeInTheDocument(); - fireEvent.mouseLeave(tooltip); - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); }); }); diff --git a/src/taxonomy/competency-management/CompetencyTreeItem.tsx b/src/taxonomy/competency-management/CompetencyTreeItem.tsx index a12ad8969e..23224c60f4 100644 --- a/src/taxonomy/competency-management/CompetencyTreeItem.tsx +++ b/src/taxonomy/competency-management/CompetencyTreeItem.tsx @@ -1,22 +1,16 @@ import type { FocusEvent, KeyboardEvent, MouseEvent } from 'react'; -import { - useEffect, - useId, - useRef, - useState, -} from 'react'; +import { useId, useRef } from 'react'; import classNames from 'classnames'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Badge, Overlay, Tooltip } from '@openedx/paragon'; +import { Badge } from '@openedx/paragon'; import ExpandCollapseIconButton from '@src/generic/ExpandCollapseIconButton'; import type { CompetencyTreeNode } from './CompetencyTree'; +import CompetencyTooltip from './CompetencyTooltip'; +import { useCompetencyTooltip } from './useCompetencyTooltip'; import messages from './messages'; -// Lets the pointer travel from the row onto its tooltip (WCAG 1.4.13, hoverable). -const TOOLTIP_HIDE_DELAY_MS = 100; - export interface CompetencyTreeItemProps { node: CompetencyTreeNode; expandedIds: Set; @@ -55,12 +49,9 @@ const CompetencyTreeItem = ({ const intl = useIntl(); const labelId = useId(); const externalIdLabelId = useId(); - const tooltipId = useId(); const rowRef = useRef(null); const labelRef = useRef(null); const badgeRef = useRef(null); - const hideTimer = useRef>(); - const [showTooltip, setShowTooltip] = useState(false); const nodeId = String(node.id); const hasChildren = !!node.subRows?.length; const isExpanded = expandedIds.has(nodeId); @@ -94,46 +85,18 @@ const CompetencyTreeItem = ({ } }; - const cancelHide = () => clearTimeout(hideTimer.current); - const closeTooltip = () => { - cancelHide(); - setShowTooltip(false); - }; - const closeTooltipSoon = () => { - cancelHide(); - hideTimer.current = setTimeout(closeTooltip, TOOLTIP_HIDE_DELAY_MS); - }; - - // Measured when hover or focus starts, so a width changed since the last - // render (e.g. by dragging the divider) is accounted for. - const openTooltipIfTruncated = () => { - cancelHide(); - const isTruncated = [labelRef.current, badgeRef.current].some( - (el) => !!el && el.scrollWidth > el.clientWidth, - ); - setShowTooltip(isTruncated); - }; - - useEffect(() => () => clearTimeout(hideTimer.current), []); - - // Document-level so Escape dismisses a hover-opened tooltip too, without - // moving focus (WCAG 1.4.13, dismissible). - useEffect(() => { - if (!showTooltip) { return undefined; } - const handleEscape = (event: globalThis.KeyboardEvent) => { - if (event.key === 'Escape') { setShowTooltip(false); } - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [showTooltip]); + // Measured each time the tooltip is about to open, so a width changed since + // the last render (e.g. by dragging the divider) is accounted for. + const tooltip = useCompetencyTooltip( + () => [labelRef.current, badgeRef.current].some((el) => !!el && el.scrollWidth > el.clientWidth), + ); - // React focus events bubble, so a nested row's focus would also reach this - // handler; only this row's own `
    • ` may open its tooltip. + // React focus events bubble; only this row's own `
    • ` may open its tooltip. const handleFocus = (event: FocusEvent) => { - if (event.target === event.currentTarget) { openTooltipIfTruncated(); } + if (event.target === event.currentTarget) { tooltip.openOnFocus(); } }; const handleBlur = (event: FocusEvent) => { - if (event.target === event.currentTarget) { closeTooltip(); } + if (event.target === event.currentTarget) { tooltip.close(); } }; return ( @@ -156,8 +119,8 @@ const CompetencyTreeItem = ({
      ) : null} - - -
      {node.value}
      - {externalId &&
      {intl.formatMessage(messages.competencyIdAccessibleLabel, { externalId })}
      } -
      -
      +
      {isExpanded && hasChildren && (
        diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss index b12ba66b73..b3c9bf9035 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss @@ -39,10 +39,9 @@ height: 100%; .competency-row__badge { - // Keeps its own width up to half the heading; past that, `text-truncate` - // ellipsizes it. + // Keeps its width up to 6rem so a long name can't squeeze the ID; past that, `text-truncate` ellipsizes it. flex-shrink: 0; - max-width: 50%; + max-width: 6rem; border-radius: var(--pgn-size-badge-border-radius-base); --pgn-spacing-badge-padding-y: 1.5px; diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx index e0a44df679..8b5731da49 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx @@ -134,70 +134,65 @@ describe(' and ', () => { .closest('.course-search-browse__associations-mastery') as HTMLElement; }; - it('is focusable and shows the full name and Competency ID in a tooltip on focus and hover when truncated', async () => { + it('is focusable and shows the tooltip on focus when truncated', async () => { stubWidths(200, 100); const heading = await renderAndGetHeading(); expect(heading).toHaveAttribute('tabindex', '0'); fireEvent.focus(heading); - let tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent('Test Competency'); - expect(tooltip).toHaveTextContent('Competency ID: EXT-042'); - fireEvent.blur(heading); - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - - fireEvent.mouseEnter(heading); - tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent('Test Competency'); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Test Competency'); }); - it('closes the tooltip on Escape without moving focus', async () => { - stubWidths(200, 100); + it('has no tabindex and shows no tooltip when not truncated', async () => { + stubWidths(100, 100); const heading = await renderAndGetHeading(); - heading.focus(); - await screen.findByRole('tooltip'); - fireEvent.keyDown(heading, { key: 'Escape' }); - - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - expect(heading).toHaveFocus(); - fireEvent.mouseEnter(heading); - await screen.findByRole('tooltip'); - fireEvent.keyDown(document.body, { key: 'Escape' }); - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + fireEvent.focus(heading); + + expect(heading).not.toHaveAttribute('tabindex'); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); - it('keeps the tooltip open while the pointer moves from the heading onto it, and closes it on leaving', async () => { - stubWidths(200, 100); - const heading = await renderAndGetHeading(); + describe('when the heading is resized', () => { + const OriginalResizeObserver = window.ResizeObserver; + let resizeCallback: ResizeObserverCallback; - fireEvent.mouseEnter(heading); - const tooltip = await screen.findByRole('tooltip'); + beforeEach(() => { + window.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe() {} - fireEvent.mouseLeave(heading); - fireEvent.mouseEnter(tooltip); - // Longer than the hide delay, so a missed cancel would have closed it. - act(() => { - jest.advanceTimersByTime(250); + unobserve() {} + + disconnect() {} + }; }); - expect(screen.getByRole('tooltip')).toBeInTheDocument(); - fireEvent.mouseLeave(tooltip); - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - }); + afterEach(() => { + window.ResizeObserver = OriginalResizeObserver; + }); - it('has no tabindex and shows no tooltip when not truncated', async () => { - stubWidths(100, 100); - const heading = await renderAndGetHeading(); + it('adds and removes tabindex as the heading becomes truncated and fits again', async () => { + stubWidths(100, 100); + const heading = await renderAndGetHeading(); + expect(heading).not.toHaveAttribute('tabindex'); - fireEvent.mouseEnter(heading); - fireEvent.focus(heading); + jest.restoreAllMocks(); + stubWidths(200, 100); + act(() => resizeCallback([], {} as ResizeObserver)); + expect(heading).toHaveAttribute('tabindex', '0'); - expect(heading).not.toHaveAttribute('tabindex'); - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + jest.restoreAllMocks(); + stubWidths(100, 100); + act(() => resizeCallback([], {} as ResizeObserver)); + expect(heading).not.toHaveAttribute('tabindex'); + }); }); }); diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx index 2a3cfbab5d..9f988b85dd 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx @@ -4,7 +4,6 @@ import { type ReactNode, useCallback, useEffect, - useId, useMemo, useRef, useState, @@ -15,12 +14,10 @@ import { Badge, Button, IconButton, - Overlay, Pagination, Row, SearchField, Stack, - Tooltip, } from '@openedx/paragon'; import { Calendar, Close } from '@openedx/paragon/icons'; import { debounce } from 'lodash'; @@ -32,14 +29,13 @@ import { LoadingSpinner } from '@src/generic/Loading'; import { DATE_FORMAT } from '@src/constants'; import { useStudioHomeCoursesV2 } from '@src/studio-home/data/apiHooks'; import type { CompetencyTreeNode } from '../CompetencyTree'; +import CompetencyTooltip from '../CompetencyTooltip'; +import { useCompetencyTooltip } from '../useCompetencyTooltip'; import CourseRow from './CourseRow'; import messages from './messages'; // @ts-ignore import './CourseSearchBrowse.scss'; -// Lets the pointer travel from the heading onto its tooltip (WCAG 1.4.13, hoverable). -const TOOLTIP_HIDE_DELAY_MS = 100; - export interface CourseSearchBrowseProps { /** The competency currently selected in the competency tree. The parent * only mounts this component once a competency is active - see @@ -252,21 +248,17 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { ); } - const tooltipId = useId(); const headingRef = useRef(null); const nameRef = useRef(null); const badgeRef = useRef(null); const [isHeadingTruncated, setIsHeadingTruncated] = useState(false); - const hideTimer = useRef>(); - const [showTooltip, setShowTooltip] = useState(false); const measureHeading = useCallback( () => [nameRef.current, badgeRef.current].some((el) => !!el && el.scrollWidth > el.clientWidth), [], ); - // Observing the heading catches width changes from dragging the - // tree/course divider as well as window resizes. + // Catches width changes from dragging the tree/course divider as well as window resizes. useEffect(() => { const heading = headingRef.current; if (!heading) { return undefined; } @@ -276,35 +268,11 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { return () => resizeObserver.disconnect(); }, [measureHeading, activeCompetency]); - const cancelHide = () => clearTimeout(hideTimer.current); - const closeTooltip = () => { - cancelHide(); - setShowTooltip(false); - }; - const closeTooltipSoon = () => { - cancelHide(); - hideTimer.current = setTimeout(closeTooltip, TOOLTIP_HIDE_DELAY_MS); - }; - - const openTooltipIfTruncated = () => { - cancelHide(); + const tooltip = useCompetencyTooltip(() => { const isTruncated = measureHeading(); setIsHeadingTruncated(isTruncated); - setShowTooltip(isTruncated); - }; - - useEffect(() => () => clearTimeout(hideTimer.current), []); - - // Document-level so Escape dismisses a hover-opened tooltip too, without - // moving focus (WCAG 1.4.13, dismissible). - useEffect(() => { - if (!showTooltip) { return undefined; } - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { setShowTooltip(false); } - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [showTooltip]); + return isTruncated; + }); return (
        @@ -356,10 +324,10 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { className="course-search-browse__associations-mastery d-flex align-items-center" ref={headingRef} tabIndex={isHeadingTruncated ? 0 : undefined} - onMouseEnter={openTooltipIfTruncated} - onMouseLeave={closeTooltipSoon} - onFocus={openTooltipIfTruncated} - onBlur={closeTooltip} + onMouseEnter={tooltip.openOnHover} + onMouseLeave={tooltip.closeSoon} + onFocus={tooltip.openOnFocus} + onBlur={tooltip.close} > {intl.formatMessage(messages.demonstrateMasteryForLabel, { @@ -383,24 +351,12 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { )} - - -
        {activeCompetency.value}
        - {activeCompetency.externalId && ( -
        - {intl.formatMessage(messages.competencyIdAccessibleLabel, { - externalId: activeCompetency.externalId, - })} -
        - )} -
        -
        +

        diff --git a/src/taxonomy/competency-management/useCompetencyTooltip.ts b/src/taxonomy/competency-management/useCompetencyTooltip.ts new file mode 100644 index 0000000000..23cfb429f7 --- /dev/null +++ b/src/taxonomy/competency-management/useCompetencyTooltip.ts @@ -0,0 +1,68 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +// Hover intent: skips tooltips for a pointer merely passing over a row. +export const TOOLTIP_SHOW_DELAY_MS = 300; +// Lets the pointer travel from the trigger onto its tooltip; satisfies WCAG 1.4.13 (hoverable). +export const TOOLTIP_HIDE_DELAY_MS = 100; + +/** + * Show/hide state and timing for a tooltip that appears only when its trigger's + * text is truncated. `isTruncated` is called each time the tooltip is about to + * open, so widths changed since the last render are accounted for. + */ +export const useCompetencyTooltip = (isTruncated: () => boolean) => { + const [show, setShow] = useState(false); + const showTimer = useRef>(); + const hideTimer = useRef>(); + const isTruncatedRef = useRef(isTruncated); + isTruncatedRef.current = isTruncated; + + const cancelShow = () => clearTimeout(showTimer.current); + const cancelHide = useCallback(() => clearTimeout(hideTimer.current), []); + + const close = useCallback(() => { + cancelShow(); + cancelHide(); + setShow(false); + }, [cancelHide]); + + const closeSoon = useCallback(() => { + cancelShow(); + cancelHide(); + hideTimer.current = setTimeout(close, TOOLTIP_HIDE_DELAY_MS); + }, [cancelHide, close]); + + const openOnFocus = useCallback(() => { + cancelShow(); + cancelHide(); + setShow(isTruncatedRef.current()); + }, [cancelHide]); + + const openOnHover = useCallback(() => { + cancelShow(); + cancelHide(); + showTimer.current = setTimeout(() => setShow(isTruncatedRef.current()), TOOLTIP_SHOW_DELAY_MS); + }, [cancelHide]); + + useEffect(() => () => { + cancelShow(); + cancelHide(); + }, [cancelHide]); + + return { + openOnHover, + openOnFocus, + close, + closeSoon, + /** Spread onto `CompetencyTooltip`. */ + tooltipProps: { + show, + onHide: close, + }, + }; +}; From d38d1430f5749c925c623bbed757de1e5b4f0284 Mon Sep 17 00:00:00 2001 From: AShatsila <319486855+AShatsila@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:19:23 +0400 Subject: [PATCH 5/5] fix: focus with tooltip on overflow --- .../competency-management/CompetencyTree.scss | 2 - .../CompetencyTree.test.tsx | 59 ++++++++++++++++--- .../CompetencyTreeItem.tsx | 9 +-- .../CompetencyTooltip.scss | 0 .../CompetencyTooltip.test.tsx | 56 ++++++++++-------- .../CompetencyTooltip.tsx | 4 +- .../competency-tooltip/index.ts | 3 + .../useCompetencyTooltip.ts | 45 +++++--------- .../course-search/CourseSearchBrowse.scss | 8 +-- .../course-search/CourseSearchBrowse.test.tsx | 53 +++++++++++++---- .../course-search/CourseSearchBrowse.tsx | 53 ++++++++--------- .../competency-management/messages.ts | 2 +- 12 files changed, 178 insertions(+), 116 deletions(-) rename src/taxonomy/competency-management/{ => competency-tooltip}/CompetencyTooltip.scss (100%) rename src/taxonomy/competency-management/{ => competency-tooltip}/CompetencyTooltip.test.tsx (74%) rename src/taxonomy/competency-management/{ => competency-tooltip}/CompetencyTooltip.tsx (83%) create mode 100644 src/taxonomy/competency-management/competency-tooltip/index.ts rename src/taxonomy/competency-management/{ => competency-tooltip}/useCompetencyTooltip.ts (56%) diff --git a/src/taxonomy/competency-management/CompetencyTree.scss b/src/taxonomy/competency-management/CompetencyTree.scss index b25e38a8bf..1c1bb6acaa 100644 --- a/src/taxonomy/competency-management/CompetencyTree.scss +++ b/src/taxonomy/competency-management/CompetencyTree.scss @@ -115,8 +115,6 @@ padding-left: 5px; .btn-icon-sm { - // Keeps a long label from shrinking the chevron along with it. - flex-shrink: 0; // `content-box` makes `width`/`height` define the glyph only, so the // padding below grows the hit box without shrinking the glyph. box-sizing: content-box; diff --git a/src/taxonomy/competency-management/CompetencyTree.test.tsx b/src/taxonomy/competency-management/CompetencyTree.test.tsx index 9e0f641f40..15f3d64800 100644 --- a/src/taxonomy/competency-management/CompetencyTree.test.tsx +++ b/src/taxonomy/competency-management/CompetencyTree.test.tsx @@ -106,6 +106,14 @@ const renderTree = (extraProps: { , ); +// jsdom treats :focus-visible as :focus, so simulate pointer-initiated focus explicitly. +const mockPointerFocus = () => { + const originalMatches = Element.prototype.matches; + jest.spyOn(Element.prototype, 'matches').mockImplementation(function matches(this: Element, selector: string) { + return selector === ':focus-visible' ? false : originalMatches.call(this, selector); + }); +}; + describe('', () => { beforeEach(() => { ({ axiosMock } = initializeMocks()); @@ -423,8 +431,26 @@ describe('', () => { jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(clientWidth); }; + // Only the Competency ID badge overflows; the label fits. + const stubOnlyBadgeTruncated = () => { + jest.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function scrollWidth(this: Element) { + return this.classList.contains('competency-row__badge') ? 200 : 100; + }); + jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(100); + }; + + const advance = (ms: number) => + act(() => { + jest.advanceTimersByTime(ms); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { jest.restoreAllMocks(); + jest.useRealTimers(); }); const renderExpandedTree = async () => { @@ -444,35 +470,54 @@ describe('', () => { stubWidths(200, 100); fireEvent.mouseEnter(rowOf(longName)); + advance(300); const tooltip = await screen.findByRole('tooltip'); expect(within(tooltip).getByText(longName, { exact: true })).toBeInTheDocument(); expect(within(tooltip).getByText(`Competency ID: ${longExternalId}`, { exact: true })).toBeInTheDocument(); }); + it('shows the tooltip when only the Competency ID badge is truncated', async () => { + axiosMock.onGet(tagListUrl).reply(200, buildLongTextResponse()); + renderTree({ onSelectCompetency: jest.fn() }); + await screen.findByText(longName); + stubOnlyBadgeTruncated(); + + act(() => screen.getByRole('treeitem', { name: `${longName} Competency ID: ${longExternalId}` }).focus()); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(longName); + }); + it('does not show an ancestor group\'s tooltip when a nested treeitem gets focus', async () => { await renderExpandedTree(); stubWidths(200, 100); - fireEvent.focus(screen.getByRole('treeitem', { name: 'Leaf A1a Competency ID: EXT-003' })); + act(() => screen.getByRole('treeitem', { name: 'Leaf A1a Competency ID: EXT-003' }).focus()); const tooltips = await screen.findAllByRole('tooltip'); expect(tooltips).toHaveLength(1); expect(tooltips[0]).toHaveTextContent('Leaf A1a'); }); + it('does not show the tooltip when a truncated row gets focus that is not focus-visible', async () => { + await renderExpandedTree(); + stubWidths(200, 100); + mockPointerFocus(); + + act(() => screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' }).focus()); + advance(400); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + it('shows no tooltip for a row that is not truncated', async () => { await renderExpandedTree(); stubWidths(100, 100); fireEvent.mouseEnter(rowOf('Root A')); - fireEvent.focus(screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' })); + act(() => screen.getByRole('treeitem', { name: 'Root A Competency ID: EXT-001' }).focus()); // Past the hover show delay, so a tooltip that was going to open has. - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 400); - }); - }); + advance(400); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); diff --git a/src/taxonomy/competency-management/CompetencyTreeItem.tsx b/src/taxonomy/competency-management/CompetencyTreeItem.tsx index 23224c60f4..a01391ff08 100644 --- a/src/taxonomy/competency-management/CompetencyTreeItem.tsx +++ b/src/taxonomy/competency-management/CompetencyTreeItem.tsx @@ -7,8 +7,7 @@ import { Badge } from '@openedx/paragon'; import ExpandCollapseIconButton from '@src/generic/ExpandCollapseIconButton'; import type { CompetencyTreeNode } from './CompetencyTree'; -import CompetencyTooltip from './CompetencyTooltip'; -import { useCompetencyTooltip } from './useCompetencyTooltip'; +import { CompetencyTooltip, useCompetencyTooltip } from './competency-tooltip'; import messages from './messages'; export interface CompetencyTreeItemProps { @@ -85,15 +84,13 @@ const CompetencyTreeItem = ({ } }; - // Measured each time the tooltip is about to open, so a width changed since - // the last render (e.g. by dragging the divider) is accounted for. const tooltip = useCompetencyTooltip( () => [labelRef.current, badgeRef.current].some((el) => !!el && el.scrollWidth > el.clientWidth), ); // React focus events bubble; only this row's own `

      • ` may open its tooltip. const handleFocus = (event: FocusEvent) => { - if (event.target === event.currentTarget) { tooltip.openOnFocus(); } + if (event.target === event.currentTarget) { tooltip.openOnFocus(event); } }; const handleBlur = (event: FocusEvent) => { if (event.target === event.currentTarget) { tooltip.close(); } @@ -120,7 +117,7 @@ const CompetencyTreeItem = ({ className="competency-row" ref={rowRef} onMouseEnter={tooltip.openOnHover} - onMouseLeave={tooltip.closeSoon} + onMouseLeave={tooltip.close} > { tabIndex={0} data-testid="trigger" onMouseEnter={tooltip.openOnHover} - onMouseLeave={tooltip.closeSoon} + onMouseLeave={tooltip.close} onFocus={tooltip.openOnFocus} onBlur={tooltip.close} > @@ -46,6 +46,14 @@ const advance = (ms: number) => jest.advanceTimersByTime(ms); }); +// jsdom treats :focus-visible as :focus, so simulate pointer-initiated focus explicitly. +const mockPointerFocus = () => { + const originalMatches = Element.prototype.matches; + jest.spyOn(Element.prototype, 'matches').mockImplementation(function matches(this: Element, selector: string) { + return selector === ':focus-visible' ? false : originalMatches.call(this, selector); + }); +}; + const pressEscape = () => fireEvent.keyUp(document, { key: 'Escape', keyCode: 27 }); describe(' with useCompetencyTooltip', () => { @@ -55,6 +63,7 @@ describe(' with useCompetencyTooltip', () => { }); afterEach(() => { + jest.restoreAllMocks(); jest.useRealTimers(); }); @@ -63,7 +72,7 @@ describe(' with useCompetencyTooltip', () => { return screen.getByTestId('trigger'); }; - it('opens on hover only after the show delay, with the name and Competency ID', () => { + it('opens on hover only after the show delay, with the name and Competency ID', async () => { const trigger = renderHost(); fireEvent.mouseEnter(trigger); @@ -71,7 +80,7 @@ describe(' with useCompetencyTooltip', () => { expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); advance(1); - const tooltip = screen.getByRole('tooltip'); + const tooltip = await screen.findByRole('tooltip'); expect(tooltip).toHaveTextContent('Full name'); expect(tooltip).toHaveTextContent('Competency ID: EXT-1'); }); @@ -87,18 +96,28 @@ describe(' with useCompetencyTooltip', () => { expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); - it('opens immediately on keyboard focus', () => { + it('opens immediately on keyboard focus', async () => { const trigger = renderHost(); - fireEvent.focus(trigger); + act(() => trigger.focus()); - expect(screen.getByRole('tooltip')).toBeInTheDocument(); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + }); + + it('does not open on focus that is not focus-visible, such as a mouse click', () => { + mockPointerFocus(); + const trigger = renderHost(); + + act(() => trigger.focus()); + advance(500); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); it('does not open when the trigger is not truncated', () => { const trigger = renderHost({ isTruncated: false }); - fireEvent.focus(trigger); + act(() => trigger.focus()); fireEvent.mouseEnter(trigger); advance(500); @@ -109,16 +128,16 @@ describe(' with useCompetencyTooltip', () => { const trigger = renderHost(); fireEvent.mouseEnter(trigger); advance(300); - expect(screen.getByRole('tooltip')).toBeInTheDocument(); + const tooltip = await screen.findByRole('tooltip'); fireEvent.mouseLeave(trigger); - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + expect(tooltip).not.toHaveClass('show'); }); it('closes on blur', async () => { const trigger = renderHost(); - fireEvent.focus(trigger); + act(() => trigger.focus()); expect(screen.getByRole('tooltip')).toBeInTheDocument(); fireEvent.blur(trigger); @@ -137,31 +156,18 @@ describe(' with useCompetencyTooltip', () => { expect(trigger).toHaveFocus(); }); - it('closes a hover-opened tooltip on Escape', async () => { - const trigger = renderHost(); - fireEvent.mouseEnter(trigger); - advance(300); - expect(screen.getByRole('tooltip')).toBeInTheDocument(); - - pressEscape(); - - await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); - }); - - it('stays open while the pointer moves from the trigger onto the tooltip, and closes on leaving it', async () => { + it('keeps the tooltip open while the pointer moves onto it, because it renders inside the trigger', async () => { const trigger = renderHost(); fireEvent.mouseEnter(trigger); advance(300); - const tooltip = screen.getByRole('tooltip'); + const tooltip = await screen.findByRole('tooltip'); // React derives enter/leave from mouseout, so the portaled tooltip counts as inside the trigger. fireEvent.mouseOut(trigger, { relatedTarget: tooltip }); - // Longer than the hide delay, so a missed cancel would have closed it. advance(250); expect(tooltip).toHaveClass('show'); fireEvent.mouseOut(tooltip, { relatedTarget: document.body }); - advance(150); await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); }); }); diff --git a/src/taxonomy/competency-management/CompetencyTooltip.tsx b/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.tsx similarity index 83% rename from src/taxonomy/competency-management/CompetencyTooltip.tsx rename to src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.tsx index 38ce8df837..ab28e79893 100644 --- a/src/taxonomy/competency-management/CompetencyTooltip.tsx +++ b/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.tsx @@ -3,7 +3,7 @@ import { useId, type RefObject } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Overlay, Tooltip } from '@openedx/paragon'; -import messages from './messages'; +import messages from '@src/taxonomy/competency-management/messages'; import './CompetencyTooltip.scss'; export interface CompetencyTooltipProps { @@ -27,6 +27,8 @@ const CompetencyTooltip = ({ onHide, }: CompetencyTooltipProps) => { const intl = useIntl(); + // Paragon requires an `id`; nothing references it on purpose, because the trigger's own accessible name + // already carries the full name and ID, and `aria-describedby` would read them twice. const id = useId(); // `rootClose` dismisses on Escape (keyup) and on an outside click, without moving focus (WCAG 1.4.13). diff --git a/src/taxonomy/competency-management/competency-tooltip/index.ts b/src/taxonomy/competency-management/competency-tooltip/index.ts new file mode 100644 index 0000000000..4790e76aff --- /dev/null +++ b/src/taxonomy/competency-management/competency-tooltip/index.ts @@ -0,0 +1,3 @@ +export { default as CompetencyTooltip } from './CompetencyTooltip'; +export type { CompetencyTooltipProps } from './CompetencyTooltip'; +export { useCompetencyTooltip } from './useCompetencyTooltip'; diff --git a/src/taxonomy/competency-management/useCompetencyTooltip.ts b/src/taxonomy/competency-management/competency-tooltip/useCompetencyTooltip.ts similarity index 56% rename from src/taxonomy/competency-management/useCompetencyTooltip.ts rename to src/taxonomy/competency-management/competency-tooltip/useCompetencyTooltip.ts index 23cfb429f7..e975f56855 100644 --- a/src/taxonomy/competency-management/useCompetencyTooltip.ts +++ b/src/taxonomy/competency-management/competency-tooltip/useCompetencyTooltip.ts @@ -1,64 +1,49 @@ -import { - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import type { FocusEvent } from 'react'; +import { useEffect, useRef, useState } from 'react'; // Hover intent: skips tooltips for a pointer merely passing over a row. export const TOOLTIP_SHOW_DELAY_MS = 300; -// Lets the pointer travel from the trigger onto its tooltip; satisfies WCAG 1.4.13 (hoverable). -export const TOOLTIP_HIDE_DELAY_MS = 100; /** * Show/hide state and timing for a tooltip that appears only when its trigger's * text is truncated. `isTruncated` is called each time the tooltip is about to * open, so widths changed since the last render are accounted for. + * + * Paragon's `OverlayTrigger` is not used because it renders the overlay as a + * sibling and checks DOM containment, so it cannot keep a portaled tooltip open + * while the pointer is on it. */ export const useCompetencyTooltip = (isTruncated: () => boolean) => { const [show, setShow] = useState(false); const showTimer = useRef>(); - const hideTimer = useRef>(); const isTruncatedRef = useRef(isTruncated); isTruncatedRef.current = isTruncated; const cancelShow = () => clearTimeout(showTimer.current); - const cancelHide = useCallback(() => clearTimeout(hideTimer.current), []); - const close = useCallback(() => { + const close = () => { cancelShow(); - cancelHide(); setShow(false); - }, [cancelHide]); - - const closeSoon = useCallback(() => { - cancelShow(); - cancelHide(); - hideTimer.current = setTimeout(close, TOOLTIP_HIDE_DELAY_MS); - }, [cancelHide, close]); + }; - const openOnFocus = useCallback(() => { + const openOnFocus = (event: FocusEvent) => { + // Pointer focus is skipped: pointer users get the tooltip through the hover delay. + if (!event.currentTarget.matches(':focus-visible')) { return; } cancelShow(); - cancelHide(); setShow(isTruncatedRef.current()); - }, [cancelHide]); + }; - const openOnHover = useCallback(() => { + const openOnHover = () => { cancelShow(); - cancelHide(); showTimer.current = setTimeout(() => setShow(isTruncatedRef.current()), TOOLTIP_SHOW_DELAY_MS); - }, [cancelHide]); + }; - useEffect(() => () => { - cancelShow(); - cancelHide(); - }, [cancelHide]); + useEffect(() => () => clearTimeout(showTimer.current), []); return { openOnHover, openOnFocus, close, - closeSoon, /** Spread onto `CompetencyTooltip`. */ tooltipProps: { show, diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss index 956a01288e..40dcdceeaf 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss @@ -147,13 +147,9 @@ color: var(--pgn-color-info-900); } - // Wraps `&__associations-label` and the "Demonstrate Mastery For"/empty- - // state text under it. - // The empty state repeats the competency name, so a long one has to wrap - // there. + // Wraps `&__associations-label` and the "Demonstrate Mastery For"/empty-state text under it. &__associations { padding: $container-padding-y $container-padding-x; - overflow-wrap: anywhere; } // Figma gives regular weight for this text layer - only the interpolated @@ -168,6 +164,8 @@ // Always shown: there's no backend endpoint yet to list a competency's // actual associations (see `CourseSearchBrowse.tsx`). &__associations-empty-state { + // The empty state repeats the competency name, so a long one has to wrap here. + overflow-wrap: anywhere; margin-top: 8px; font-size: 15.8px; line-height: 24.5px; diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx index c44580d249..ec1e98b127 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.test.tsx @@ -40,6 +40,14 @@ const buildResponse = (courses: Course[], numPages: number = 1) => ({ const lastRequestParams = () => axiosMock.history.get[axiosMock.history.get.length - 1].params; +// jsdom treats :focus-visible as :focus, so simulate pointer-initiated focus explicitly. +const mockPointerFocus = () => { + const originalMatches = Element.prototype.matches; + jest.spyOn(Element.prototype, 'matches').mockImplementation(function matches(this: Element, selector: string) { + return selector === ':focus-visible' ? false : originalMatches.call(this, selector); + }); +}; + describe(' and ', () => { beforeAll(() => { jest.useFakeTimers(); @@ -118,8 +126,9 @@ describe(' and ', () => { // jsdom has no layout, so every element reports the same widths. const stubWidths = (scrollWidth: number, clientWidth: number) => { - jest.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(scrollWidth); - jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(clientWidth); + const scrollWidthSpy = jest.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(scrollWidth); + const clientWidthSpy = jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(clientWidth); + return { scrollWidthSpy, clientWidthSpy }; }; afterEach(() => { @@ -140,22 +149,48 @@ describe(' and ', () => { expect(heading).toHaveAttribute('tabindex', '0'); - fireEvent.focus(heading); + act(() => heading.focus()); expect(await screen.findByRole('tooltip')).toHaveTextContent('Test Competency'); }); + it('shows no tooltip when the truncated heading gets focus that is not focus-visible', async () => { + stubWidths(200, 100); + mockPointerFocus(); + const heading = await renderAndGetHeading(); + + act(() => heading.focus()); + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + it('has no tabindex and shows no tooltip when not truncated', async () => { stubWidths(100, 100); const heading = await renderAndGetHeading(); - fireEvent.mouseEnter(heading); - fireEvent.focus(heading); + act(() => heading.focus()); expect(heading).not.toHaveAttribute('tabindex'); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); + it('is focusable and shows the tooltip on focus when only the Competency ID badge is truncated', async () => { + jest.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function scrollWidth(this: Element) { + return this.classList.contains('competency-row__badge') ? 200 : 100; + }); + jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(100); + const heading = await renderAndGetHeading(); + + expect(heading).toHaveAttribute('tabindex', '0'); + + act(() => heading.focus()); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Test Competency'); + }); + describe('when the heading is resized', () => { const OriginalResizeObserver = window.ResizeObserver; let resizeCallback: ResizeObserverCallback; @@ -179,17 +214,15 @@ describe(' and ', () => { }); it('adds and removes tabindex as the heading becomes truncated and fits again', async () => { - stubWidths(100, 100); + const { scrollWidthSpy } = stubWidths(100, 100); const heading = await renderAndGetHeading(); expect(heading).not.toHaveAttribute('tabindex'); - jest.restoreAllMocks(); - stubWidths(200, 100); + scrollWidthSpy.mockReturnValue(200); act(() => resizeCallback([], {} as ResizeObserver)); expect(heading).toHaveAttribute('tabindex', '0'); - jest.restoreAllMocks(); - stubWidths(100, 100); + scrollWidthSpy.mockReturnValue(100); act(() => resizeCallback([], {} as ResizeObserver)); expect(heading).not.toHaveAttribute('tabindex'); }); diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx index 408d9bf619..6ae6522645 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx @@ -29,8 +29,7 @@ import { LoadingSpinner } from '@src/generic/Loading'; import { DATE_FORMAT } from '@src/constants'; import { useStudioHomeCoursesV2 } from '@src/studio-home/data/apiHooks'; import type { CompetencyTreeNode } from '../CompetencyTree'; -import CompetencyTooltip from '../CompetencyTooltip'; -import { useCompetencyTooltip } from '../useCompetencyTooltip'; +import { CompetencyTooltip, useCompetencyTooltip } from '@src/taxonomy/competency-management/competency-tooltip'; import CourseRow from './CourseRow'; import messages from './messages'; // @ts-ignore @@ -184,6 +183,28 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { const handleClearDateRange = () => handleDateRangeChange([null, null]); + const headingRef = useRef(null); + const nameRef = useRef(null); + const badgeRef = useRef(null); + const [isHeadingTruncated, setIsHeadingTruncated] = useState(false); + + const measureHeading = useCallback( + () => [nameRef.current, badgeRef.current].some((el) => !!el && el.scrollWidth > el.clientWidth), + [], + ); + + // Catches width changes from dragging the tree/course divider as well as window resizes. + useEffect(() => { + const heading = headingRef.current; + if (!heading) { return undefined; } + setIsHeadingTruncated(measureHeading()); + const resizeObserver = new ResizeObserver(() => setIsHeadingTruncated(measureHeading())); + resizeObserver.observe(heading); + return () => resizeObserver.disconnect(); + }, [measureHeading, activeCompetency]); + + const tooltip = useCompetencyTooltip(measureHeading); + const { data, isLoading, isError } = useStudioHomeCoursesV2({ page, pageSize: PAGE_SIZE, @@ -236,32 +257,6 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { hasPagination = numPages > 1; } - const headingRef = useRef(null); - const nameRef = useRef(null); - const badgeRef = useRef(null); - const [isHeadingTruncated, setIsHeadingTruncated] = useState(false); - - const measureHeading = useCallback( - () => [nameRef.current, badgeRef.current].some((el) => !!el && el.scrollWidth > el.clientWidth), - [], - ); - - // Catches width changes from dragging the tree/course divider as well as window resizes. - useEffect(() => { - const heading = headingRef.current; - if (!heading) { return undefined; } - setIsHeadingTruncated(measureHeading()); - const resizeObserver = new ResizeObserver(() => setIsHeadingTruncated(measureHeading())); - resizeObserver.observe(heading); - return () => resizeObserver.disconnect(); - }, [measureHeading, activeCompetency]); - - const tooltip = useCompetencyTooltip(() => { - const isTruncated = measureHeading(); - setIsHeadingTruncated(isTruncated); - return isTruncated; - }); - return (
        @@ -313,7 +308,7 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { ref={headingRef} tabIndex={isHeadingTruncated ? 0 : undefined} onMouseEnter={tooltip.openOnHover} - onMouseLeave={tooltip.closeSoon} + onMouseLeave={tooltip.close} onFocus={tooltip.openOnFocus} onBlur={tooltip.close} > diff --git a/src/taxonomy/competency-management/messages.ts b/src/taxonomy/competency-management/messages.ts index bb8518e43a..428c04bdb9 100644 --- a/src/taxonomy/competency-management/messages.ts +++ b/src/taxonomy/competency-management/messages.ts @@ -29,7 +29,7 @@ const messages = defineMessages({ competencyIdAccessibleLabel: { id: 'course-authoring.competency-management.competency-id.accessible-label', defaultMessage: 'Competency ID: {externalId}', - description: 'Accessible name for the Competency ID badge, read by screen readers since there is no column header to associate it with.', + description: 'Labels a competency\'s Competency ID; {externalId} is the ID itself.', }, importCompetencyFrameworkButton: { id: 'course-authoring.competency-management.button.import-framework.label',