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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/taxonomy/competency-management/CompetencyTree.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
123 changes: 123 additions & 0 deletions src/taxonomy/competency-management/CompetencyTree.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
act,
fireEvent,
initializeMocks,
render,
Expand Down Expand Up @@ -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: {
Expand All @@ -90,6 +106,14 @@ const renderTree = (extraProps: {
<CompetencyTree taxonomyId={taxonomyId} taxonomyName={taxonomyName} {...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('<CompetencyTree />', () => {
beforeEach(() => {
({ axiosMock } = initializeMocks());
Expand Down Expand Up @@ -399,4 +423,103 @@ describe('<CompetencyTree />', () => {
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();
});
});
});
40 changes: 35 additions & 5 deletions src/taxonomy/competency-management/CompetencyTreeItem.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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';
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 {
Expand Down Expand Up @@ -47,6 +48,9 @@ const CompetencyTreeItem = ({
const intl = useIntl();
const labelId = useId();
const externalIdLabelId = useId();
const rowRef = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const badgeRef = useRef<HTMLElement>(null);
const nodeId = String(node.id);
const hasChildren = !!node.subRows?.length;
const isExpanded = expandedIds.has(nodeId);
Expand Down Expand Up @@ -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 `<li>` may open its tooltip.
const handleFocus = (event: FocusEvent<HTMLLIElement>) => {
if (event.target === event.currentTarget) { tooltip.openOnFocus(event); }
};
const handleBlur = (event: FocusEvent<HTMLLIElement>) => {
if (event.target === event.currentTarget) { tooltip.close(); }
};

return (
<li
className={classNames({
Expand All @@ -94,16 +110,23 @@ const CompetencyTreeItem = ({
aria-expanded={isSelectable && hasChildren ? isExpanded : undefined}
onClick={isSelectable ? handleSelect : undefined}
onKeyDown={isSelectable ? handleKeyDown : undefined}
onFocus={handleFocus}
onBlur={handleBlur}
>
<div className="competency-row">
<div
className="competency-row"
ref={rowRef}
onMouseEnter={tooltip.openOnHover}
onMouseLeave={tooltip.close}
>
<ExpandCollapseIconButton
canExpand={hasChildren}
isExpanded={isExpanded}
onToggle={() => onToggle(nodeId)}
expandLabel={intl.formatMessage(messages.expandRowButtonLabel)}
collapseLabel={intl.formatMessage(messages.collapseRowButtonLabel)}
/>
<span className="competency-row__label" id={labelId}>{node.value}</span>
<span className="competency-row__label text-truncate" id={labelId} ref={labelRef}>{node.value}</span>
{externalId ?
(
<>
Expand All @@ -112,14 +135,21 @@ const CompetencyTreeItem = ({
</span>
<Badge
variant="info"
className="competency-row__badge ml-auto"
className="competency-row__badge text-truncate ml-auto"
ref={badgeRef}
aria-hidden="true"
>
{externalId}
</Badge>
</>
) :
null}
<CompetencyTooltip
anchorRef={rowRef}
name={node.value}
externalId={externalId}
{...tooltip.tooltipProps}
/>
</div>
{isExpanded && hasChildren && (
<ul role={isSelectable ? 'group' : undefined}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const CompetencyAssociationsPanel = ({ taxonomyId, taxonomyName }: CompetencyAss
{tree}
</ResizableBox>
{selectedCompetency && (
<div className="flex-grow-1">
<div className="flex-grow-1 competency-associations__course-column">
<CourseSearchBrowse activeCompetency={selectedCompetency} />
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.competency-tooltip {
// Top-level, not under `.competency-tree`: the tooltip renders in a portal
// under `<body>`. Paragon's 200px default makes a long name tall enough to
// run off the top of the viewport.
--pgn-size-tooltip-max-width: 400px;
}
Loading
Loading