diff --git a/change/@fluentui-react-charting-cf0ad301-c5b0-4363-b71a-ed4c74ede886.json b/change/@fluentui-react-charting-cf0ad301-c5b0-4363-b71a-ed4c74ede886.json
new file mode 100644
index 0000000000000..bde42f532908b
--- /dev/null
+++ b/change/@fluentui-react-charting-cf0ad301-c5b0-4363-b71a-ed4c74ede886.json
@@ -0,0 +1,7 @@
+{
+ "type": "none",
+ "comment": "test: add real coverage for the Legends overflow scenario (reducers, overflow button count, hover card contents)",
+ "packageName": "@fluentui/react-charting",
+ "email": "144495202+AKnassa@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/change/@fluentui-react-charts-b589e49b-ecd8-40df-9102-e80f8af0ff6a.json b/change/@fluentui-react-charts-b589e49b-ecd8-40df-9102-e80f8af0ff6a.json
new file mode 100644
index 0000000000000..253aba89b5034
--- /dev/null
+++ b/change/@fluentui-react-charts-b589e49b-ecd8-40df-9102-e80f8af0ff6a.json
@@ -0,0 +1,7 @@
+{
+ "type": "none",
+ "comment": "test: add real coverage for the Legends overflow scenario (overflow menu button count, hidden legends, menu contents)",
+ "packageName": "@fluentui/react-charts",
+ "email": "144495202+AKnassa@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/packages/charts/react-charting/src/components/Legends/Legends.test.tsx b/packages/charts/react-charting/src/components/Legends/Legends.test.tsx
index 74ad9280c8e15..f66be5f3ae5da 100644
--- a/packages/charts/react-charting/src/components/Legends/Legends.test.tsx
+++ b/packages/charts/react-charting/src/components/Legends/Legends.test.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
import { resetIds } from '../../Utilities';
import { Legends } from './index';
import { LegendsBase } from './Legends.base';
-import { render, cleanup } from '@testing-library/react';
+import { render, cleanup, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { DefaultPalette } from '@fluentui/react/lib/Styling';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -99,14 +99,19 @@ describe('Legends - basic props', () => {
expect(document.querySelectorAll('[class^="legendContainer"]')).toBeDefined();
});
- it('Should mount Overflow Button when not empty', () => {
+ it('Should render every legend inline and no overflow button when all legends fit', () => {
+ // jsdom reports zero widths, so ResizeGroup never overflows here: all legends stay inline.
+ // (The overflow scenario itself is covered in the 'Legends - overflow rendering' suite below.)
render();
- expect(document.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]')).toBeDefined();
+ expect(document.querySelectorAll('button[role="option"]').length).toBe(legends.length);
+ expect(screen.queryByText(/OverFlow Items/)).toBeNull();
});
- it('Should not mount Overflow when empty', () => {
+ it('Should not mount an overflow button when nothing overflows', () => {
render();
- expect(document.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]').length).toBe(0);
+ // The overflow indicator would render as ' more' ('more' is the default overflow text).
+ expect(screen.queryByText(/^\d+ more$/)).toBeNull();
+ expect(document.querySelectorAll('button[role="option"]').length).toBe(legends.length);
});
it('Should be not able to select multiple Legends', () => {
@@ -199,3 +204,156 @@ describe('Legends - controlled legend selection', () => {
expect(selectedLegends.length).toBe(1);
});
});
+
+describe('Legends - overflow reducers', () => {
+ beforeEach(sharedBeforeEach);
+ afterEach(sharedAfterEach);
+
+ // The reducers are private, pure functions driven by ResizeGroup; they are what decides which
+ // legends move in and out of the overflow, so they are tested directly on an instance.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const instance: any = new LegendsBase({ legends: [] });
+ const makeItems = (count: number) => Array.from({ length: count }, (_, i) => ({ key: i, title: `L${i + 1}` }));
+
+ it('_onReduceData moves the last primary item to the front of the overflow', () => {
+ const [first, second, third] = makeItems(3);
+ expect(instance._onReduceData({ primary: [first, second, third], overflow: [] })).toEqual({
+ primary: [first, second],
+ overflow: [third],
+ });
+ expect(instance._onReduceData({ primary: [first, second], overflow: [third] })).toEqual({
+ primary: [first],
+ overflow: [second, third],
+ });
+ });
+
+ it('_onReduceData returns undefined when there is no primary item left to move', () => {
+ expect(instance._onReduceData({ primary: [], overflow: makeItems(2) })).toBeUndefined();
+ });
+
+ it('_onGrowData moves the first overflow item back to the end of primary', () => {
+ const [first, second, third] = makeItems(3);
+ expect(instance._onGrowData({ primary: [first], overflow: [second, third] })).toEqual({
+ primary: [first, second],
+ overflow: [third],
+ });
+ });
+
+ it('_onGrowData returns undefined when there is no overflow item to restore', () => {
+ expect(instance._onGrowData({ primary: makeItems(2), overflow: [] })).toBeUndefined();
+ });
+
+ it('_onGrowData is the inverse of _onReduceData', () => {
+ const data = { primary: makeItems(3), overflow: [] };
+ expect(instance._onGrowData(instance._onReduceData(data))).toEqual(data);
+ });
+});
+
+describe('Legends - overflow rendering', () => {
+ beforeEach(sharedBeforeEach);
+ afterEach(sharedAfterEach);
+
+ const originalGetBoundingClientRect = window.HTMLElement.prototype.getBoundingClientRect;
+ const CONTAINER_WIDTH = 300;
+ const LEGEND_BUTTON_WIDTH = 60;
+ const OVERFLOW_INDICATOR_WIDTH = 60;
+
+ beforeEach(() => {
+ // ResizeGroup decides how many legends fit by measuring a hidden copy of the content
+ // (initial pass: the div with data-automation-id="visibleContent" while it is hidden;
+ // update passes: an anonymous div with visibility: hidden) against its own root container.
+ // jsdom reports 0 for every width, which is exactly why overflow never happened in these
+ // tests before. Report a fixed container width and a content width proportional to the
+ // number of legend buttons currently rendered, so the real
+ // ResizeGroup -> _onReduceData -> OverflowSet pipeline runs and converges: 17 legends at
+ // 60px each never fit into 300px, and the loop settles at 4 inline legends + the overflow
+ // indicator (4 * 60 + 60 = 300 <= 300).
+ window.HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement): DOMRect {
+ let width = CONTAINER_WIDTH;
+ const isMeasuredContent =
+ this.getAttribute('data-automation-id') === 'visibleContent' || this.style.visibility === 'hidden';
+ if (isMeasuredContent) {
+ const legendButtonCount = this.querySelectorAll('button').length;
+ const hasOverflowIndicator = /\d+ Overflow Items/.test(this.textContent || '');
+ width = legendButtonCount * LEGEND_BUTTON_WIDTH + (hasOverflowIndicator ? OVERFLOW_INDICATOR_WIDTH : 0);
+ }
+ return {
+ width,
+ height: 32,
+ top: 0,
+ left: 0,
+ right: width,
+ bottom: 32,
+ x: 0,
+ y: 0,
+ toJSON: () => '',
+ } as DOMRect;
+ };
+ });
+
+ afterEach(() => {
+ window.HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
+ });
+
+ /** Resolves once ResizeGroup has committed its final, visible render. */
+ async function waitForResizeGroupToSettle(): Promise {
+ await waitFor(() => {
+ const visibleContent = document.querySelector('[data-automation-id="visibleContent"]') as HTMLElement;
+ expect(visibleContent.style.visibility).not.toBe('hidden');
+ });
+ }
+
+ it('moves the legends that do not fit into an overflow button with the correct count', async () => {
+ render();
+
+ const overflowButton = await screen.findByText('13 Overflow Items');
+ await waitForResizeGroupToSettle();
+
+ const inlineLegends = document.querySelectorAll('button[role="option"]');
+ expect(inlineLegends.length).toBe(4);
+ // Every legend is either inline or accounted for by the overflow button count.
+ expect(inlineLegends.length + 13).toBe(legends.length);
+ // The first legends stay inline; overflowed legends leave the DOM entirely
+ // (OverflowSet only renders them inside the hover card once it is opened).
+ expect(screen.getByText('Legend 1')).toBeTruthy();
+ expect(screen.queryByText('Legend 17')).toBeNull();
+ expect(overflowButton.getAttribute('role')).toBe('button');
+ expect(overflowButton.getAttribute('aria-expanded')).toBe('false');
+ expect(overflowButton.getAttribute('aria-label')).toBe('13 Overflow Items');
+ });
+
+ it('opens a hover card listing the overflowed legends when the overflow button is clicked', async () => {
+ render();
+
+ const overflowButton = await screen.findByText('13 Overflow Items');
+ await waitForResizeGroupToSettle();
+ // Overflowed legends are not in the DOM before the hover card opens.
+ expect(screen.queryByText('Legend 5')).toBeNull();
+ expect(screen.queryByText('Legend 17')).toBeNull();
+
+ fireEvent.click(overflowButton);
+
+ await waitFor(() => expect(overflowButton.getAttribute('aria-expanded')).toBe('true'));
+ // The hover card renders every overflowed legend (Legend 5 through Legend 17). The queries are
+ // scoped to the hover card because opening it re-renders Legends, which makes ResizeGroup
+ // re-measure all 17 legends in a temporary hidden div.
+ const hoverCard = await waitFor(() => {
+ const card = document.querySelector('[class*="hoverCardRoot"]') as HTMLElement;
+ expect(card).not.toBeNull();
+ return card;
+ });
+ expect(hoverCard.querySelectorAll('button').length).toBe(13);
+ expect(within(hoverCard).getByText('Legend 5')).toBeTruthy();
+ expect(within(hoverCard).getByText('Legend 17')).toBeTruthy();
+ });
+
+ it('renders no overflow button when all legends fit', async () => {
+ render();
+
+ // 3 legends * 60px = 180px fits into the 300px container without any reduction.
+ await waitForResizeGroupToSettle();
+
+ expect(document.querySelectorAll('button[role="option"]').length).toBe(3);
+ expect(screen.queryByText(/Overflow Items/)).toBeNull();
+ });
+});
diff --git a/packages/charts/react-charts/library/src/components/Legends/Legends.test.tsx b/packages/charts/react-charts/library/src/components/Legends/Legends.test.tsx
index 0d621eb8966dd..dedc3dd2fb198 100644
--- a/packages/charts/react-charts/library/src/components/Legends/Legends.test.tsx
+++ b/packages/charts/react-charts/library/src/components/Legends/Legends.test.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Legends } from './index';
-import { render, act } from '@testing-library/react';
+import { render, act, fireEvent, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
@@ -130,16 +130,20 @@ describe('Legends - basic props', () => {
expect(legend).toBeDefined();
});
- it('Should mount Overflow Button when not empty', () => {
- const wrapper = render();
- const overflowBtnText = wrapper.container.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]');
- expect(overflowBtnText).toBeDefined();
+ it('Should render every legend inline and no overflow menu button when nothing overflows', () => {
+ // jsdom reports zero widths, so the overflow manager never hides anything here: all legends
+ // stay inline. (The overflow scenario itself is covered in the 'Legends - overflow' suite below.)
+ const wrapper = render();
+ expect(wrapper.container.querySelectorAll('button[role="option"]').length).toBe(legends.length);
+ expect(wrapper.container.querySelectorAll('[data-overflowing]').length).toBe(0);
+ expect(wrapper.queryByText(/OverFlow Items/)).toBeNull();
});
- it('Should not mount Overflow when empty', () => {
+ it('Should not mount an overflow menu button when nothing overflows', () => {
const wrapper = render();
- const overflowBtn = wrapper.container.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]');
- expect(overflowBtn!.length).toBe(0);
+ // The overflow menu button would render as '+ more' ('more' is the default overflow text).
+ expect(wrapper.queryByText(/^\+\d+/)).toBeNull();
+ expect(wrapper.container.querySelectorAll('[data-overflowing]').length).toBe(0);
});
it('Should be not able to select multiple Legends', () => {
@@ -239,3 +243,77 @@ describe('Legends - axe-core', () => {
expect(axeResults).toHaveNoViolations();
});
});
+
+describe('Legends - overflow', () => {
+ const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
+ const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth');
+ const CONTAINER_WIDTH = 250;
+ const ITEM_WIDTH = 50;
+
+ beforeEach(() => {
+ // @fluentui/react-overflow measures the container via clientWidth and each legend button (and
+ // the overflow menu button) via offsetWidth, then resolves overflow synchronously at mount in
+ // tests (observe() force-updates when clientWidth > 0 and its debounce is synchronous when
+ // NODE_ENV === 'test'). jsdom reports 0 for both, which is exactly why overflow never happened
+ // in these tests before. Mock the widths so the real overflow pipeline runs: 250px container
+ // - 10px default padding = 240px available; 17 legends at 50px each never fit, and with the
+ // 50px menu button the manager settles at 3 visible legends (3 * 50 + 50 = 200 <= 240) and
+ // 14 overflowed ones.
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => CONTAINER_WIDTH });
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => ITEM_WIDTH });
+ });
+
+ afterEach(() => {
+ if (originalClientWidth) {
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth);
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ delete (HTMLElement.prototype as any).clientWidth;
+ }
+ if (originalOffsetWidth) {
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth);
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ delete (HTMLElement.prototype as any).offsetWidth;
+ }
+ });
+
+ it('hides the legends that do not fit and renders an overflow menu button with the count', () => {
+ const { container } = render();
+
+ expect(screen.getByText('+14 Overflow Items')).toBeTruthy();
+ // Overflowed legends stay in the DOM (hidden via CSS) and are marked with data-overflowing.
+ const hiddenLegends = container.querySelectorAll('[data-overflowing]');
+ expect(hiddenLegends.length).toBe(14);
+ const visibleLegends = container.querySelectorAll('button[role="option"]:not([data-overflowing])');
+ expect(visibleLegends.length).toBe(3);
+ // Every legend is either visible or overflowed.
+ expect(visibleLegends.length + hiddenLegends.length).toBe(legends.length);
+ // The first legends remain visible; the trailing ones overflow.
+ expect(visibleLegends[0].textContent).toBe('Legend 1');
+ expect(hiddenLegends[hiddenLegends.length - 1].textContent).toBe('Legend 17');
+ });
+
+ it('opens a menu listing exactly the overflowed legends when the overflow menu button is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByText('+14 Overflow Items'));
+
+ const menuItems = screen.getAllByRole('menuitemcheckbox');
+ expect(menuItems.length).toBe(14);
+ // The menu lists the overflowed legends (Legend 4 through Legend 17) in order.
+ expect(menuItems[0].textContent).toContain('Legend 4');
+ expect(menuItems[menuItems.length - 1].textContent).toContain('Legend 17');
+ });
+
+ it('renders no overflow menu button when all legends fit', () => {
+ // A container wide enough for all 17 legends (17 * 50 = 850 < 2000 - 10).
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 2000 });
+
+ const { container } = render();
+
+ expect(screen.queryByText(/Overflow Items/)).toBeNull();
+ expect(container.querySelectorAll('[data-overflowing]').length).toBe(0);
+ expect(container.querySelectorAll('button[role="option"]').length).toBe(legends.length);
+ });
+});