diff --git a/src/taxonomy/competency-management/CompetencyTree.scss b/src/taxonomy/competency-management/CompetencyTree.scss index 6ec1fd2a2b..1c1bb6acaa 100644 --- a/src/taxonomy/competency-management/CompetencyTree.scss +++ b/src/taxonomy/competency-management/CompetencyTree.scss @@ -38,7 +38,9 @@ } .competency-row__badge { - overflow-wrap: anywhere; + // 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: 6rem; // 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/CompetencyTree.test.tsx b/src/taxonomy/competency-management/CompetencyTree.test.tsx index 5d4da911c3..15f3d64800 100644 --- a/src/taxonomy/competency-management/CompetencyTree.test.tsx +++ b/src/taxonomy/competency-management/CompetencyTree.test.tsx @@ -1,4 +1,5 @@ import { + act, fireEvent, initializeMocks, render, @@ -80,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: { @@ -90,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()); @@ -399,4 +423,103 @@ 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); + }; + + // 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 () => { + 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 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.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); + + 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')); + 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. + 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 37dce35922..a01391ff08 100644 --- a/src/taxonomy/competency-management/CompetencyTreeItem.tsx +++ b/src/taxonomy/competency-management/CompetencyTreeItem.tsx @@ -1,5 +1,5 @@ -import type { KeyboardEvent, MouseEvent } from 'react'; -import { useId } from 'react'; +import type { FocusEvent, KeyboardEvent, MouseEvent } from 'react'; +import { useId, useRef } from 'react'; import classNames from 'classnames'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -7,6 +7,7 @@ import { Badge } from '@openedx/paragon'; import ExpandCollapseIconButton from '@src/generic/ExpandCollapseIconButton'; import type { CompetencyTreeNode } from './CompetencyTree'; +import { CompetencyTooltip, useCompetencyTooltip } from './competency-tooltip'; import messages from './messages'; export interface CompetencyTreeItemProps { @@ -47,6 +48,9 @@ const CompetencyTreeItem = ({ const intl = useIntl(); const labelId = useId(); const externalIdLabelId = useId(); + const rowRef = useRef(null); + const labelRef = useRef(null); + const badgeRef = useRef(null); const nodeId = String(node.id); const hasChildren = !!node.subRows?.length; const isExpanded = expandedIds.has(nodeId); @@ -80,6 +84,18 @@ const CompetencyTreeItem = ({ } }; + 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(event); } + }; + const handleBlur = (event: FocusEvent) => { + if (event.target === event.currentTarget) { tooltip.close(); } + }; + return (
  • -
    +
    - {node.value} + {node.value} {externalId ? ( <> @@ -112,7 +135,8 @@ const CompetencyTreeItem = ({
    {isExpanded && hasChildren && (
      diff --git a/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.scss b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.scss index 46eb30871d..fa3a289d32 100644 --- a/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.scss +++ b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.scss @@ -4,3 +4,10 @@ .competency-associations-panel { --competency-panel-height: 73rem; } + +.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 619d027101..85fb86c1d6 100644 --- a/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx +++ b/src/taxonomy/competency-management/associations/CompetencyAssociationsPanel.tsx @@ -58,7 +58,7 @@ const CompetencyAssociationsPanel = ({ taxonomyId, taxonomyName }: CompetencyAss {tree} {selectedCompetency && ( -
      +
      )} diff --git a/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.scss b/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.scss new file mode 100644 index 0000000000..1d9b064bfa --- /dev/null +++ b/src/taxonomy/competency-management/competency-tooltip/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/competency-tooltip/CompetencyTooltip.test.tsx b/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.test.tsx new file mode 100644 index 0000000000..c33723f69c --- /dev/null +++ b/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.test.tsx @@ -0,0 +1,173 @@ +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); + }); + +// 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', () => { + beforeEach(() => { + jest.useFakeTimers(); + initializeMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + 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', async () => { + const trigger = renderHost(); + + fireEvent.mouseEnter(trigger); + advance(299); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + advance(1); + const tooltip = await screen.findByRole('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', async () => { + const trigger = renderHost(); + + act(() => trigger.focus()); + + 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 }); + + act(() => trigger.focus()); + 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); + const tooltip = await screen.findByRole('tooltip'); + + fireEvent.mouseLeave(trigger); + + expect(tooltip).not.toHaveClass('show'); + }); + + it('closes on blur', async () => { + const trigger = renderHost(); + act(() => trigger.focus()); + 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('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 = await screen.findByRole('tooltip'); + + // React derives enter/leave from mouseout, so the portaled tooltip counts as inside the trigger. + fireEvent.mouseOut(trigger, { relatedTarget: tooltip }); + advance(250); + expect(tooltip).toHaveClass('show'); + + fireEvent.mouseOut(tooltip, { relatedTarget: document.body }); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); +}); diff --git a/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.tsx b/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.tsx new file mode 100644 index 0000000000..ab28e79893 --- /dev/null +++ b/src/taxonomy/competency-management/competency-tooltip/CompetencyTooltip.tsx @@ -0,0 +1,56 @@ +import { useId, type RefObject } from 'react'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Overlay, Tooltip } from '@openedx/paragon'; + +import messages from '@src/taxonomy/competency-management/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(); + // 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). + 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/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/competency-tooltip/useCompetencyTooltip.ts b/src/taxonomy/competency-management/competency-tooltip/useCompetencyTooltip.ts new file mode 100644 index 0000000000..e975f56855 --- /dev/null +++ b/src/taxonomy/competency-management/competency-tooltip/useCompetencyTooltip.ts @@ -0,0 +1,53 @@ +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; + +/** + * 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 isTruncatedRef = useRef(isTruncated); + isTruncatedRef.current = isTruncated; + + const cancelShow = () => clearTimeout(showTimer.current); + + const close = () => { + cancelShow(); + setShow(false); + }; + + 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(); + setShow(isTruncatedRef.current()); + }; + + const openOnHover = () => { + cancelShow(); + showTimer.current = setTimeout(() => setShow(isTruncatedRef.current()), TOOLTIP_SHOW_DELAY_MS); + }; + + useEffect(() => () => clearTimeout(showTimer.current), []); + + return { + openOnHover, + openOnFocus, + close, + /** Spread onto `CompetencyTooltip`. */ + tooltipProps: { + show, + onHide: close, + }, + }; +}; diff --git a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss index 3ca1e33c6f..40dcdceeaf 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.scss @@ -39,7 +39,9 @@ } .competency-row__badge { - overflow-wrap: anywhere; + // 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: 6rem; border-radius: var(--pgn-size-badge-border-radius-base); --pgn-spacing-badge-padding-y: 1.5px; @@ -145,8 +147,7 @@ color: var(--pgn-color-info-900); } - // Wraps `&__associations-label` and the "Demonstrate Mastery For"/empty- - // state text under it. + // Wraps `&__associations-label` and the "Demonstrate Mastery For"/empty-state text under it. &__associations { padding: $container-padding-y $container-padding-x; } @@ -163,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 9140cc6ee5..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(); @@ -113,6 +121,114 @@ 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) => { + const scrollWidthSpy = jest.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(scrollWidth); + const clientWidthSpy = jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(clientWidth); + return { scrollWidthSpy, clientWidthSpy }; + }; + + 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 tooltip on focus when truncated', async () => { + stubWidths(200, 100); + const heading = await renderAndGetHeading(); + + expect(heading).toHaveAttribute('tabindex', '0'); + + 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(); + + 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; + + beforeEach(() => { + window.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe() {} + + unobserve() {} + + disconnect() {} + }; + }); + + afterEach(() => { + window.ResizeObserver = OriginalResizeObserver; + }); + + it('adds and removes tabindex as the heading becomes truncated and fits again', async () => { + const { scrollWidthSpy } = stubWidths(100, 100); + const heading = await renderAndGetHeading(); + expect(heading).not.toHaveAttribute('tabindex'); + + scrollWidthSpy.mockReturnValue(200); + act(() => resizeCallback([], {} as ResizeObserver)); + expect(heading).toHaveAttribute('tabindex', '0'); + + scrollWidthSpy.mockReturnValue(100); + act(() => resizeCallback([], {} as ResizeObserver)); + expect(heading).not.toHaveAttribute('tabindex'); + }); + }); + }); + 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 49e6eb1769..6ae6522645 100644 --- a/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx +++ b/src/taxonomy/competency-management/course-search/CourseSearchBrowse.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; @@ -28,6 +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, useCompetencyTooltip } from '@src/taxonomy/competency-management/competency-tooltip'; import CourseRow from './CourseRow'; import messages from './messages'; // @ts-ignore @@ -181,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, @@ -279,10 +303,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 && ( <> @@ -290,11 +324,22 @@ const CourseSearchBrowse = ({ activeCompetency }: CourseSearchBrowseProps) => { externalId: activeCompetency.externalId, })} -

      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',