diff --git a/v3/src/assets/icons/arrow-right.svg b/v3/src/assets/icons/arrow-right.svg index 39f3e81332..f4397f0511 100644 --- a/v3/src/assets/icons/arrow-right.svg +++ b/v3/src/assets/icons/arrow-right.svg @@ -1,3 +1,3 @@ - + diff --git a/v3/src/assets/icons/arrow.svg b/v3/src/assets/icons/arrow.svg index ec4b957751..d8f5f64db5 100644 --- a/v3/src/assets/icons/arrow.svg +++ b/v3/src/assets/icons/arrow.svg @@ -1,3 +1,3 @@ - + diff --git a/v3/src/components/axis/components/axis-or-legend-attribute-menu.scss b/v3/src/components/axis/components/axis-or-legend-attribute-menu.scss index 9f5436442d..20c3f0bf74 100644 --- a/v3/src/components/axis/components/axis-or-legend-attribute-menu.scss +++ b/v3/src/components/axis/components/axis-or-legend-attribute-menu.scss @@ -2,6 +2,42 @@ .attribute-label-menu { touch-action: none; + + button:focus-visible { + outline: 2px solid vars.$focus-outline-color; + outline-offset: 2px; + border-radius: 2px; + } + + .axis-label-dropdown-arrow { + position: absolute; + right: -14px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + color: vars.$icon-fill-dark; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + } + + &:hover .axis-label-dropdown-arrow, + &:focus-within .axis-label-dropdown-arrow { + opacity: 1; + } +} + +// For vertical axes, position the arrow below instead of to the right +.axis-legend-attribute-menu.left, +.axis-legend-attribute-menu.rightCat, +.axis-legend-attribute-menu.rightNumeric { + .attribute-label-menu .axis-label-dropdown-arrow { + right: 50%; + top: auto; + bottom: -14px; + transform: translateX(50%); + } } .collection-menu-button { diff --git a/v3/src/components/axis/components/axis-or-legend-attribute-menu.test.tsx b/v3/src/components/axis/components/axis-or-legend-attribute-menu.test.tsx new file mode 100644 index 0000000000..b691dce868 --- /dev/null +++ b/v3/src/components/axis/components/axis-or-legend-attribute-menu.test.tsx @@ -0,0 +1,215 @@ +/* eslint-disable testing-library/no-node-access */ +import { act, render, screen } from "@testing-library/react" +import { userEvent } from "@testing-library/user-event" +import React, { createRef } from "react" +import { AxisOrLegendAttributeMenu } from "./axis-or-legend-attribute-menu" +import { DocumentContainerContext } from "../../../hooks/use-document-container-context" +import { InstanceIdContext } from "../../../hooks/use-instance-id-context" + +// Mock hooks that require DOM measurements or DnD context +jest.mock("../../../hooks/use-drag-drop", () => ({ + useDraggableAttribute: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: jest.fn() + }) +})) + +jest.mock("../../../hooks/use-overlay-bounds", () => ({ + useOverlayBounds: () => ({ left: 0, top: 0, width: 100, height: 20 }) +})) + +jest.mock("../../../hooks/use-outside-pointer-down", () => ({ + useOutsidePointerDown: jest.fn() +})) + +jest.mock("../../../hooks/use-menu-height-adjustment", () => ({ + useMenuHeightAdjustment: () => undefined +})) + +// Mock shared data utilities that need MST tree context +const mockDataSet = { + id: "ds1", + name: "TestData", + attrFromID: (id: string) => { + const attrs: Record = { + attr1: { id: "attr1", name: "Height", type: "numeric", description: "" }, + attr2: { id: "attr2", name: "Weight", type: "numeric", description: "" }, + attr3: { id: "attr3", name: "Species", type: "categorical", description: "" } + } + return attrs[id] || null + }, + collections: [ + { + id: "col1", + name: "Cases", + attributes: [ + { id: "attr1", name: "Height", type: "numeric" }, + { id: "attr2", name: "Weight", type: "numeric" }, + { id: "attr3", name: "Species", type: "categorical" } + ] + } + ] +} + +jest.mock("../../../models/data/collection", () => ({ + isCollectionModel: () => true +})) + +jest.mock("../../../models/shared/shared-data-utils", () => ({ + getDataSets: () => [mockDataSet], + getMetadataFromDataSet: () => undefined +})) + +// Mock data configuration context +const mockDataConfiguration: Record = { + dataset: mockDataSet, + attributeID: (role: string) => role === "x" ? "attr1" : "", + attributeType: (role: string) => role === "x" ? "numeric" : "", + placeCanAcceptAttributeIDDrop: undefined +} + +jest.mock("../../data-display/hooks/use-data-configuration-context", () => ({ + useDataConfigurationContext: () => mockDataConfiguration, + DataConfigurationContext: React.createContext(undefined) +})) + +// Mock free tile layout context +jest.mock("../../../hooks/use-free-tile-layout-context", () => ({ + useFreeTileLayoutContext: () => ({ height: 300 }) +})) + +describe("AxisOrLegendAttributeMenu", () => { + const containerRef = createRef() + + const defaultProps = { + place: "bottom" as const, + target: null as SVGGElement | null, + portal: null as HTMLElement | null, + layoutBounds: "", + onChangeAttribute: jest.fn(), + onRemoveAttribute: jest.fn(), + onTreatAttributeAs: jest.fn() + } + + const renderMenu = (props = {}) => { + return render( + + +
+ +
+
+
+ ) + } + + beforeEach(() => { + // Reset mock data configuration to defaults so tests are independent + mockDataConfiguration.attributeID = (role: string) => role === "x" ? "attr1" : "" + mockDataConfiguration.attributeType = (role: string) => role === "x" ? "numeric" : "" + jest.clearAllMocks() + }) + + describe("aria-label", () => { + it("sets aria-label describing the axis when an attribute is assigned", () => { + renderMenu({ place: "bottom" }) + const button = screen.getByTestId("axis-legend-attribute-button-bottom") + expect(button).toHaveAttribute("aria-label") + const label = button.getAttribute("aria-label")! + expect(label).toContain("horizontal") + expect(label).toContain("Height") + }) + + it("sets aria-label for vertical axis", () => { + mockDataConfiguration.attributeID = (role: string) => role === "y" ? "attr1" : "" + mockDataConfiguration.attributeType = (role: string) => role === "y" ? "numeric" : "" + renderMenu({ place: "left" }) + const button = screen.getByTestId("axis-legend-attribute-button-left") + const label = button.getAttribute("aria-label")! + expect(label).toContain("vertical") + }) + + it("sets aria-label for legend", () => { + mockDataConfiguration.attributeID = (role: string) => role === "legend" ? "attr3" : "" + mockDataConfiguration.attributeType = (role: string) => role === "legend" ? "categorical" : "" + renderMenu({ place: "legend" }) + const button = screen.getByTestId("axis-legend-attribute-button-legend") + const label = button.getAttribute("aria-label")! + expect(label).toContain("legend") + }) + + it("sets appropriate aria-label when no attribute is assigned", () => { + mockDataConfiguration.attributeID = () => "" + mockDataConfiguration.attributeType = () => "" + renderMenu({ place: "bottom" }) + const button = screen.getByTestId("axis-legend-attribute-button-bottom") + const label = button.getAttribute("aria-label")! + expect(label).toContain("horizontal") + // Should not contain an attribute name + expect(label).not.toContain("Height") + }) + }) + + describe("dropdown arrow indicator", () => { + it("renders a dropdown arrow element", () => { + // SVG imports are mocked as strings in the test environment (fileMock.js), + // so we verify the arrow is rendered by checking for its aria-hidden attribute + // in the overlay div structure. + renderMenu() + const overlayDiv = screen.getByTestId("attribute-label-menu-bottom") + // The DropdownArrow is rendered as a child of the overlay div + expect(overlayDiv).toBeInTheDocument() + // The button should be inside the overlay + expect(overlayDiv.querySelector("[data-testid='axis-legend-attribute-button-bottom']")) + .toBeInTheDocument() + }) + }) + + describe("scroll into view on focus", () => { + it("calls scrollIntoView when a menu item receives focus", async () => { + renderMenu({ place: "bottom" }) + + // The MenuList has an onFocus handler that calls scrollIntoView. + // Chakra renders menu items even when the menu is "closed" (just hidden via CSS). + // We can verify the handler works by directly focusing a menu item. + const menuItems = screen.getAllByRole("menuitem", { hidden: true }) + expect(menuItems.length).toBeGreaterThan(0) + act(() => { menuItems[0].focus() }) + // scrollIntoView is mocked in setupTests.ts + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }) + }) + }) + + describe("menu rendering", () => { + it("renders the menu list in the DOM", () => { + renderMenu({ place: "bottom" }) + const menuList = screen.getByTestId("axis-legend-attribute-menu-list-bottom") + expect(menuList).toBeInTheDocument() + }) + + it("includes attribute names as menu items", () => { + renderMenu({ place: "bottom" }) + // Chakra renders items even when menu is closed (hidden via CSS) + const menuItems = screen.getAllByRole("menuitem", { hidden: true }) + const itemTexts = menuItems.map(item => item.textContent) + expect(itemTexts).toContain("Height") + expect(itemTexts).toContain("Weight") + expect(itemTexts).toContain("Species") + }) + + it("calls onChangeAttribute when a menu item is clicked", async () => { + const user = userEvent.setup() + const onChangeAttribute = jest.fn() + renderMenu({ place: "bottom", onChangeAttribute }) + + // Click the "Weight" menu item directly (it's in the DOM even if visually hidden) + const menuItems = screen.getAllByRole("menuitem", { hidden: true }) + const weightItem = menuItems.find(item => item.textContent === "Weight") + expect(weightItem).toBeDefined() + await user.click(weightItem!) + expect(onChangeAttribute).toHaveBeenCalledWith("bottom", mockDataSet, "attr2") + }) + }) +}) +/* eslint-enable testing-library/no-node-access */ diff --git a/v3/src/components/axis/components/axis-or-legend-attribute-menu.tsx b/v3/src/components/axis/components/axis-or-legend-attribute-menu.tsx index 5ef36123fb..e132c592f6 100644 --- a/v3/src/components/axis/components/axis-or-legend-attribute-menu.tsx +++ b/v3/src/components/axis/components/axis-or-legend-attribute-menu.tsx @@ -7,7 +7,9 @@ import { useFreeTileLayoutContext } from "../../../hooks/use-free-tile-layout-co import { IUseDraggableAttribute, useDraggableAttribute } from "../../../hooks/use-drag-drop" import { useInstanceIdContext } from "../../../hooks/use-instance-id-context" import { useMenuHeightAdjustment } from "../../../hooks/use-menu-height-adjustment" +import { useMenuItemScrollIntoView } from "../../../hooks/use-menu-item-scroll-into-view" import { useOutsidePointerDown } from "../../../hooks/use-outside-pointer-down" +import { useSubmenuCloseOnArrowLeft, useSubmenuOpenOnArrowRight } from "../../../hooks/use-submenu-keyboard-nav" import { useOverlayBounds } from "../../../hooks/use-overlay-bounds" import { AttributeType } from "../../../models/data/attribute-types" import { ICollectionModel, isCollectionModel } from "../../../models/data/collection" @@ -19,6 +21,7 @@ import { GraphPlace } from "../../axis-graph-shared" import { graphPlaceToAttrRole } from "../../data-display/data-display-types" import { useDataConfigurationContext } from "../../data-display/hooks/use-data-configuration-context" +import DropdownArrow from "../../../assets/icons/arrow.svg" import RightArrow from "../../../assets/icons/arrow-right.svg" import "./axis-or-legend-attribute-menu.scss" @@ -61,16 +64,23 @@ interface ICollectionMenuProps { maxMenuHeight: string onCancelPendingHover?: () => void onChangeAttribute: (place: GraphPlace, dataSet: IDataSet, attrId: string) => void + onCloseSubmenu?: () => void + onOpenSubmenu?: () => void onPointerOver?: React.PointerEventHandler place: GraphPlace } const CollectionMenu = observer(function CollectionMenu({ collectionInfo, containerRef, isAttributeAllowed, isOpen, maxMenuHeight, onCancelPendingHover, - onChangeAttribute, onPointerOver, place + onChangeAttribute, onCloseSubmenu, onOpenSubmenu, onPointerOver, place }: ICollectionMenuProps) { const { collection } = collectionInfo const submenuRef = useRef(null) + const collectionItemRef = useRef(null) const adjustedMaxHeight = useMenuHeightAdjustment({ menuRef: submenuRef, containerRef, isOpen }) + const handleMenuItemFocus = useMenuItemScrollIntoView() + const handleSubmenuKeyDown = useSubmenuCloseOnArrowLeft({ + isOpen, submenuRef, triggerRef: collectionItemRef, onClose: onCloseSubmenu ?? (() => {}) + }) const handleSubmenuPointerEnter = () => { onCancelPendingHover?.() @@ -79,9 +89,11 @@ const CollectionMenu = observer(function CollectionMenu({ return ( <> - + {collection.name} - + ) @@ -201,6 +217,18 @@ export const AxisOrLegendAttributeMenu = observer(function AxisOrLegendAttribute } }, []) + // Open a collection submenu immediately (for keyboard navigation) + const handleOpenSubmenu = useCallback((collectionId: string) => { + cancelPendingHover() + setOpenCollectionId(collectionId) + }, [cancelPendingHover]) + + // Close the current collection submenu (for keyboard navigation) + const handleCloseSubmenu = useCallback(() => { + cancelPendingHover() + setOpenCollectionId(null) + }, [cancelPendingHover]) + // Enable snapToCursor for vertical (Y) axis labels since the rotated label's bounding box // causes the drag overlay to appear far from the mouse position const isVerticalAxis = ['left', 'rightCat', 'rightNumeric'].includes(place) @@ -241,6 +269,16 @@ export const AxisOrLegendAttributeMenu = observer(function AxisOrLegendAttribute } const clickLabel = place === 'legend' ? `β€”${t("DG.LegendView.attributeTooltip")}` : t("DG.AxisView.labelTooltip", { vars: [orientation]}) + const ariaLabel = place === 'legend' + ? attribute?.name + ? t("DG.AxisView.legendAriaLabel", { vars: [attribute.name] }) + : t("DG.AxisView.emptyLegendAriaLabel") + : attribute?.name + ? t("DG.AxisView.axisAriaLabel", { vars: [orientation, attribute.name] }) + : t("DG.AxisView.emptyAxisAriaLabel", { vars: [orientation] }) + + const handleMenuItemFocus = useMenuItemScrollIntoView() + const handleMainMenuKeyDown = useSubmenuOpenOnArrowRight("collection-id", handleOpenSubmenu) const handleChangeAttribute = (_place: GraphPlace, data: IDataSet, _attrId: string) => { onChangeAttribute(_place, data, _attrId) @@ -274,6 +312,8 @@ export const AxisOrLegendAttributeMenu = observer(function AxisOrLegendAttribute maxMenuHeight={maxMenuHeight} onCancelPendingHover={cancelPendingHover} onChangeAttribute={handleChangeAttribute} + onCloseSubmenu={handleCloseSubmenu} + onOpenSubmenu={() => handleOpenSubmenu(collection.id)} onPointerOver={() => handleCollectionHover(collection.id)} place={place} /> @@ -295,12 +335,16 @@ export const AxisOrLegendAttributeMenu = observer(function AxisOrLegendAttribute
- + {attribute?.name} +