diff --git a/packages/react/.scripts/untranslated-copy-debt.json b/packages/react/.scripts/untranslated-copy-debt.json index d9204d6adf..358f298750 100644 --- a/packages/react/.scripts/untranslated-copy-debt.json +++ b/packages/react/.scripts/untranslated-copy-debt.json @@ -1,6 +1,6 @@ { "note": "Untranslated user-visible copy in packages/react/src — string literals that should come from the i18n layer (src/lib/providers/i18n) instead. Enforced by .scripts/check-untranslated-copy.ts. This list may only shrink: translate a string and remove it from here (or run \"--update\"), never add one.", - "total": 133, + "total": 132, "files": { "src/components/F0Card/components/CardMetadata.tsx": [ "Unsupported property type:" @@ -112,9 +112,6 @@ ], "src/lib/F0GridStack/components/grid-stack-provider.tsx": ["No content"], "src/lib/xray.tsx": ["XRay"], - "src/patterns/F0AnalyticsDashboard/components/DashboardGrid/DashboardGrid.tsx": [ - "Drag to reorder" - ], "src/patterns/F0AnalyticsDashboard/components/FilterBar/FilterBarSkeleton.tsx": [ "Loading filters" ], diff --git a/packages/react/src/experimental/OneTable/Table/__tests__/Table.test.tsx b/packages/react/src/experimental/OneTable/Table/__tests__/Table.test.tsx new file mode 100644 index 0000000000..680025c127 --- /dev/null +++ b/packages/react/src/experimental/OneTable/Table/__tests__/Table.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { act, fireEvent, zeroRender as render } from "@/testing/test-utils" + +import { OneTable, TableBody, TableCell, TableRow } from "../../index" + +let resizeCallback: ResizeObserverCallback | undefined + +beforeEach(() => { + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock) +}) + +afterEach(() => { + resizeCallback = undefined + vi.unstubAllGlobals() +}) + +const renderTable = () => + render( + + + + Engineering + + + + ) + +describe("OneTable scrolling", () => { + it("only enters the tab order when its content overflows", () => { + const { container } = renderTable() + const scrollContainer = container.querySelector(".overflow-auto") + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected the table scroll container") + } + + expect(scrollContainer).not.toHaveAttribute("tabindex") + + Object.defineProperties(scrollContainer, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 200 }, + }) + act(() => resizeCallback?.([], {} as ResizeObserver)) + + expect(scrollContainer).toHaveAttribute("tabindex", "0") + + Object.defineProperties(scrollContainer, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 100 }, + clientWidth: { configurable: true, value: 100 }, + scrollWidth: { configurable: true, value: 200 }, + }) + act(() => resizeCallback?.([], {} as ResizeObserver)) + + expect(scrollContainer).toHaveAttribute("tabindex", "0") + + Object.defineProperties(scrollContainer, { + scrollWidth: { configurable: true, value: 100 }, + }) + act(() => resizeCallback?.([], {} as ResizeObserver)) + + expect(scrollContainer).not.toHaveAttribute("tabindex") + expect(scrollContainer.className).toContain("focus-visible:ring") + }) + + it("measures overflow from scroll events when ResizeObserver is unavailable", () => { + vi.stubGlobal("ResizeObserver", undefined) + const { container } = renderTable() + const scrollContainer = container.querySelector(".overflow-auto") + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected the table scroll container") + } + + Object.defineProperties(scrollContainer, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 200 }, + }) + fireEvent.scroll(scrollContainer) + + expect(scrollContainer).toHaveAttribute("tabindex", "0") + }) +}) diff --git a/packages/react/src/experimental/OneTable/Table/index.tsx b/packages/react/src/experimental/OneTable/Table/index.tsx index 7756579ef6..419b2cf435 100644 --- a/packages/react/src/experimental/OneTable/Table/index.tsx +++ b/packages/react/src/experimental/OneTable/Table/index.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion } from "motion/react" import { useEffect, useRef, useState } from "react" -import { cn } from "@/lib/utils" +import { cn, focusRing } from "@/lib/utils" import { Skeleton } from "@/ui/skeleton" import { Table as TableRoot } from "@/ui/table" @@ -23,6 +23,7 @@ export interface TableProps { function TableBase({ children, loading = false }: TableProps) { const [isScrolled, setIsScrolled] = useState(false) const [isScrolledRight, setIsScrolledRight] = useState(false) + const [isScrollable, setIsScrollable] = useState(false) const containerRef = useRef(null) @@ -30,26 +31,48 @@ function TableBase({ children, loading = false }: TableProps) { const container = containerRef.current if (!container) return - const handleScroll = () => { + const updateScrollState = () => { setIsScrolled(container.scrollLeft > 0) setIsScrolledRight( container.scrollWidth - container.scrollLeft - container.clientWidth > 0 ) + setIsScrollable( + container.scrollWidth > container.clientWidth || + container.scrollHeight > container.clientHeight + ) } - handleScroll() - container.addEventListener("scroll", handleScroll) + updateScrollState() + container.addEventListener("scroll", updateScrollState) + + const resizeObserver = + typeof ResizeObserver === "function" + ? new ResizeObserver(updateScrollState) + : null + resizeObserver?.observe(container) + const content = container.firstElementChild + if (content) resizeObserver?.observe(content) return () => { - container.removeEventListener("scroll", handleScroll) + container.removeEventListener("scroll", updateScrollState) + resizeObserver?.disconnect() } - }, []) + }, [children]) return ( -
+
+### Grid lines + +`gridLineType` controls the value-axis grid pattern. It defaults to `"solid"`, +accepts `"dashed"` or `"dotted"`, and accepts a numeric dash sequence such as +`[1, 5]` when a sparser pattern is needed. `gridLineContrast="strong"` uses the +more visible F0 border token while remaining theme-aware; the default is +`"subtle"`. + + + ### Responsive Snapshot The same chart rendered in every breakpoint (small / medium / large) and every series count (low / normal / large) — mirrors the AI Analytics Figma matrix. diff --git a/packages/react/src/kits/F0DataChart/__stories__/Line.stories.tsx b/packages/react/src/kits/F0DataChart/__stories__/Line.stories.tsx index a2e1ee9c16..f1a4feeb2f 100644 --- a/packages/react/src/kits/F0DataChart/__stories__/Line.stories.tsx +++ b/packages/react/src/kits/F0DataChart/__stories__/Line.stories.tsx @@ -2,7 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import type { F0DataChartProps } from "../types" -import { F0DataChart } from "../index" +import { + F0DataChart, + f0DataChartGridLineContrasts, + f0DataChartGridLineTypes, +} from "../index" import { ChartDecorator, ResponsiveSnapshot } from "./decorators" const meta = { @@ -10,6 +14,18 @@ const meta = { title: "F0DataChart/Line", tags: ["autodocs", "experimental"], decorators: [ChartDecorator], + argTypes: { + gridLineContrast: { + control: "select", + options: f0DataChartGridLineContrasts, + table: { type: { summary: f0DataChartGridLineContrasts.join(" | ") } }, + }, + gridLineType: { + control: "select", + options: f0DataChartGridLineTypes, + table: { type: { summary: f0DataChartGridLineTypes.join(" | ") } }, + }, + }, } satisfies Meta export default meta @@ -161,6 +177,22 @@ export const WithDots: Story = { }, } +/** + * Custom value-axis grid patterns keep dense dashboard charts legible while + * preserving the theme-aware grid color. + */ +export const SparseGrid: Story = { + render: (args) => , + args: { + type: "line", + categories: [...MONTHS_SHORT], + series: [{ name: "Clock ins", data: [8, 21, 47, 32, 41, 19] }], + gridLineType: [1, 5], + gridLineContrast: "strong", + lineType: "smooth", + }, +} + // --------------------------------------------------------------------------- // Formatting & minimal variants // --------------------------------------------------------------------------- diff --git a/packages/react/src/kits/F0DataChart/__tests__/BarChart.test.tsx b/packages/react/src/kits/F0DataChart/__tests__/BarChart.test.tsx index 75f2a5b786..8bfb2beeef 100644 --- a/packages/react/src/kits/F0DataChart/__tests__/BarChart.test.tsx +++ b/packages/react/src/kits/F0DataChart/__tests__/BarChart.test.tsx @@ -1,5 +1,4 @@ import { - afterAll, afterEach, beforeAll, beforeEach, @@ -21,6 +20,7 @@ import { resolveChartTheme } from "../utils/theme" // --------------------------------------------------------------------------- const setOptionMock = vi.fn() +const chartDom = document.createElement("div") /** Handlers the chart registered, so tests can fire ECharts events at it. */ const chartHandlers: Record void)[]> = {} @@ -39,7 +39,7 @@ vi.mock("echarts", () => ({ setOption: setOptionMock, resize: vi.fn(), dispose: vi.fn(), - getDom: vi.fn(() => document.createElement("div")), + getDom: vi.fn(() => chartDom), on: vi.fn((event: string, handler: (params: unknown) => void) => { ;(chartHandlers[event] ??= []).push(handler) }), @@ -161,8 +161,16 @@ function getBorderRadii(seriesIndex: number) { ) } +beforeAll(() => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + measureText: (text: string) => ({ width: text.length * 8 }), + } as unknown as CanvasRenderingContext2D) +}) + beforeEach(() => { setOptionMock.mockClear() + chartDom.removeAttribute("role") + chartDom.removeAttribute("aria-label") for (const key of Object.keys(chartHandlers)) delete chartHandlers[key] containerSize.width = 800 containerSize.height = 320 @@ -753,16 +761,6 @@ describe("BarChart — value axis grid density", () => { // --------------------------------------------------------------------------- describe("BarChart — hideOverflowingLabels", () => { - // jsdom has no canvas; return a deterministic width so the measurer is stable. - beforeAll(() => { - vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ - measureText: (text: string) => ({ width: text.length * 8 }), - } as unknown as CanvasRenderingContext2D) - }) - afterAll(() => { - vi.restoreAllMocks() - }) - const base = { type: "bar" as const, categories: ["Jan", "Feb", "Mar"], @@ -1002,6 +1000,84 @@ describe("BarChart — item tooltip", () => { expect(getLatestOption().aria?.label?.description).toContain("107,505") }) + it("does not expose a blank canvas as an unlabeled image", () => { + render( + + ) + + expect(getLatestOption().aria).toEqual({ enabled: false }) + }) + + it("clears stale image semantics when chart data becomes empty", () => { + const { rerender } = render( + + ) + chartDom.setAttribute("role", "img") + chartDom.setAttribute("aria-label", "Revenue: January, 100") + + rerender( + + ) + + expect(chartDom).not.toHaveAttribute("role") + expect(chartDom).not.toHaveAttribute("aria-label") + }) + + it("omits empty named series from the chart description", () => { + render( + + ) + + expect(getLatestOption().aria).toEqual({ enabled: false }) + }) + + it("describes populated series without counting empty series", () => { + const populatedSeries = Array.from({ length: 11 }, (_, index) => ({ + name: `Series ${index + 1}`, + data: [index + 1], + })) + + render( + + ) + + const aria = getLatestOption().aria + expect(aria?.enabled).toBe(true) + expect(aria?.label?.description).toContain("Series 1") + expect(aria?.label?.description).toContain("1 more series.") + expect(aria?.label?.description).not.toContain("3 more series.") + expect(aria?.label?.description).not.toContain("Empty revenue") + expect(aria?.label?.description).not.toContain("Empty margin") + }) + // The tooltip reads the number the way the axis does, so a unit written by // `valueFormatter` (a currency, a "%") is not silently dropped on hover. it("falls back to the axis formatter when no tooltipValueFormatter is given", () => { @@ -1636,8 +1712,8 @@ describe("BarChart — headroom for labels above columns", () => { }) describe("BarChart — category label width", () => { - // jsdom has no canvas, so `measureTextWidth` falls back to 8px per character: - // every expectation below is (longest label length × 8) + 4px of slack, or the + // Keep measurement deterministic across jsdom/canvas implementations: every + // expectation below is (longest label length × 8) + 4px of slack, or the // container-derived cap where that is smaller. const long = "A very long workplace name indeed" // 33 chars → 268 const short = "Berlin" // 6 chars → 52 diff --git a/packages/react/src/kits/F0DataChart/__tests__/HeatmapChart.test.tsx b/packages/react/src/kits/F0DataChart/__tests__/HeatmapChart.test.tsx index d8bb4c157b..ea22e98eb6 100644 --- a/packages/react/src/kits/F0DataChart/__tests__/HeatmapChart.test.tsx +++ b/packages/react/src/kits/F0DataChart/__tests__/HeatmapChart.test.tsx @@ -1,7 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest" -import "@testing-library/jest-dom/vitest" -import { screen } from "@testing-library/react" -import { zeroRender as render } from "@/testing/test-utils" +import { screen, zeroRender as render } from "@/testing/test-utils" import { F0DataChart } from "../F0DataChart" @@ -88,9 +86,9 @@ describe("HeatmapChart — responsive breakpoints", () => { containerSize.width = 180 render() - expect( - screen.getByText("Heatmap not supported at this size") - ).toBeInTheDocument() + expect(screen.getByText("Heatmap not supported at this size")).toHaveClass( + "text-f1-foreground-secondary" + ) }) it("shows only the X axis at the medium breakpoint (220–519px)", () => { diff --git a/packages/react/src/kits/F0DataChart/__tests__/LineChart.test.tsx b/packages/react/src/kits/F0DataChart/__tests__/LineChart.test.tsx index 304ea26740..835b1e4a10 100644 --- a/packages/react/src/kits/F0DataChart/__tests__/LineChart.test.tsx +++ b/packages/react/src/kits/F0DataChart/__tests__/LineChart.test.tsx @@ -65,7 +65,10 @@ function getLatestOption() { series: Array<{ areaStyle?: unknown }> legend?: { show?: boolean } xAxis: { axisLabel: { show: boolean } } - yAxis: { axisLabel: { show: boolean } } + yAxis: { + axisLabel: { show: boolean } + splitLine?: { lineStyle?: { type?: string; color?: string } } + } tooltip?: { formatter?: (params: unknown) => string } @@ -196,6 +199,25 @@ describe("LineChart — area mode", () => { }) }) +describe("LineChart — grid styling", () => { + it("uses the requested line pattern without losing the theme color", () => { + render( + + ) + + expect(getLatestOption().yAxis.splitLine?.lineStyle).toMatchObject({ + type: [1, 5], + color: expect.any(String), + }) + }) +}) + describe("LineChart — responsive breakpoints", () => { const minimalProps = { type: "line" as const, diff --git a/packages/react/src/kits/F0DataChart/__tests__/options.test.ts b/packages/react/src/kits/F0DataChart/__tests__/options.test.ts index dc69c41671..5ece23fc6c 100644 --- a/packages/react/src/kits/F0DataChart/__tests__/options.test.ts +++ b/packages/react/src/kits/F0DataChart/__tests__/options.test.ts @@ -119,6 +119,10 @@ describe("buildValueAxis", () => { expect(axis).not.toHaveProperty("scale") expect(axis.axisLabel).not.toHaveProperty("alignMinLabel") expect(axis.axisLabel).not.toHaveProperty("alignMaxLabel") + expect(axis.splitLine.lineStyle).toEqual({ + type: "solid", + color: theme.colors.borderSecondary, + }) }) it("fits the axis to its data range when scaled", () => { @@ -127,6 +131,32 @@ describe("buildValueAxis", () => { ).toMatchObject({ scale: true }) }) + it("keeps the theme grid color when changing the line pattern", () => { + const axis = buildValueAxis({ + theme, + showGrid: true, + gridLineType: [1, 5], + }) + + expect(axis.splitLine.lineStyle).toEqual({ + type: [1, 5], + color: theme.colors.borderSecondary, + }) + }) + + it("uses the stronger theme border when requested", () => { + const axis = buildValueAxis({ + theme, + showGrid: true, + gridLineContrast: "strong", + }) + + expect(axis.splitLine.lineStyle).toEqual({ + type: "solid", + color: theme.colors.border, + }) + }) + it("anchors the end labels so they cannot overflow the container", () => { const axis = buildValueAxis({ theme, diff --git a/packages/react/src/kits/F0DataChart/components/BarChart/useBarChartOptions.ts b/packages/react/src/kits/F0DataChart/components/BarChart/useBarChartOptions.ts index 89bf6d1b2f..cd7447cfaa 100644 --- a/packages/react/src/kits/F0DataChart/components/BarChart/useBarChartOptions.ts +++ b/packages/react/src/kits/F0DataChart/components/BarChart/useBarChartOptions.ts @@ -1126,7 +1126,10 @@ export function useBarChartOptions( // Keep the DOM attribute bounded for large datasets, matching ECharts' // own default of summarizing rather than serializing every data point. - const ariaDescriptions = series + const describedSeries = series.filter( + (currentSeries) => currentSeries.data.length > 0 + ) + const ariaDescriptions = describedSeries .slice(0, ARIA_MAX_SERIES) .map((currentSeries) => { const values = currentSeries.data @@ -1150,16 +1153,21 @@ export function useBarChartOptions( ) return `${currentSeries.name}: ${values}${remainingValues > 0 ? `; ${remainingValues} more values` : ""}.` }) - if (series.length > ARIA_MAX_SERIES) { - ariaDescriptions.push(`${series.length - ARIA_MAX_SERIES} more series.`) - } - options.aria = { - enabled: true, - label: { - enabled: true, - description: ariaDescriptions.join(" "), - }, + if (describedSeries.length > ARIA_MAX_SERIES) { + ariaDescriptions.push( + `${describedSeries.length - ARIA_MAX_SERIES} more series.` + ) } + const ariaDescription = ariaDescriptions.join(" ") + options.aria = ariaDescription + ? { + enabled: true, + label: { + enabled: true, + description: ariaDescription, + }, + } + : { enabled: false } // Fade in/out of the hover blur state (see `blur` on the series). Two // requirements: `stateAnimation` is only honored at the option root (the diff --git a/packages/react/src/kits/F0DataChart/components/HeatmapChart/HeatmapChart.tsx b/packages/react/src/kits/F0DataChart/components/HeatmapChart/HeatmapChart.tsx index 3359f47af0..30d879b2cd 100644 --- a/packages/react/src/kits/F0DataChart/components/HeatmapChart/HeatmapChart.tsx +++ b/packages/react/src/kits/F0DataChart/components/HeatmapChart/HeatmapChart.tsx @@ -36,7 +36,7 @@ export const HeatmapChart = (props: F0DataChartHeatmapProps) => { className="h-full w-full data-[axis-hover=true]:[&_canvas]:!cursor-default" /> {size === "sm" && ( -
+
{i18n.dataChart.heatmapNotSupported}
)} diff --git a/packages/react/src/kits/F0DataChart/components/LineChart/useLineChartOptions.ts b/packages/react/src/kits/F0DataChart/components/LineChart/useLineChartOptions.ts index 5382477e6d..0ff383ae6a 100644 --- a/packages/react/src/kits/F0DataChart/components/LineChart/useLineChartOptions.ts +++ b/packages/react/src/kits/F0DataChart/components/LineChart/useLineChartOptions.ts @@ -151,6 +151,8 @@ export function useLineChartOptions( showDots = false, showLegend = true, showGrid = true, + gridLineType = "solid", + gridLineContrast = "subtle", showLabels = false, valueFormatter, tooltipValueFormatter, @@ -264,6 +266,8 @@ export function useLineChartOptions( legendData, isVertical: true, showGrid, + gridLineType, + gridLineContrast, showLegend: effectiveShowLegend, showCategoryAxis, showValueAxis, @@ -283,6 +287,8 @@ export function useLineChartOptions( showDots, showLegend, showGrid, + gridLineType, + gridLineContrast, showLabels, valueFormatter, tooltipValueFormatter, diff --git a/packages/react/src/kits/F0DataChart/index.ts b/packages/react/src/kits/F0DataChart/index.ts index 04d13e08df..f94130a881 100644 --- a/packages/react/src/kits/F0DataChart/index.ts +++ b/packages/react/src/kits/F0DataChart/index.ts @@ -11,6 +11,8 @@ export type { F0DataChartFunnelProps, F0DataChartFunnelSeries, F0DataChartGaugeProps, + F0DataChartGridLineContrast, + F0DataChartGridLineType, F0DataChartHeatmapProps, F0DataChartLineDataPoint, F0DataChartLineProps, @@ -31,6 +33,7 @@ export type { } from "./types" export { DataChartEmptyStateView } from "./components/EmptyState/DataChartEmptyStateView" +export { f0DataChartGridLineContrasts, f0DataChartGridLineTypes } from "./types" export { type ChartColorToken, chartColorTokens } from "./utils/colors" export type { ChartTheme } from "./utils/theme" export { diff --git a/packages/react/src/kits/F0DataChart/types.ts b/packages/react/src/kits/F0DataChart/types.ts index cfcae7c5c6..a66c84c76d 100644 --- a/packages/react/src/kits/F0DataChart/types.ts +++ b/packages/react/src/kits/F0DataChart/types.ts @@ -174,6 +174,21 @@ export type F0DataChartLineDataPoint = /** Line interpolation type */ export type F0DataChartLineType = "linear" | "smooth" | "step" +/** Named value-axis grid patterns supported by ECharts. */ +export const f0DataChartGridLineTypes = ["solid", "dashed", "dotted"] as const + +/** Value-axis grid pattern. A numeric sequence defines an ECharts dash pattern. */ +export type F0DataChartGridLineType = + | (typeof f0DataChartGridLineTypes)[number] + | readonly number[] + +/** Semantic contrast levels for value-axis grid lines. */ +export const f0DataChartGridLineContrasts = ["subtle", "strong"] as const + +/** Value-axis grid contrast resolved from the active F0 theme. */ +export type F0DataChartGridLineContrast = + (typeof f0DataChartGridLineContrasts)[number] + /** * A series of data points to render as a line. */ @@ -331,6 +346,10 @@ export interface F0DataChartLineProps extends F0DataChartBaseProps { showArea?: boolean /** Show data point dots on the lines. @default false */ showDots?: boolean + /** Pattern used for value-axis grid lines. @default "solid" */ + gridLineType?: F0DataChartGridLineType + /** Theme-aware contrast used for value-axis grid lines. @default "subtle" */ + gridLineContrast?: F0DataChartGridLineContrast /** * Formatter for the values shown in the hover tooltip. Defaults to * {@link F0DataChartBaseProps.valueFormatter}, so a unit or a currency on the diff --git a/packages/react/src/kits/F0DataChart/utils/options.ts b/packages/react/src/kits/F0DataChart/utils/options.ts index fc4cf7f5e7..cb14d88b62 100644 --- a/packages/react/src/kits/F0DataChart/utils/options.ts +++ b/packages/react/src/kits/F0DataChart/utils/options.ts @@ -1,5 +1,9 @@ import type * as echarts from "echarts" +import type { + F0DataChartGridLineContrast, + F0DataChartGridLineType, +} from "../types" import type { ChartTheme } from "./theme" // --------------------------------------------------------------------------- @@ -270,6 +274,8 @@ export function buildCategoryAxis({ interface ValueAxisOptions { theme: ChartTheme showGrid: boolean + gridLineType?: F0DataChartGridLineType + gridLineContrast?: F0DataChartGridLineContrast formatter?: (value: number) => string /** Max label width in pixels — when set, labels are truncated with ellipsis */ maxLabelWidth?: number @@ -301,10 +307,12 @@ interface ValueAxisOptions { alignEdgeLabels?: boolean } -/** Build a styled value axis with optional solid grid lines */ +/** Build a styled value axis with optional theme-aware grid lines */ export function buildValueAxis({ theme, showGrid, + gridLineType = "solid", + gridLineContrast = "subtle", formatter, maxLabelWidth, show = true, @@ -352,8 +360,11 @@ export function buildValueAxis({ splitLine: { show: showGrid, lineStyle: { - type: "solid" as const, - color: theme.colors.borderSecondary, + type: gridLineType, + color: + gridLineContrast === "strong" + ? theme.colors.border + : theme.colors.borderSecondary, }, }, } @@ -750,6 +761,8 @@ export function buildAxes({ categories, theme, showGrid, + gridLineType, + gridLineContrast, valueFormatter, categoryFormatter, containerWidth, @@ -766,6 +779,8 @@ export function buildAxes({ categories: string[] theme: ChartTheme showGrid: boolean + gridLineType?: F0DataChartGridLineType + gridLineContrast?: F0DataChartGridLineContrast valueFormatter?: (value: number) => string categoryFormatter?: (value: string) => string containerWidth?: number @@ -838,6 +853,8 @@ export function buildAxes({ const valueAxis = buildValueAxis({ theme, showGrid, + gridLineType, + gridLineContrast, formatter: valueFormatter, show: showValueAxis, splitNumber: valueAxisSplitNumber, @@ -882,6 +899,10 @@ interface BaseChartOptionsParams { isVertical: boolean /** Show grid lines on the value axis */ showGrid: boolean + /** Pattern used for value-axis grid lines. */ + gridLineType?: F0DataChartGridLineType + /** Theme-aware contrast used for value-axis grid lines. */ + gridLineContrast?: F0DataChartGridLineContrast /** Show the legend below the chart */ showLegend: boolean /** Format value axis labels */ @@ -936,6 +957,8 @@ export function buildBaseChartOptions({ legendData, isVertical, showGrid, + gridLineType, + gridLineContrast, showLegend, valueFormatter, categoryFormatter, @@ -958,6 +981,8 @@ export function buildBaseChartOptions({ categories, theme, showGrid, + gridLineType, + gridLineContrast, valueFormatter, categoryFormatter, containerWidth, diff --git a/packages/react/src/kits/F0DataChart/utils/useEChartsInstance.ts b/packages/react/src/kits/F0DataChart/utils/useEChartsInstance.ts index 329972b1fc..a1584d143e 100644 --- a/packages/react/src/kits/F0DataChart/utils/useEChartsInstance.ts +++ b/packages/react/src/kits/F0DataChart/utils/useEChartsInstance.ts @@ -38,7 +38,19 @@ export function useEChartsInstance( }, [ref]) useEffect(() => { - chart.current?.setOption(options, true) + const instance = chart.current + instance?.setOption(options, true) + + if ( + instance && + options.aria && + !Array.isArray(options.aria) && + options.aria.enabled === false + ) { + const renderer = instance.getDom() + renderer.removeAttribute("role") + renderer.removeAttribute("aria-label") + } }, [options]) return chart diff --git a/packages/react/src/lib/storybook-utils/docs-nav.tsx b/packages/react/src/lib/storybook-utils/docs-nav.tsx new file mode 100644 index 0000000000..caf2fe8bee --- /dev/null +++ b/packages/react/src/lib/storybook-utils/docs-nav.tsx @@ -0,0 +1,30 @@ +const defaultItems = [ + { label: "Overview", href: "#overview" }, + { label: "Guidelines", href: "#guidelines" }, + { label: "Code", href: "#code" }, + { label: "Examples", href: "#examples" }, +] as const + +export interface DocsNavProps { + items?: ReadonlyArray<{ label: string; href: string }> +} + +/** Compact in-page navigation shared by authored Storybook documentation. */ +export function DocsNav({ items = defaultItems }: DocsNavProps) { + return ( + + ) +} diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/F0AnalyticsDashboard.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/F0AnalyticsDashboard.tsx index 07c7de6737..7644569193 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/F0AnalyticsDashboard.tsx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/F0AnalyticsDashboard.tsx @@ -26,12 +26,13 @@ import { useDashboardExport } from "./hooks/useDashboardExport" /** * F0AnalyticsDashboard — a declarative, config-driven analytics dashboard. * - * Renders a shared filter bar at the top and a 3-column grid of chart - * and collection widgets below. Each widget independently fetches its data, - * receiving the dashboard-level filters in its `fetchData` function. + * Renders a shared filter bar at the top and a responsive grid of metric, + * chart, collection, and location widgets below. Each data-backed widget + * independently fetches its data, receiving the dashboard-level filters. * * The entire dashboard structure is defined via optional `filters` / `presets` - * and an `items` array — making it fully LLM-generatable. + * and an `items` array. Built-in item configuration is serializable; hosts can + * also attach exceptional host-owned renderers through custom items. */ export const F0AnalyticsDashboard = < Filters extends FiltersDefinition = FiltersDefinition, diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/LocationItem.mdx b/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/LocationItem.mdx new file mode 100644 index 0000000000..b52a70ac9e --- /dev/null +++ b/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/LocationItem.mdx @@ -0,0 +1,1047 @@ +import { Canvas, Controls, Meta, Unstyled } from "@storybook/addon-docs/blocks" +import { DoDonts } from "@/lib/storybook-utils/do-donts" +import { DocsNav } from "@/lib/storybook-utils/docs-nav" +import * as Stories from "./LocationItem.stories" + + + +# Analytics dashboard location item + +A built-in `F0AnalyticsDashboard` item for comparing a location-weighted dataset, inspecting rows at one selected location, and reading its timeline. The contract is domain-neutral: clock activity and IT inventory are two examples of the same dashboard item. + + + +# Overview + +## Anatomy + + + +# Guidelines + +### Design best practices + +**When to use** + + + + + + + + + + + + + + + + + + + + + + + +
SituationUse the location item when
Location-led analysis + Coordinates, density, selected records, and a timeline answer one + analytical question. +
Inspectable density + Every map point has domain records that belong in a bounded details + panel. +
Analytics dashboard authoring + Loading, filters, export, Ask One, fullscreen, drag, resize, and the + item menu must remain dashboard-owned. +
+
+ +**When not to use** + + + + + + + + + + + + + + + + + + + + + + + +
SituationUse instead
Coordinates are incidental or unavailableA chart or collection dashboard item.
Records do not share one meaningful density measureA chart dashboard item with explicit series.
The visualization has no reusable location contractA custom dashboard item as the escape hatch.
+
+ +**Do's and don'ts** + + + +### Content best practices + +- Name the analytical question in the item title. + - **Correct:** “IT inventory by location” + - **Incorrect:** “Map” +- Put period and scope in the standard item description. + - **Correct:** “Last 30 days · Europe” + - **Incorrect:** “Data from multiple places and dates” +- Make `detailsLabel` explicit and count the record type. + - **Correct:** “18 people”, “116 devices” + - **Incorrect:** “18”, “116” +- Keep each detail value short. Additional values reflow, but one or two are easiest to scan. +- Supply every domain label and formatter in `location`; the renderer does not invent product copy. + + + +### Behavior + +- WHEN the item shares a row → THEN it uses two dashboard slots and allows at most one peer. +- WHEN an equal-width slot would be narrower than `minItemWidth` → THEN the dashboard stacks the row. +- WHEN details open or close → THEN the summary strip, expanded details header, and collapsed trigger remain aligned at 64px without layout shift. +- WHEN the item is at least 896px wide → THEN summary and details surfaces are 400px wide and short detail values remain on one row. +- WHEN the item shares a narrower dashboard row → THEN summary and details each use the available half width and detail values may wrap. +- WHEN the item is between 480px and 719px wide → THEN details become a bounded disclosure above the timeline. +- WHEN the item is narrower than 480px → THEN the details surface uses the compact full-width layout. +- WHEN `selectedLocationId` is provided → THEN selection is controlled; otherwise `defaultSelectedLocationId` seeds internal selection. +- WHEN `defaultSelectedLocationId` is explicitly `null` → THEN no details panel or disclosure trigger appears until a map location is selected. +- WHEN a section is set to `false` → THEN its surface and layout reservation are both removed in the live map and fallback. +- WHEN timeline data is omitted → THEN the item behaves as if `sections.timeline` were `false`. +- WHEN `densityPalette` overrides only some levels → THEN missing levels retain the default F0 red scale, inaccessible opaque steps normalize within the requested hue, and markers remain identical to their legend swatches. +- WHEN map rendering is unavailable → THEN the same locations remain available through the accessible list fallback. +- WHEN the item enters fullscreen or designer mode → THEN `F0AnalyticsDashboard` continues to own focus, Ask One, reorder, and resize behavior. + +# Code + +Import the dashboard and generic location contracts from the public F0 React barrel. Controls expose the normal dashboard props inherited by the story. + +```tsx +import { + F0AnalyticsDashboard, + type DashboardLocationConfig, + type DashboardLocationData, + type DashboardLocationItem, +} from "@factorialco/f0-react" +``` + + + + + +### Location item contract + +**DashboardLocationItem** + +The item inherits the normal `DashboardItemBase` fields and adds the location data contract. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultRequiredDescription
+ id + + string + YesStable item and layout identity.
+ title + + string + YesStandard dashboard item heading.
+ type + + "location" + YesSelects the built-in location renderer.
+ description + + string + Period and scope below the title.
+ info + + string | InfoHintContent + Explains what the item measures.
+ explanation + + string + Markdown calculation detail in the item menu.
+ location + + DashboardLocationConfig + Yes + Labels, summary definitions, density policy, and optional map style. +
+ fetchData + + (filters) => Promise<DashboardLocationData> + YesFetches the generic location dataset.
+ selectedLocationId + + string | null + Controlled selection.
+ defaultSelectedLocationId + + string | null + First locationFirst point, unless explicitly supplied.
+ onLocationSelect + + (id: string | null) => void + Receives controlled or uncontrolled selection changes.
+ minItemWidth + + number + + 720 + Minimum equal-width slot before stacking.
+ itemHeight + + number + + 700 + Initial item height in pixels.
+ minItemHeight + + number + + 640 + Smallest designer-resized height.
+ x / y + + number + Persisted dashboard position.
+ useDashboardFilters + + boolean + + true + + Passes dashboard filters to fetchData when true. +
+ colSpan / rowSpan + + number + Deprecated persisted-layout compatibility fields.
+
+ +**DashboardLocationConfig** + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultRequiredDescription
+ summaryMetrics + + Tuple of three DashboardLocationSummaryMetric + YesKeys, labels, icons, and tones for the summary strip.
+ densityLabel + + string + YesNames the value encoded by marker color.
+ densityLowLabel + + (below) => string + YesFormats the low legend range.
+ densityMediumLabel + + (from, below) => string + YesFormats the medium legend range.
+ densityHighLabel + + (from) => string + YesFormats the high legend range.
+ timelineTitle + + string + YesVisible timeline title.
+ timelineAriaLabel + + string + YesNames the timeline text alternative.
+ mapAriaLabel + + string + YesNames the map and its fallback region.
+ selectLocationLabel + + string + YesEmpty-selection instruction.
+ viewLocationDetailsLabel + + (name) => string + YesAccessible disclosure label.
+ closeLocationDetailsLabel + + string + YesAccessible close label.
+ noDataLabel + + string + YesEmpty-state copy.
+ exportLabels + + DashboardLocationExportLabels + YesHost-localized spreadsheet column labels.
+ sections + + DashboardLocationSections + All visible + Independently controls summary,{" "} + locationDetails,densityLegend, and{" "} + timeline. Hidden surfaces do not reserve map space. +
+ densityPalette + + Partial<F0MapDensityPalette> + Sequential F0 red + Overrides low, medium, or high with F0 color tokens. Inaccessible + opaque steps resolve to the nearest accessible step of the same hue; + that resolved palette is shared by map markers, clusters, and the + legend. +
+ densityScale + + {`{ mediumAt: number; highAt: number }`} + + {`{ mediumAt: 6, highAt: 16 }`} + Invalid ranges fall back to the default scale.
+ formatDensity + + (value) => string + Browser localeBrowser-locale number formatting.
+ formatSummaryValue + + (value) => string + Browser localeBrowser-locale number formatting.
+ mapStyle + + F0MapStylePair + F0 light/dark map stylesOverrides the default light and dark map styles.
+
+ +**Data and nested contracts** + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeRequired fieldsOptional fields / constraints
+ DashboardLocationData + + summary, locations + + timeline is optional. Summary values are strings or + numbers keyed by summary metric IDs. +
+ DashboardLocationSummaryMetric + + id, label, icon + + tone: default, positive, critical, or selected. +
+ DashboardLocationPoint + + id, name, coordinates,{" "} + density, detailsLabel, details + + Coordinates are [longitude, latitude]. +
+ DashboardLocationDetailRow + + id, title, avatar,{" "} + values + + description; prefer one or two values. +
+ DashboardLocationDetailValue + + label, value + + icon, iconColor, and a default, positive, or + critical tone. +
+ DashboardLocationTimelineData + + categories, series + + accessibleLabels, one per category; series use{" "} + F0DataChartLineSeries. +
+ DashboardLocationSections + + summary, locationDetails,{" "} + densityLegend, and timeline are optional + booleans; each defaults to true. +
+ DashboardLocationExportLabels + + location, density, details,{" "} + item, description + Host-localized spreadsheet headers.
+ F0MapDensityStyle + + color, colorStep + + color is an F0 categorical hue (not neutral/grey);{" "} + colorStep is 10 | 50 | 60 | 70. Inaccessible + opaque steps resolve within the same hue. +
+ F0MapDensityPalette + + low, medium, high + + Each level is an F0MapDensityStyle. +
+
+ +# Examples + +**Clock activity** + +Configure every domain label while keeping the renderer and dashboard behavior generic. + +```tsx +import { + F0AnalyticsDashboard, + type DashboardLocationConfig, + type DashboardLocationData, + type DashboardLocationItem, +} from "@factorialco/f0-react" +import { ChartLine, ClockBack } from "@factorialco/f0-react/icons/app" +import { ClockIn } from "@factorialco/f0-react/icons/modules" + +const location: DashboardLocationConfig = { + summaryMetrics: [ + { id: "clockIns", label: "Clock ins", icon: ClockIn, tone: "positive" }, + { id: "clockOuts", label: "Clock outs", icon: ClockBack, tone: "critical" }, + { id: "peak", label: "Density", icon: ChartLine, tone: "selected" }, + ], + densityLabel: "Density", + densityLowLabel: (below) => `1–${below - 1}`, + densityMediumLabel: (from, below) => `${from}–${below - 1}`, + densityHighLabel: (from) => `${from}+`, + timelineTitle: "24-hour activity", + timelineAriaLabel: "Clock activity by hour", + mapAriaLabel: "Clock activity by location", + selectLocationLabel: "Select a location", + viewLocationDetailsLabel: (name) => `View activity for ${name}`, + closeLocationDetailsLabel: "Close location activity", + noDataLabel: "No clock activity for this period", + exportLabels: { + location: "Location", + density: "Density", + details: "People", + item: "Employee", + description: "Workplace", + }, +} + +const data: DashboardLocationData = { + summary: { clockIns: 1389, clockOuts: 1276, peak: "Peak 09:00" }, + locations: [{ + id: "barcelona", + name: "Barcelona · HQ", + coordinates: [2.1734, 41.3851], + density: 39, + detailsLabel: "1 person", + details: [{ + id: "alex", + title: "Alex Rivera", + description: "Workplace", + avatar: { type: "person", firstName: "Alex", lastName: "Rivera" }, + values: [ + { label: "Clock in", value: "09:02", icon: ClockIn }, + { label: "Clock out", value: "18:07", icon: ClockBack }, + ], + }], + }], + timeline: { + categories: ["00:00", "12:00", "24:00"], + series: [ + { name: "Clock ins", data: [0, 12, 0], color: "viridian" }, + { name: "Clock outs", data: [0, 8, 0], color: "red", dashed: true }, + ], + }, +} + +const item: DashboardLocationItem = { + id: "clock-activity", + type: "location", + title: "Clock activity by location", + description: "Last 30 days · Europe", + location, + fetchData: async () => data, +} + + +``` + +**IT inventory with the same item type** + +The same `type: "location"` item renders assets by changing only configuration and data. + + + +```tsx +import { + F0AnalyticsDashboard, + type DashboardLocationData, + type DashboardLocationItem, +} from "@factorialco/f0-react" +import { + AlertCircle, + CheckCircle, + Laptop, +} from "@factorialco/f0-react/icons/app" + +const inventoryData: DashboardLocationData = { + summary: { assigned: 322, available: 48, attention: 17 }, + locations: [ + { + id: "barcelona-it", + name: "Barcelona · HQ", + coordinates: [2.1734, 41.3851], + density: 116, + detailsLabel: "116 devices", + details: [ + { + id: "mac-1842", + title: 'MacBook Pro 14" · IT-1842', + description: "Alex Rivera", + avatar: { type: "icon", icon: Laptop }, + values: [{ label: "Status", value: "Healthy", icon: CheckCircle }], + }, + ], + }, + ], + timeline: { + categories: ["00:00", "12:00", "24:00"], + series: [ + { name: "Assignments", data: [0, 18, 0], color: "viridian" }, + { name: "Returns", data: [0, 11, 0], color: "red", dashed: true }, + ], + }, +} + +const inventory: DashboardLocationItem = { + id: "it-inventory", + type: "location", + title: "IT inventory by location", + description: "Today · Europe", + location: { + summaryMetrics: [ + { id: "assigned", label: "Assigned", icon: Laptop, tone: "selected" }, + { + id: "available", + label: "Available", + icon: CheckCircle, + tone: "positive", + }, + { + id: "attention", + label: "Attention", + icon: AlertCircle, + tone: "critical", + }, + ], + densityLabel: "Devices", + densityLowLabel: (below) => `1–${below - 1}`, + densityMediumLabel: (from, below) => `${from}–${below - 1}`, + densityHighLabel: (from) => `${from}+`, + densityScale: { mediumAt: 35, highAt: 80 }, + timelineTitle: "24-hour asset movement", + timelineAriaLabel: "Asset assignments and returns by hour", + mapAriaLabel: "IT inventory by location", + selectLocationLabel: "Select an office on the map", + viewLocationDetailsLabel: (name) => `View inventory for ${name}`, + closeLocationDetailsLabel: "Close location inventory", + noDataLabel: "No inventory for this period", + exportLabels: { + location: "Location", + density: "Devices", + details: "Inventory", + item: "Device", + description: "Owner", + }, + }, + fetchData: async () => inventoryData, +} + + +``` + +**Modular surfaces and density colors** + +Choose only the surfaces that answer the analytical question. Palette entries +accept F0 color names and steps; raw CSS colors are intentionally unsupported. +The following is a focused continuation of the complete `inventory` and +`inventoryData` example above. + + + + +```tsx +// Continue from the complete IT inventory example above. +const mapOnlyItem: DashboardLocationItem = { + ...inventory, + id: "map-only", + location: { + ...inventory.location, + sections: { + summary: false, + locationDetails: false, + densityLegend: false, + timeline: false, + }, + densityPalette: { + low: { color: "malibu", colorStep: 10 }, + medium: { color: "indigo", colorStep: 60 }, + high: { color: "purple", colorStep: 70 }, + }, + }, + fetchData: async () => ({ + summary: {}, + locations: inventoryData.locations, + }), +} + + +``` + +**Optional section variants** + +Each Canvas is a tested contract variant rather than a cosmetic demo. + + + + + + +**Dashboard designer and fullscreen** + +Use the normal dashboard props; location items inherit the same authoring and One actions as other widgets. + + + +**Responsive and shared rows** + +These examples cover the roomy 400px details surface, equal two-column sizing, automatic row stacking, the intermediate disclosure, and the compact sub-480px layout. + + + + + + + +**Crowded and operational states** + +Detail scrolling, no selection, empty, loading, error, dark, and map-fallback states keep the standard dashboard shell. + + + + + + + + + +## Accessibility + +### Screen reader behavior + +- The visible map points are presentational. Every point and cluster remains available through the keyboard-operable `F0MapList`, so there is one accessibility path rather than duplicate focus stops. +- Selecting a point updates a labelled complementary details region. +- Keyboard activation moves focus to Close when the disclosure opens and restores it to the trigger when it closes. Pointer activation does not force a focus ring. Later resizes do not hide the focused subtree. +- A crowded details list is focusable and keyboard-scrollable. +- The timeline exposes one text summary per category, so the canvas is never its only data surface. +- Fullscreen transfers focus from Expand to Collapse and restores it on exit. + +### Keyboard interaction + + + + + + + + + + + + + + + + + + + + + + + +
KeyAction
+ Tab + + Moves through dashboard controls, the operable location list, the + details disclosure, and the detail list. +
+ Enter / Space + Selects a location or opens and closes its details.
+ Arrow keys + + Scroll the focused detail list; in edit mode they also provide + non-pointer reorder and resize controls. +
+
diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/LocationItem.stories.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/LocationItem.stories.tsx new file mode 100644 index 0000000000..7f2e65078a --- /dev/null +++ b/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/LocationItem.stories.tsx @@ -0,0 +1,1426 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { useEffect, useRef, type ReactNode } from "react" +import { expect, fn, userEvent, waitFor, within } from "storybook/test" + +import type { F0MapStylePair } from "@/patterns/F0Map" + +import { + AlertCircle, + ChartLine, + CheckCircle, + ClockBack, + Computer, + Laptop, + Mobile, +} from "@/icons/app" +import { ClockIn } from "@/icons/modules" +import { withSnapshot } from "@/lib/storybook-utils/parameters" + +import type { + DashboardChartItem, + DashboardItem, + DashboardLocationConfig, + DashboardLocationData, + DashboardLocationDetailRow, + DashboardLocationItem, +} from "../types" + +import { F0AnalyticsDashboard } from "../index" + +const handleAskAi = fn() +const handleLayoutChange = fn() +const handleLocationSelectWithoutDetails = fn() + +const personDetail = ( + id: string, + firstName: string, + lastName: string, + clockIn: string, + clockOut: string, + description = "Workplace" +): DashboardLocationDetailRow => ({ + id, + title: `${firstName} ${lastName}`, + description, + avatar: { type: "person", firstName, lastName }, + values: [ + { + label: "Clock in", + value: clockIn, + icon: ClockIn, + iconColor: "positive", + }, + { + label: "Clock out", + value: clockOut, + icon: ClockBack, + iconColor: "critical", + tone: clockOut === "Active" ? "positive" : "default", + }, + ], +}) + +const barcelonaPeople = [ + ["alex-rivera", "Alex", "Rivera", "09:02", "18:07"], + ["pau-garcia", "Pau", "Garcia", "09:04", "18:03"], + ["marta-soler", "Marta", "Soler", "09:05", "18:12"], + ["jordi-pons", "Jordi", "Pons", "09:06", "Active"], + ["elena-costa", "Elena", "Costa", "08:52", "17:40"], + ["marc-vidal", "Marc", "Vidal", "08:58", "17:51"], + ["laia-ferrer", "Laia", "Ferrer", "09:13", "18:26"], + ["oriol-puig", "Oriol", "Puig", "09:17", "18:32"], + ["nuria-serra", "Núria", "Serra", "09:21", "18:29"], + ["pol-roca", "Pol", "Roca", "09:26", "18:41"], + ["anna-marti", "Anna", "Martí", "09:31", "18:47"], + ["roger-font", "Roger", "Font", "09:38", "18:53"], + ["emma-sala", "Emma", "Sala", "09:44", "18:58"], + ["jan-casas", "Jan", "Casas", "09:49", "19:03"], + ["clara-vila", "Clara", "Vila", "09:56", "19:07"], + ["nil-mas", "Nil", "Mas", "10:02", "19:08"], + ["aina-bosch", "Aina", "Bosch", "10:24", "Active", "Remote"], + ["eric-soler", "Eric", "Soler", "11:06", "20:14"], +] as const + +const clockCategories = Array.from( + { length: 25 }, + (_, hour) => `${String(hour).padStart(2, "0")}:00` +) +const clockIns = [ + 2, 1, 1, 2, 4, 8, 16, 31, 58, 96, 59, 48, 52, 43, 39, 32, 49, 42, 55, 34, 20, + 13, 8, 4, 0, +] +const clockOuts = [ + 1, 1, 1, 1, 2, 3, 7, 13, 26, 38, 42, 45, 48, 44, 41, 38, 46, 52, 71, 49, 29, + 18, 10, 5, 0, +] + +const clockActivityConfig: DashboardLocationConfig = { + summaryMetrics: [ + { id: "clockIns", label: "Clock ins", icon: ClockIn, tone: "positive" }, + { + id: "clockOuts", + label: "Clock outs", + icon: ClockBack, + tone: "critical", + }, + { id: "peak", label: "Density", icon: ChartLine, tone: "selected" }, + ], + densityLabel: "Density", + densityLowLabel: (below) => `1–${below - 1}`, + densityMediumLabel: (from, below) => `${from}–${below - 1}`, + densityHighLabel: (from) => `${from}+`, + timelineTitle: "24-hour activity", + timelineAriaLabel: "Clock activity by hour", + mapAriaLabel: "Clock activity by location", + selectLocationLabel: "Select a location on the map", + viewLocationDetailsLabel: (name) => `View activity for ${name}`, + closeLocationDetailsLabel: "Close location activity", + noDataLabel: "No clock activity for this period", + exportLabels: { + location: "Location", + density: "Density", + details: "Details", + item: "Employee", + description: "Workplace", + }, +} + +const clockActivityData: DashboardLocationData = { + summary: { clockIns: 1389, clockOuts: 1276, peak: "Peak 09:00" }, + locations: [ + { + id: "barcelona", + name: "Barcelona · HQ", + coordinates: [2.1734, 41.3851], + density: 39, + detailsLabel: "18 people", + details: barcelonaPeople.map( + ([id, firstName, lastName, clockIn, clockOut, description]) => + personDetail(id, firstName, lastName, clockIn, clockOut, description) + ), + }, + { + id: "madrid", + name: "Madrid · Castellana", + coordinates: [-3.7038, 40.4168], + density: 28, + detailsLabel: "2 people", + details: [ + personDetail("lucia-vega", "Lucía", "Vega", "08:54", "17:58"), + personDetail("daniel-ruiz", "Daniel", "Ruiz", "09:10", "Active"), + ], + }, + { + id: "london", + name: "London · Shoreditch", + coordinates: [-0.1276, 51.5072], + density: 19, + detailsLabel: "2 people", + details: [ + personDetail("amelia-clarke", "Amelia", "Clarke", "08:48", "17:31"), + personDetail("theo-martin", "Theo", "Martin", "09:12", "Active"), + ], + }, + { + id: "paris", + name: "Paris · République", + coordinates: [2.3522, 48.8566], + density: 18, + detailsLabel: "2 people", + details: [ + personDetail("lea-bernard", "Léa", "Bernard", "09:01", "18:16"), + personDetail("hugo-petit", "Hugo", "Petit", "09:08", "18:22"), + ], + }, + { + id: "berlin", + name: "Berlin · Mitte", + coordinates: [13.405, 52.52], + density: 12, + detailsLabel: "1 person", + details: [personDetail("mia-wagner", "Mia", "Wagner", "08:57", "17:46")], + }, + { + id: "rome", + name: "Rome · Termini", + coordinates: [12.4964, 41.9028], + density: 5, + detailsLabel: "1 person", + details: [ + personDetail("giulia-romano", "Giulia", "Romano", "09:03", "18:05"), + ], + }, + ], + timeline: { + categories: clockCategories, + series: [ + { name: "Clock ins", data: clockIns, color: "viridian" }, + { name: "Clock outs", data: clockOuts, color: "red", dashed: true }, + ], + accessibleLabels: clockCategories.map( + (hour, index) => + `${hour}: ${clockIns[index]} clock ins, ${clockOuts[index]} clock outs` + ), + }, +} + +const deviceDetail = ( + id: string, + name: string, + kind: "laptop" | "computer" | "mobile", + owner: string, + status: string +): DashboardLocationDetailRow => ({ + id, + title: name, + description: owner, + avatar: { + type: "icon", + icon: kind === "laptop" ? Laptop : kind === "mobile" ? Mobile : Computer, + }, + values: [ + { + label: "Status", + value: status, + icon: status === "Healthy" ? CheckCircle : AlertCircle, + iconColor: status === "Healthy" ? "positive" : "critical", + tone: status === "Healthy" ? "positive" : "critical", + }, + ], +}) + +const itInventoryConfig: DashboardLocationConfig = { + summaryMetrics: [ + { id: "assigned", label: "Assigned", icon: Laptop, tone: "selected" }, + { + id: "available", + label: "Available", + icon: CheckCircle, + tone: "positive", + }, + { + id: "attention", + label: "Attention", + icon: AlertCircle, + tone: "critical", + }, + ], + densityLabel: "Devices", + densityLowLabel: (below) => `1–${below - 1}`, + densityMediumLabel: (from, below) => `${from}–${below - 1}`, + densityHighLabel: (from) => `${from}+`, + densityScale: { mediumAt: 35, highAt: 80 }, + timelineTitle: "24-hour asset movement", + timelineAriaLabel: "Asset assignments and returns by hour", + mapAriaLabel: "IT inventory by location", + selectLocationLabel: "Select an office on the map", + viewLocationDetailsLabel: (name) => `View inventory for ${name}`, + closeLocationDetailsLabel: "Close location inventory", + noDataLabel: "No inventory for this period", + exportLabels: { + location: "Location", + density: "Devices", + details: "Inventory", + item: "Device", + description: "Owner", + }, +} + +const itInventoryData: DashboardLocationData = { + summary: { assigned: 322, available: 48, attention: 17 }, + locations: [ + { + id: "barcelona-it", + name: "Barcelona · HQ", + coordinates: [2.1734, 41.3851], + density: 116, + detailsLabel: "116 devices", + details: [ + deviceDetail( + "mac-1842", + 'MacBook Pro 14" · IT-1842', + "laptop", + "Alex Rivera", + "Healthy" + ), + deviceDetail( + "imac-0271", + 'iMac 24" · IT-0271', + "computer", + "Design studio", + "Needs update" + ), + deviceDetail( + "iphone-9084", + "iPhone 17 · IT-9084", + "mobile", + "Marta Soler", + "Healthy" + ), + ], + }, + { + id: "madrid-it", + name: "Madrid · Castellana", + coordinates: [-3.7038, 40.4168], + density: 84, + detailsLabel: "84 devices", + details: [ + deviceDetail( + "mac-1321", + 'MacBook Air 13" · IT-1321', + "laptop", + "Lucía Vega", + "Healthy" + ), + deviceDetail( + "surface-044", + "Surface Studio · IT-0044", + "computer", + "Finance lab", + "Needs update" + ), + ], + }, + { + id: "london-it", + name: "London · Shoreditch", + coordinates: [-0.1276, 51.5072], + density: 63, + detailsLabel: "63 devices", + details: [ + deviceDetail( + "mac-2210", + 'MacBook Pro 16" · IT-2210', + "laptop", + "Amelia Clarke", + "Healthy" + ), + ], + }, + { + id: "paris-it", + name: "Paris · République", + coordinates: [2.3522, 48.8566], + density: 27, + detailsLabel: "27 devices", + details: [ + deviceDetail( + "iphone-0103", + "iPhone 17 · IT-0103", + "mobile", + "Léa Bernard", + "Healthy" + ), + ], + }, + ], + timeline: { + categories: clockCategories, + series: [ + { + name: "Assignments", + data: [ + 0, 0, 0, 0, 0, 1, 2, 4, 8, 15, 12, 10, 8, 7, 6, 8, 11, 9, 5, 3, 2, 1, + 0, 0, 0, + ], + color: "viridian", + }, + { + name: "Returns", + data: [ + 0, 0, 0, 0, 0, 0, 1, 2, 4, 6, 7, 8, 7, 6, 7, 9, 10, 13, 11, 7, 4, 2, + 1, 0, 0, + ], + color: "red", + dashed: true, + }, + ], + }, +} + +const snapshotMapStyle: F0MapStylePair = { + light: { + version: 8, + sources: {}, + layers: [ + { + id: "background", + type: "background", + paint: { "background-color": "#dcecf4" }, + }, + ], + }, + dark: { + version: 8, + sources: {}, + layers: [ + { + id: "background", + type: "background", + paint: { "background-color": "#1c2a34" }, + }, + ], + }, +} + +const locationItem = ( + data: DashboardLocationData, + config: DashboardLocationConfig, + overrides: Partial = {} +): DashboardLocationItem => ({ + id: "location-activity", + type: "location", + title: "Clock activity by location", + description: "Last 30 days · Europe", + info: "Activity grouped by the location recorded for each event.", + explanation: + "Density is the host-defined value for each mapped location during the selected period.", + defaultSelectedLocationId: data.locations[0]?.id ?? null, + location: config, + fetchData: async () => data, + ...overrides, +}) + +const companionChart: DashboardChartItem = { + id: "activity-by-location", + type: "chart", + title: "Clock events by workplace", + description: "Last 30 days · Europe", + itemHeight: 700, + chart: { type: "bar", orientation: "horizontal" }, + fetchData: async () => ({ + categories: clockActivityData.locations.map((location) => location.name), + series: [ + { + name: "Density", + data: clockActivityData.locations.map((location) => location.density), + }, + ], + }), +} + +const DashboardFrame = ({ + items, + width, + editMode = false, + className = "h-[780px]", +}: { + items: DashboardItem[] + width?: number + editMode?: boolean + className?: string +}) => ( +
+
+ +
+
+) + +const WebGlUnavailable = ({ children }: { children: ReactNode }) => { + const originalGetContext = useRef() + + if (!originalGetContext.current) { + originalGetContext.current = HTMLCanvasElement.prototype.getContext + const getContext = originalGetContext.current as ( + this: HTMLCanvasElement, + ...args: unknown[] + ) => unknown + HTMLCanvasElement.prototype.getContext = function ( + this: HTMLCanvasElement, + ...args: unknown[] + ) { + const contextId = args[0] + if ( + this.closest("[data-story-map-unavailable]") && + (contextId === "webgl" || + contextId === "webgl2" || + contextId === "experimental-webgl") + ) { + return null + } + return getContext.apply(this, args) + } as HTMLCanvasElement["getContext"] + } + + useEffect( + () => () => { + if (originalGetContext.current) { + HTMLCanvasElement.prototype.getContext = originalGetContext.current + } + }, + [] + ) + + return children +} + +const defaultItems = [ + locationItem(clockActivityData, clockActivityConfig), +] satisfies DashboardItem[] + +const longLocationData: DashboardLocationData = { + ...clockActivityData, + locations: clockActivityData.locations.map((location, index) => + index === 0 + ? { + ...location, + name: "Barcelona · Headquarters and Innovation Campus", + } + : location + ), +} + +const emptyLocationItem = locationItem( + { + ...clockActivityData, + locations: [], + timeline: { categories: [], series: [] }, + }, + clockActivityConfig, + { id: "empty-location", defaultSelectedLocationId: null } +) + +const noSelectionLocationItem = locationItem( + clockActivityData, + clockActivityConfig, + { + id: "location-without-selection", + defaultSelectedLocationId: null, + } +) + +const customDensityItem = locationItem( + clockActivityData, + { + ...clockActivityConfig, + densityPalette: { + low: { color: "malibu", colorStep: 10 }, + medium: { color: "indigo", colorStep: 60 }, + high: { color: "purple", colorStep: 70 }, + }, + }, + { id: "custom-density-palette" } +) + +const withoutSummaryItem = locationItem( + clockActivityData, + { + ...clockActivityConfig, + sections: { summary: false }, + }, + { id: "without-location-summary" } +) + +const withoutDetailsItem = locationItem( + clockActivityData, + { + ...clockActivityConfig, + sections: { locationDetails: false }, + }, + { + id: "without-location-details", + onLocationSelect: handleLocationSelectWithoutDetails, + } +) + +const withoutLegendItem = locationItem( + clockActivityData, + { + ...clockActivityConfig, + sections: { densityLegend: false }, + }, + { id: "without-density-legend" } +) + +const clockActivityWithoutTimeline: DashboardLocationData = { + summary: clockActivityData.summary, + locations: clockActivityData.locations, +} + +const withoutTimelineItem = locationItem( + clockActivityData, + { + ...clockActivityConfig, + sections: { timeline: false }, + }, + { id: "without-location-timeline" } +) + +const mapOnlyItem = locationItem( + clockActivityWithoutTimeline, + { + ...clockActivityConfig, + sections: { + summary: false, + locationDetails: false, + densityLegend: false, + timeline: false, + }, + }, + { id: "map-only-location" } +) + +const loadingLocationItem = locationItem( + clockActivityData, + clockActivityConfig, + { + id: "loading-location", + fetchData: () => new Promise(() => {}), + } +) + +const errorLocationItem = locationItem(clockActivityData, clockActivityConfig, { + id: "error-location", + fetchData: async () => { + throw new globalThis.Error("Location data unavailable") + }, +}) + +const meta = { + title: "AnalyticsDashboard/Location item", + component: F0AnalyticsDashboard, + tags: ["experimental", "!autodocs"], + parameters: { + layout: "fullscreen", + a11y: { test: "error" }, + chromatic: { disableSnapshot: true }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { items: defaultItems }, + render: (args) => , +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const page = within(canvasElement.closest("body")!) + + await step("Use the spacious details layout", async () => { + const panel = await canvas.findByRole("complementary", { + name: "Barcelona · HQ", + }) + const visualization = canvasElement.querySelector( + "[data-location-visualization]" + ) + const legend = canvasElement.querySelector( + "[data-location-density-legend]" + ) + const timeline = canvasElement.querySelector( + "[data-location-timeline]" + ) + const detailValues = panel.querySelector( + "[data-location-detail-values]" + ) + if (!visualization || !legend || !timeline || !detailValues) { + throw new globalThis.Error( + "Expected visualization, legend, timeline, and location detail values" + ) + } + await expect( + visualization.getBoundingClientRect().width + ).toBeGreaterThanOrEqual(896) + await expect(panel.getBoundingClientRect().width).toBeGreaterThanOrEqual( + 398 + ) + await expect(getComputedStyle(legend).maxWidth).toBe("calc(100% - 440px)") + await expect(legend.getBoundingClientRect().right).toBeLessThanOrEqual( + panel.getBoundingClientRect().left + ) + await expect(legend.getBoundingClientRect().bottom).toBeLessThanOrEqual( + timeline.getBoundingClientRect().top + ) + await expect(getComputedStyle(detailValues).flexWrap).toBe("nowrap") + await expect(getComputedStyle(panel).boxShadow).toBe("none") + }) + + await step("Select another location", async () => { + const locations = canvas.getByRole("navigation", { name: "Locations" }) + await userEvent.click( + within(locations).getByRole("button", { name: /Paris · République/ }) + ) + await expect( + canvas.getByRole("complementary", { name: "Paris · République" }) + ).toBeInTheDocument() + }) + + await step("Use the standard dashboard menu", async () => { + await userEvent.click( + canvas.getByRole("button", { name: "Other actions" }) + ) + await expect( + page.getByRole("menuitem", { name: "Where does this data come from?" }) + ).toBeInTheDocument() + await expect( + page.getByRole("menuitem", { name: "Ask One" }) + ).toBeInTheDocument() + + await userEvent.keyboard("{Escape}") + await waitFor(() => { + expect( + page.queryByRole("menuitem", { + name: "Where does this data come from?", + }) + ).not.toBeInTheDocument() + }) + }) + }, +} + +/** The same built-in item renders IT inventory without time-tracking code. */ +export const ITInventory: Story = { + tags: ["no-sidebar"], + args: { + items: [ + locationItem(itInventoryData, itInventoryConfig, { + id: "it-inventory-by-location", + title: "IT inventory by location", + description: "Today · Europe", + info: "Assigned devices grouped by their current workplace.", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(await canvas.findByText("Assigned")).toBeInTheDocument() + await expect(canvas.getAllByText("116 devices")).not.toHaveLength(0) + await expect( + canvas.getByText('MacBook Pro 14" · IT-1842') + ).toBeInTheDocument() + await expect(canvas.getByText("24-hour asset movement")).toBeInTheDocument() + }, +} + +/** Hosts can replace the default red heat scale with F0 palette tokens. */ +export const CustomDensityPalette: Story = { + tags: ["no-sidebar"], + args: { items: [customDensityItem] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const legend = await waitFor(() => { + const element = canvasElement.querySelector( + "[data-location-density-legend]" + ) + if (!element) throw new globalThis.Error("Expected the density legend") + return element + }) + await expect( + legend.querySelector('[data-density-level="low"]')?.style + .backgroundColor + ).toBe("hsl(var(--neutral-0))") + await expect( + legend.querySelector('[data-density-level="medium"]') + ).toHaveStyle({ backgroundColor: "hsl(239 59% 54%)" }) + await expect( + legend.querySelector('[data-density-level="high"]') + ).toHaveStyle({ backgroundColor: "hsl(258 43% 46%)" }) + await expect( + await canvas.findByRole("button", { + name: /Barcelona · HQ · Density: 39/, + }) + ).toBeInTheDocument() + }, +} + +export const WithoutSummary: Story = { + tags: ["no-sidebar"], + args: { items: [withoutSummaryItem] }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await canvas.findByRole("navigation", { name: "Locations" }) + await expect( + canvasElement.querySelector("[data-location-summary]") + ).not.toBeInTheDocument() + const trigger = canvas.getByRole("button", { + name: "View activity for Barcelona · HQ", + }) + const legend = canvasElement.querySelector( + "[data-location-density-legend]" + ) + if (!legend) throw new globalThis.Error("Expected the density legend") + await expect(legend.getBoundingClientRect().top).toBeGreaterThanOrEqual( + trigger.getBoundingClientRect().bottom + 8 + ) + }, +} + +export const WithoutDetails: Story = { + tags: ["no-sidebar"], + args: { items: [withoutDetailsItem] }, + play: async ({ canvasElement }) => { + handleLocationSelectWithoutDetails.mockClear() + const canvas = within(canvasElement) + const locations = await canvas.findByRole("navigation", { + name: "Locations", + }) + await userEvent.click( + within(locations).getByRole("button", { name: /Paris · République/ }) + ) + await expect(handleLocationSelectWithoutDetails).toHaveBeenCalledWith( + "paris" + ) + await expect(canvas.queryByRole("complementary")).not.toBeInTheDocument() + await expect( + canvas.queryByRole("button", { name: /View activity for/ }) + ).not.toBeInTheDocument() + }, +} + +export const WithoutDensityLegend: Story = { + tags: ["no-sidebar"], + args: { items: [withoutLegendItem] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await canvas.findByRole("navigation", { name: "Locations" }) + await expect( + canvasElement.querySelector("[data-location-density-legend]") + ).not.toBeInTheDocument() + }, +} + +export const WithoutTimeline: Story = { + tags: ["no-sidebar"], + args: { items: [withoutTimelineItem] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await canvas.findByRole("navigation", { name: "Locations" }) + await expect( + canvasElement.querySelector("[data-location-timeline]") + ).not.toBeInTheDocument() + }, +} + +export const MapOnly: Story = { + tags: ["no-sidebar"], + args: { items: [mapOnlyItem] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await canvas.findByRole("navigation", { name: "Locations" }) + for (const selector of [ + "[data-location-summary]", + "[data-location-details]", + "[data-location-details-trigger]", + "[data-location-density-legend]", + "[data-location-timeline]", + ]) { + await expect( + canvasElement.querySelector(selector) + ).not.toBeInTheDocument() + } + }, +} + +/** Two real dashboard items share one equal-width row at a usable map width. */ +export const SideBySide: Story = { + tags: ["no-sidebar"], + args: { items: [defaultItems[0], companionChart] }, + render: (args) => ( + + ), + play: async ({ canvasElement }) => { + await waitFor(() => + expect( + canvasElement.querySelectorAll("[data-dashboard-row]") + ).toHaveLength(1) + ) + const cards = canvasElement.querySelectorAll("[data-card-id]") + await expect(cards).toHaveLength(2) + await expect( + Math.abs( + cards[0].getBoundingClientRect().width - + cards[1].getBoundingClientRect().width + ) + ).toBeLessThan(2) + await expect(cards[0].getBoundingClientRect().width).toBeGreaterThanOrEqual( + 720 + ) + + const locationCard = canvasElement.querySelector( + '[data-card-id="location-activity"]' + ) + if (!locationCard) throw new globalThis.Error("Expected the location item") + await within(locationCard).findByRole("complementary", { + name: "Barcelona · HQ", + }) + const summary = locationCard.querySelector( + "[data-location-summary]" + ) + const visualization = locationCard.querySelector( + "[data-location-visualization]" + ) + const openPanel = locationCard.querySelector( + "[data-location-details]" + ) + const legend = locationCard.querySelector( + "[data-location-density-legend]" + ) + const timeline = locationCard.querySelector( + "[data-location-timeline]" + ) + if (!summary || !visualization || !openPanel || !legend || !timeline) { + throw new globalThis.Error( + "Expected summary, visualization, legend, timeline, and open location details" + ) + } + const panelHeader = openPanel.firstElementChild + if (!(panelHeader instanceof HTMLElement)) { + throw new globalThis.Error("Expected the location details header") + } + const summaryBefore = summary.getBoundingClientRect() + const panelBefore = openPanel.getBoundingClientRect() + const panelHeaderBefore = panelHeader.getBoundingClientRect() + const openTitle = within(openPanel).getByText("Barcelona · HQ") + const openTitleRect = openTitle.getBoundingClientRect() + const openTitleStyle = getComputedStyle(openTitle) + await expect( + visualization.getBoundingClientRect().width + ).toBeGreaterThanOrEqual(720) + await expect(visualization.getBoundingClientRect().width).toBeLessThan(896) + await expect(Math.abs(summaryBefore.top - panelBefore.top)).toBeLessThan(2) + await expect(getComputedStyle(legend).maxWidth).toBe("calc(50% - 20px)") + await expect(legend.getBoundingClientRect().right).toBeLessThanOrEqual( + panelBefore.left + ) + await expect(legend.getBoundingClientRect().bottom).toBeLessThanOrEqual( + timeline.getBoundingClientRect().top + ) + await expect( + Math.abs(summaryBefore.height - panelHeaderBefore.height) + ).toBeLessThan(2) + await expect(summaryBefore.height).toBeGreaterThanOrEqual(63) + await expect(getComputedStyle(openPanel).boxShadow).toBe("none") + + await userEvent.click( + within(openPanel).getByRole("button", { + name: "Close location activity", + }) + ) + const trigger = within(locationCard).getByRole("button", { + name: "View activity for Barcelona · HQ", + }) + const triggerTitle = within(trigger).getByText("Barcelona · HQ") + const triggerTitleRect = triggerTitle.getBoundingClientRect() + const triggerRect = trigger.getBoundingClientRect() + const summaryAfter = summary.getBoundingClientRect() + await expect(Math.abs(triggerRect.top - panelBefore.top)).toBeLessThan(2) + await expect(Math.abs(triggerRect.width - panelBefore.width)).toBeLessThan( + 2 + ) + await expect( + Math.abs(triggerRect.height - panelHeaderBefore.height) + ).toBeLessThan(2) + await expect( + Math.abs(triggerTitleRect.top - openTitleRect.top) + ).toBeLessThan(2) + await expect(summaryAfter.height).toBe(summaryBefore.height) + await expect(getComputedStyle(triggerTitle).fontSize).toBe( + openTitleStyle.fontSize + ) + await expect(getComputedStyle(triggerTitle).lineHeight).toBe( + openTitleStyle.lineHeight + ) + }, +} + +/** The dashboard stacks the row before the map becomes too narrow to use. */ +export const MinimumUsableWidth: Story = { + tags: ["no-sidebar"], + args: { items: [defaultItems[0], companionChart] }, + render: (args) => ( + + ), + play: async ({ canvasElement }) => { + await waitFor(() => + expect( + canvasElement.querySelectorAll("[data-dashboard-row]") + ).toHaveLength(2) + ) + const rows = canvasElement.querySelectorAll( + "[data-dashboard-row]" + ) + await expect(rows[0].querySelectorAll("[data-card-id]")).toHaveLength(1) + await expect(rows[1].querySelectorAll("[data-card-id]")).toHaveLength(1) + }, +} + +/** At a narrow dashboard width, paired widgets stack into usable full rows. */ +export const PairedNarrow: Story = { + tags: ["no-sidebar"], + args: { items: [defaultItems[0], companionChart] }, + render: (args) => ( + + ), + play: async ({ canvasElement }) => { + await waitFor(() => + expect( + canvasElement.querySelectorAll("[data-dashboard-row]") + ).toHaveLength(2) + ) + for (const row of canvasElement.querySelectorAll("[data-dashboard-row]")) { + await expect(row.querySelectorAll("[data-card-id]")).toHaveLength(1) + } + }, +} + +/** Long location names stay bounded through the 480–719px layout range. */ +export const IntermediateWidth: Story = { + tags: ["no-sidebar"], + args: { + items: [ + locationItem(longLocationData, clockActivityConfig, { + id: "long-location-name", + }), + ], + }, + render: (args) => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const trigger = await canvas.findByRole("button", { + name: /View activity for Barcelona · Headquarters/, + }) + const summary = canvasElement.querySelector( + "[data-location-summary]" + ) + const timeline = canvasElement.querySelector( + "[data-location-timeline]" + ) + const visualization = canvasElement.querySelector( + "[data-location-visualization]" + ) + const legend = canvasElement.querySelector( + "[data-location-density-legend]" + ) + if (!summary || !timeline || !visualization || !legend) { + throw new globalThis.Error( + "Expected summary, visualization, legend, and timeline" + ) + } + const triggerBefore = trigger.getBoundingClientRect() + const legendBefore = legend.getBoundingClientRect() + await expect( + visualization.getBoundingClientRect().width + ).toBeGreaterThanOrEqual(480) + await expect(visualization.getBoundingClientRect().width).toBeLessThan(720) + await expect(triggerBefore.width).toBeGreaterThanOrEqual(319) + await expect(triggerBefore.width).toBeLessThanOrEqual(321) + await expect(triggerBefore.top).toBeGreaterThanOrEqual( + summary.getBoundingClientRect().bottom + ) + await expect(Math.abs(legendBefore.top - triggerBefore.top)).toBeLessThan(2) + await expect(legendBefore.right).toBeLessThanOrEqual(triggerBefore.left) + await userEvent.click(trigger) + const panel = await canvas.findByRole("complementary", { + name: "Barcelona · Headquarters and Innovation Campus", + }) + const legendAfter = legend.getBoundingClientRect() + await expect(Math.abs(legendAfter.left - legendBefore.left)).toBeLessThan(2) + await expect(Math.abs(legendAfter.top - legendBefore.top)).toBeLessThan(2) + await expect( + Math.abs(panel.getBoundingClientRect().top - triggerBefore.top) + ).toBeLessThan(2) + await expect(panel.getBoundingClientRect().width).toBe(triggerBefore.width) + await expect(legendAfter.right).toBeLessThanOrEqual( + panel.getBoundingClientRect().left + ) + await expect(panel.getBoundingClientRect().bottom).toBeLessThanOrEqual( + timeline.getBoundingClientRect().top + ) + }, +} + +/** Fullscreen, Ask One, drag handles, and layout callbacks stay dashboard-owned. */ +export const DesignerAndFullscreen: Story = { + tags: ["no-sidebar"], + args: { + items: [ + locationItem(clockActivityData, clockActivityConfig, { + minItemWidth: 560, + }), + companionChart, + ], + }, + render: (args) => ( + + ), + play: async ({ canvasElement, step }) => { + handleAskAi.mockClear() + handleLayoutChange.mockClear() + const page = within(canvasElement.closest("body")!) + const locationCard = canvasElement.querySelector( + '[data-card-id="location-activity"]' + ) + if (!locationCard) + throw new globalThis.Error("Expected the location dashboard item") + + await step("Expose dashboard designer controls", async () => { + const rows = canvasElement.querySelectorAll("[data-dashboard-row]") + await expect(rows).toHaveLength(1) + await expect(rows[0].querySelectorAll("[data-card-id]")).toHaveLength(2) + await expect( + canvasElement.querySelectorAll('[aria-label^="Drag to reorder"]') + ).toHaveLength(2) + await expect( + canvasElement.querySelector('button[role="separator"]') + ).toBeInTheDocument() + + const increase = canvasElement.querySelector( + "[data-dashboard-row-increase]" + ) + if (!increase) + throw new globalThis.Error("Expected the row increase control") + await userEvent.click(increase) + await expect(handleLayoutChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: "location-activity", + itemHeight: 724, + }), + ]) + ) + }) + + await step("Expand the item and ask One", async () => { + await userEvent.click( + within(locationCard).getByRole("button", { name: "Expand" }) + ) + const expanded = within(canvasElement) + await expect( + expanded.getByRole("button", { name: "Collapse" }) + ).toBeInTheDocument() + await userEvent.click( + expanded.getByRole("button", { name: "Other actions" }) + ) + const askOne = await page.findByRole("menuitem", { name: "Ask One" }) + await userEvent.click(askOne) + await expect(handleAskAi).toHaveBeenCalledWith({ + id: "location-activity", + title: "Clock activity by location", + }) + }) + }, +} + +/** A crowded location keeps the detail panel bounded and keyboard-scrollable. */ +export const CrowdedDetails: Story = { + tags: ["no-sidebar"], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const list = await canvas.findByRole("list", { name: "18 people" }) + await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight) + list.focus() + await expect(list).toHaveFocus() + list.scrollTop = list.scrollHeight + list.dispatchEvent(new Event("scroll", { bubbles: true })) + await waitFor(() => + expect(list.scrollTop + list.clientHeight).toBeGreaterThanOrEqual( + list.scrollHeight - 1 + ) + ) + await expect(within(list).getByText("Eric Soler")).toBeVisible() + list.blur() + await expect(list).not.toHaveFocus() + }, +} + +export const Narrow: Story = { + tags: ["no-sidebar"], + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const trigger = await canvas.findByRole("button", { + name: "View activity for Barcelona · HQ", + }) + const legend = canvasElement.querySelector( + "[data-location-density-legend]" + ) + const visualization = canvasElement.querySelector( + "[data-location-visualization]" + ) + if (!legend || !visualization) { + throw new globalThis.Error( + "Expected the visualization and density legend" + ) + } + await expect(visualization.getBoundingClientRect().width).toBeLessThan(480) + await expect(trigger).toBeVisible() + await expect(legend.getBoundingClientRect().top).toBeGreaterThanOrEqual( + trigger.getBoundingClientRect().bottom + ) + await userEvent.click(trigger) + await expect( + canvas.getByRole("complementary", { name: "Barcelona · HQ" }) + ).toBeVisible() + const panel = canvas.getByRole("complementary", { name: "Barcelona · HQ" }) + const timeline = canvasElement.querySelector( + "[data-location-timeline]" + ) + if (!timeline) throw new globalThis.Error("Expected the location timeline") + await expect(panel.getBoundingClientRect().bottom).toBeLessThanOrEqual( + timeline.getBoundingClientRect().top + ) + }, +} + +export const Empty: Story = { + tags: ["no-sidebar"], + args: { items: [emptyLocationItem] }, +} + +export const NoSelection: Story = { + tags: ["no-sidebar"], + args: { items: [noSelectionLocationItem] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const location = await canvas.findByRole("button", { + name: /Barcelona · HQ · Density: 39/, + }) + + await expect(canvas.queryByRole("complementary")).not.toBeInTheDocument() + await expect( + canvas.queryByRole("button", { + name: "View activity for Barcelona · HQ", + }) + ).not.toBeInTheDocument() + + await userEvent.click(location) + + await expect( + canvas.getByRole("complementary", { name: "Barcelona · HQ" }) + ).toBeInTheDocument() + }, +} + +export const Loading: Story = { + tags: ["no-sidebar"], + args: { items: [loadingLocationItem] }, +} + +export const Error: Story = { + tags: ["no-sidebar"], + args: { items: [errorLocationItem] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect( + await canvas.findByText("Location data unavailable") + ).toBeVisible() + await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible() + }, +} + +export const Dark: Story = { + tags: ["no-sidebar"], + render: (args) => ( +
+ +
+ ), +} + +export const MapUnavailable: Story = { + tags: ["no-sidebar"], + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await waitFor(() => + expect( + canvas.getByRole("button", { name: /Barcelona · HQ Density: 39/ }) + ).toBeVisible() + ) + await expect(canvas.getByText("24-hour activity")).toBeVisible() + }, +} + +export const Snapshot: Story = { + tags: ["no-sidebar"], + parameters: withSnapshot({}), + render: () => { + const clockItem = locationItem( + clockActivityData, + { + ...clockActivityConfig, + mapStyle: snapshotMapStyle, + }, + { + minItemWidth: 560, + } + ) + const inventoryItem = locationItem( + itInventoryData, + { + ...itInventoryConfig, + mapStyle: snapshotMapStyle, + }, + { + id: "snapshot-it", + title: "IT inventory by location", + description: "Today · Europe", + } + ) + const noSelectionItem = locationItem( + clockActivityData, + { + ...clockActivityConfig, + mapStyle: snapshotMapStyle, + }, + { + id: "snapshot-no-selection", + defaultSelectedLocationId: null, + } + ) + const customPaletteSnapshotItem = locationItem( + clockActivityData, + { + ...clockActivityConfig, + mapStyle: snapshotMapStyle, + densityPalette: customDensityItem.location.densityPalette, + }, + { id: "snapshot-custom-density" } + ) + const mapOnlySnapshotItem = locationItem( + clockActivityWithoutTimeline, + { + ...clockActivityConfig, + mapStyle: snapshotMapStyle, + sections: mapOnlyItem.location.sections, + }, + { id: "snapshot-map-only" } + ) + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+ ) + }, +} diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/index.mdx b/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/index.mdx index fdb092f09b..bfd5192a77 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/index.mdx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/index.mdx @@ -1,14 +1,15 @@ import { Canvas, Meta, Controls, Unstyled } from "@storybook/addon-docs/blocks" import * as Stories from "./index.stories" import * as AskOneStories from "./AskOne.stories" -import * as WidgetDropStories from "@/kits/ai/F0AiChat/__stories__/F0AiChatWidgetDrop.stories" +import * as LocationStories from "./LocationItem.stories" +import * as WidgetDropStories from "../../../kits/ai/F0AiChat/__stories__/F0AiChatWidgetDrop.stories" import { DoDonts } from "@/lib/storybook-utils/do-donts" # Analytics dashboard -F0AnalyticsDashboard presents related metrics, charts, and tables as one report with shared filters, optional export, and an editable grid. +F0AnalyticsDashboard presents related metrics, charts, tables, and location views as one report with shared filters, optional export, and an editable grid. ## Anatomy @@ -34,7 +35,8 @@ F0AnalyticsDashboard presents related metrics, charts, and tables as one report One report combines related visualizations - Metrics, charts, and tables must share context and filter values. + Metrics, charts, tables, and location views must share context and + filter values. @@ -106,6 +108,87 @@ F0AnalyticsDashboard presents related metrics, charts, and tables as one report }} /> +### Built-in location items + +Use a `location` item when geographic density, selected-location details, and a timeline answer one analytical question. The contract is generic: clock activity, IT inventory, and other domains supply different labels and data without forking the visualization. The dashboard owns filters, loading, errors, export, the standard header and menu, Ask One, drag and resize controls, and fullscreen. + + + +```tsx +const item: DashboardLocationItem = { + id: "clock-activity-by-location", + type: "location", + title: "Clock activity by location", + description: "Last 30 days · Europe", + location: clockActivityConfig, + fetchData: (filters) => fetchClockActivity(filters), +} + + +``` + +See the location item documentation for its complete generic data contract, IT inventory example, responsive width behavior, and accessibility guidance. + +### Exceptional custom items + +Use a `custom` item only when a visualization does not fit the built-in chart, metric, collection, or location renderers. The dashboard still owns the standard shell and layout while `renderContent` owns the body. Set `minItemHeight` for a real intrinsic floor and `useDashboardFilters=false` when the body must ignore report filters. + + + +```tsx +const items: DashboardItem[] = [ + { + id: "domain-visualization", + type: "custom", + title: "Domain visualization", + minItemHeight: 480, + renderContent: (appliedFilters) => , + }, +] + + +``` + +Custom item bodies are host-composed and are therefore omitted from the dashboard's automatic Excel export. Provide a domain export action from the host when that visualization needs one. + +### Layout and fullscreen + +Built-in metrics and charts greedily share rows up to the dashboard's four-slot +limit. A location item occupies two slots and may share one equal-width row +with one peer; the row stacks before its slot falls below + +minItemWidth (720px by default). Collections and custom items +reserve a full-width row by default. A custom body with explicit responsive +states may opt into the same two-item row contract with +allowRowSharing: true. Designer drag, one-click move controls, and +keyboard reorder controls preserve these constraints. + + + +Fullscreen temporarily renders only the selected item. Give the dashboard a +parent with a defined height—such as a flex child with min-height: 0 +inside a bounded canvas—because the expanded grid fills its parent. Collapse +restores the previous rows and peer widgets. + +The story below proves four widgets in one row, expand and collapse, and a +host-owned Ask One action while fullscreen remains active. + + + +- WHEN compatible built-in items fit within four slots → THEN they share one row. +- WHEN a location item and one peer each have at least minItemWidth → THEN they share one equal-width row. +- WHEN a location item's slot would be narrower than minItemWidth → THEN that row stacks without changing the persisted layout. +- WHEN a custom item omits allowRowSharing → THEN it occupies a full-width row and other items pack into subsequent rows. +- WHEN a custom item sets allowRowSharing: true → THEN it may share one equal-width row with one peer, and any third item moves to the next row. +- WHEN a shared custom item's slot would be narrower than minItemWidth → THEN that row stacks without changing the persisted layout. +- WHEN Expand is chosen in a multi-item dashboard → THEN only that item fills the bounded dashboard canvas. +- WHEN Collapse is chosen → THEN the previous multi-row layout is restored. +- WHEN onLayoutChange is provided → THEN drag, one-click, keyboard reorder, and resize changes emit the next persisted layout. + ### Widget information hints Use `info` for the definition of the figure itself. A string reveals a short @@ -120,7 +203,7 @@ so the new affordance can be compared with the unchanged header. -- WHEN an item provides `info` → THEN an information trigger appears beside its title on chart, metric, and collection widgets. +- WHEN an item provides `info` → THEN an information trigger appears beside its title on chart, metric, collection, location, and custom widgets. - WHEN `info` is omitted → THEN the header layout and interaction remain unchanged. - WHEN the widget is in an error state → THEN the information trigger remains available to explain what the failed widget was meant to measure. - WHEN the title is long → THEN it truncates around the fixed-size trigger instead of pushing the trigger out of the header. @@ -412,6 +495,18 @@ fallback without exposing selected values in the widget. Moves through data points in the open chart action menu. + + + ArrowLeft / ArrowRight + + Moves a focused designer reorder grip earlier or later. + + + + ArrowUp / ArrowDown on a row separator + + Decreases or increases that designer row's height. + diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/index.stories.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/index.stories.tsx index 6bd8f3c311..5cabc8e730 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/index.stories.tsx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/__stories__/index.stories.tsx @@ -8,9 +8,11 @@ import type { FiltersState } from "@/patterns/OneFilterPicker/types" import { withSnapshot } from "@/lib/storybook-utils/parameters" import type { + DashboardCustomItem, DashboardItem, DashboardItemFiltersConfig, DashboardItemFiltersState, + F0AnalyticsDashboardProps, } from "../types" import { F0AnalyticsDashboard } from "../index" @@ -36,6 +38,40 @@ const meta = { export default meta type Story = StoryObj +const handleLayoutStoryAskAi = fn() + +const layoutItems: DashboardItem[] = [ + { + id: "headcount", + type: "metric", + title: "Headcount", + description: "Last 30 days", + fetchData: async () => ({ value: 420 }), + }, + { + id: "turnover", + type: "metric", + title: "Turnover", + description: "Last 30 days", + fetchData: async () => ({ value: 18 }), + }, + { + id: "absence-rate", + type: "metric", + title: "Absence rate", + description: "Last 30 days", + format: { type: "percent" }, + fetchData: async () => ({ value: 4.2 }), + }, + { + id: "open-roles", + type: "metric", + title: "Open roles", + description: "Today", + fetchData: async () => ({ value: 27 }), + }, +] + const emptyReportFilters = {} satisfies FiltersState const preAppliedReportFilters = { @@ -181,6 +217,180 @@ export const MixedDashboard: Story = { render: () => , } +/** + * Four peer widgets share one row, while fullscreen temporarily gives one + * widget the complete bounded dashboard canvas. The play function also proves + * that a host-owned Ask One action remains available while expanded. + */ +export const LayoutAndFullscreen: Story = { + tags: ["no-sidebar"], + render: () => ( +
+ +
+ ), + play: async ({ canvasElement, step }) => { + handleLayoutStoryAskAi.mockClear() + const page = within(canvasElement.closest("body")!) + const firstItem = canvasElement.querySelector( + '[data-card-id="headcount"]' + ) + if (!firstItem) throw new Error("Expected the Headcount dashboard item") + + await step("Pack four peer widgets into one row", async () => { + const rows = canvasElement.querySelectorAll( + "[data-dashboard-row]" + ) + await expect(rows).toHaveLength(1) + await expect(rows[0].querySelectorAll("[data-card-id]")).toHaveLength(4) + }) + + await step("Expand one widget and hide its peers", async () => { + await userEvent.click( + within(firstItem).getByRole("button", { name: "Expand" }) + ) + await expect( + within(canvasElement).getByRole("button", { name: "Collapse" }) + ).toBeInTheDocument() + await expect(canvasElement).not.toHaveTextContent("Turnover") + }) + + await step( + "Use host-owned Ask One without leaving fullscreen", + async () => { + const expandedItem = within(canvasElement) + .getByRole("button", { name: "Collapse" }) + .closest('[class~="group/dashitem"]') + if (!expandedItem) { + throw new Error("Expected the expanded dashboard item") + } + await userEvent.click( + within(expandedItem).getByRole("button", { name: "Other actions" }) + ) + const askOne = await page.findByRole("menuitem", { name: "Ask One" }) + await userEvent.click(askOne) + await expect(handleLayoutStoryAskAi).toHaveBeenCalledWith({ + id: "headcount", + title: "Headcount", + }) + await expect( + canvasElement.querySelector('button[aria-label="Collapse"]') + ).toBeInTheDocument() + } + ) + + await step("Collapse and restore the shared row", async () => { + const collapse = canvasElement.querySelector( + 'button[aria-label="Collapse"]' + ) + if (!collapse) throw new Error("Expected the collapse control") + await userEvent.click(collapse) + await expect( + canvasElement.querySelectorAll("[data-card-id]") + ).toHaveLength(4) + }) + }, +} + +const customItem: DashboardCustomItem = { + id: "custom-domain-visualization", + type: "custom", + title: "Clock activity by location", + description: "Last 30 days · Europe", + info: "A domain visualization rendered inside the standard dashboard item shell.", + explanation: + "The host composes this body while the dashboard owns its title, description, menu, layout, and designer controls.", + itemHeight: 480, + renderContent: (filters) => ( +
+
+

Domain-owned content

+

+ Applied department: {filters.department?.join(", ") ?? "All"} +

+
+
+ {[42, 68, 52, 88, 64, 76].map((height, index) => ( +
+ ))} +
+
+ ), +} + +/** + * A host-composed visualization using the same item header, menu, filter, + * layout, fullscreen, and designer contracts as built-in dashboard items. + */ +export const CustomItemDashboard: StoryObj< + F0AnalyticsDashboardProps +> = { + args: { + filters: dashboardFilters, + defaultFilters: preAppliedReportFilters, + items: [customItem], + }, + render: (args) => , + play: async ({ canvasElement }) => { + await expect( + await within(canvasElement).findByRole("img", { + name: "Example custom visualization", + }) + ).toBeVisible() + }, +} + +const sharedRowItems: DashboardItem[] = [ + { + ...customItem, + id: "responsive-domain-visualization", + allowRowSharing: true, + minItemWidth: 360, + }, + { + id: "custom-item-peer", + type: "metric", + title: "Clock events", + description: "Last 30 days · Europe", + itemHeight: 480, + fetchData: async () => ({ value: 24_680 }), + }, +] + +/** + * A responsive custom item opts into a two-item row. The grid keeps both + * peers equal width and will move any third item to the next row. + */ +export const SharedRowCustomItem: Story = { + tags: ["no-sidebar"], + render: () => ( +
+ +
+ ), + play: async ({ canvasElement, step }) => { + await step("Share one dashboard row with one peer", async () => { + const rows = canvasElement.querySelectorAll( + "[data-dashboard-row]" + ) + await expect(rows).toHaveLength(1) + await expect(rows[0].querySelectorAll("[data-card-id]")).toHaveLength(2) + }) + }, +} + /** * Dashboard with the global Excel export button enabled. */ @@ -533,6 +743,16 @@ export const Snapshot: Story = { "employee-table": { country: ["ES", "FR"] }, }} /> + +
), play: async ({ canvasElement }) => { diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/DashboardGrid.test.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/DashboardGrid.test.tsx index 9c9158eab1..3041c907d3 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/DashboardGrid.test.tsx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/DashboardGrid.test.tsx @@ -1,6 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest" +import { ChartLine } from "@/icons/app" +import { AiChatStateProvider } from "@/kits/ai/F0AiChat/providers/AiChatStateProvider" +import { + WIDGET_DRAG_START, + type WidgetDragStartDetail, +} from "@/lib/dnd/widgetDragEvents" import { + act, fireEvent, screen, userEvent, @@ -9,16 +16,25 @@ import { zeroRender as render, } from "@/testing/test-utils" -import { AiChatStateProvider } from "@/kits/ai/F0AiChat/providers/AiChatStateProvider" -import { - WIDGET_DRAG_START, - type WidgetDragStartDetail, -} from "@/lib/dnd/widgetDragEvents" - -import type { DashboardItem } from "../types" +import type { DashboardItem, DashboardLocationItem } from "../types" import { DashboardGrid } from "../components/DashboardGrid/DashboardGrid" +vi.mock("@/patterns/F0Map", () => ({ + f0MapDensityColors: { low: "red", medium: "red", high: "red" }, + f0MapDensityColorSteps: { low: 10, medium: 50, high: 70 }, + f0MapDensityPalette: { + low: { color: "red", colorStep: 10 }, + medium: { color: "red", colorStep: 50 }, + high: { color: "red", colorStep: 70 }, + }, + f0MapStyles: { + light: { version: 8, sources: {}, layers: [] }, + dark: { version: 8, sources: {}, layers: [] }, + }, + F0Map: () =>
Map
, +})) + type ExpenseRecord = { employee: string category: string @@ -83,6 +99,43 @@ function makeCollectionItems(itemHeight: number): DashboardItem[] { ] } +function makeLocationItem( + overrides: Partial = {} +): DashboardLocationItem { + return { + id: "locations", + type: "location", + title: "Activity by location", + location: { + summaryMetrics: [ + { id: "one", label: "One", icon: ChartLine }, + { id: "two", label: "Two", icon: ChartLine }, + { id: "three", label: "Three", icon: ChartLine }, + ], + densityLabel: "Density", + densityLowLabel: () => "Low", + densityMediumLabel: () => "Medium", + densityHighLabel: () => "High", + timelineTitle: "Timeline", + timelineAriaLabel: "Timeline data", + mapAriaLabel: "Locations", + selectLocationLabel: "Select a location", + viewLocationDetailsLabel: (name) => `View ${name}`, + closeLocationDetailsLabel: "Close details", + noDataLabel: "No data", + exportLabels: { + location: "Location", + density: "Density", + details: "Details", + item: "Item", + description: "Description", + }, + }, + fetchData: () => new Promise(() => {}), + ...overrides, + } +} + function getDashboardRowHeight(container: HTMLElement): string { const card = container.querySelector('[data-card-id="headcount"]') if (!(card instanceof HTMLElement)) { @@ -102,6 +155,317 @@ describe("DashboardGrid", () => { vi.restoreAllMocks() }) + it("treats a location item as a built-in two-slot dashboard item", () => { + const items: DashboardItem[] = [ + makeLocationItem(), + { + id: "comparison", + type: "chart", + title: "Comparison", + chart: { type: "bar" }, + fetchData: async () => ({ categories: [], series: [] }), + }, + { + id: "headcount", + type: "metric", + title: "Headcount", + fetchData: async () => ({ value: 42 }), + }, + ] + + const { container } = render() + const rows = container.querySelectorAll("[data-dashboard-row]") + + expect(rows).toHaveLength(2) + expect(rows[0]).toHaveStyle({ height: "700px" }) + expect(rows[0].querySelectorAll("[data-card-id]")).toHaveLength(2) + expect( + rows[0].querySelector('[data-card-id="locations"]') + ).toBeInTheDocument() + expect( + rows[0].querySelector('[data-card-id="comparison"]') + ).toBeInTheDocument() + expect( + rows[1].querySelector('[data-card-id="headcount"]') + ).toBeInTheDocument() + }) + + it("gives custom items a full-width dashboard row and designer controls", () => { + const items: DashboardItem[] = [ + { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + renderContent: () =>
Map content
, + }, + { + id: "headcount", + type: "metric", + title: "Headcount", + fetchData: async () => ({ value: 42 }), + }, + ] + + const { container } = render( + + ) + const rows = container.querySelectorAll("[data-dashboard-row]") + + expect(rows).toHaveLength(2) + expect(rows[0]).toHaveStyle({ height: "700px" }) + expect( + rows[0].querySelector('[data-card-id="clock-activity"]') + ).toHaveTextContent("Map content") + expect( + container.querySelectorAll('[aria-label="Drag to reorder"]') + ).toHaveLength(2) + }) + + it("lets a responsive custom item share a row with exactly one peer", () => { + const items: DashboardItem[] = [ + { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + allowRowSharing: true, + renderContent: () =>
Map content
, + }, + { + id: "clock-events", + type: "chart", + title: "Clock events by workplace", + chart: { type: "bar" }, + fetchData: async () => ({ categories: [], series: [] }), + }, + { + id: "headcount", + type: "metric", + title: "Headcount", + fetchData: async () => ({ value: 42 }), + }, + ] + + const { container } = render() + const rows = container.querySelectorAll("[data-dashboard-row]") + + expect(rows).toHaveLength(2) + expect(rows[0].querySelectorAll("[data-card-id]")).toHaveLength(2) + expect( + rows[0].querySelector('[data-card-id="clock-activity"]') + ).toBeInTheDocument() + expect( + rows[0].querySelector('[data-card-id="clock-events"]') + ).toBeInTheDocument() + expect( + rows[1].querySelector('[data-card-id="headcount"]') + ).toBeInTheDocument() + }) + + it("stacks a shared custom row before its item width becomes unusable", async () => { + const originalResizeObserver = Object.getOwnPropertyDescriptor( + globalThis, + "ResizeObserver" + ) + const observations: Array<{ + callback: ResizeObserverCallback + observer: ResizeObserver + target?: Element + }> = [] + + class TestResizeObserver { + private readonly observation: (typeof observations)[number] + + constructor(callback: ResizeObserverCallback) { + this.observation = { + callback, + observer: this as unknown as ResizeObserver, + } + observations.push(this.observation) + } + + observe = (target: Element) => { + this.observation.target = target + } + unobserve = () => {} + disconnect = () => {} + } + + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: TestResizeObserver, + }) + + try { + const items: DashboardItem[] = [ + { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + allowRowSharing: true, + minItemWidth: 720, + renderContent: () =>
Map content
, + }, + { + id: "headcount", + type: "metric", + title: "Headcount", + fetchData: async () => ({ value: 42 }), + }, + ] + const { container } = render( + + ) + const gridObservation = observations.find((observation) => + observation.target?.querySelector("[data-dashboard-row]") + ) + if (!gridObservation?.target) { + throw new Error("Expected the dashboard resize observation") + } + const notifyWidth = (width: number) => { + act(() => { + gridObservation.callback( + [ + { + target: gridObservation.target as Element, + contentRect: { width }, + } as ResizeObserverEntry, + ], + gridObservation.observer + ) + }) + } + + notifyWidth(1200) + await waitFor(() => + expect( + container.querySelectorAll("[data-dashboard-row]") + ).toHaveLength(2) + ) + expect( + container.querySelectorAll('[aria-label="Drag to reorder"]') + ).toHaveLength(0) + + notifyWidth(1600) + await waitFor(() => + expect( + container.querySelectorAll("[data-dashboard-row]") + ).toHaveLength(1) + ) + expect( + container.querySelectorAll('[aria-label="Drag to reorder"]') + ).toHaveLength(2) + } finally { + if (originalResizeObserver) { + Object.defineProperty( + globalThis, + "ResizeObserver", + originalResizeObserver + ) + } else { + Reflect.deleteProperty(globalThis, "ResizeObserver") + } + } + }) + + it("expands one widget into the bounded grid and restores its peers", async () => { + const { container } = render( +
+ +
+ ) + const expand = container.querySelector( + '[data-card-id="headcount"] button[aria-label="Expand"]' + ) + if (!expand) throw new Error("Expected the fullscreen control") + + expand.focus() + fireEvent.click(expand) + + await waitFor(() => { + expect(document.activeElement).toHaveAttribute("aria-label", "Collapse") + expect( + container.querySelector('[data-card-id="turnover"]') + ).not.toBeInTheDocument() + }) + const collapse = container.querySelector( + 'button[aria-label="Collapse"]' + ) + if (!collapse) throw new Error("Expected the collapse control") + + fireEvent.click(collapse) + + await waitFor(() => { + expect(document.activeElement).toHaveAttribute("aria-label", "Expand") + expect(container.querySelectorAll("[data-card-id]")).toHaveLength(2) + expect( + container.querySelector('[data-card-id="turnover"]') + ).toBeInTheDocument() + }) + }) + + it("repairs saved layouts that place another item beside a full-row custom item", () => { + const items: DashboardItem[] = [ + { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + x: 0, + y: 0, + itemHeight: 480, + minItemHeight: 624, + renderContent: () =>
Map content
, + }, + { + id: "headcount", + type: "metric", + title: "Headcount", + x: 6, + y: 0, + fetchData: async () => ({ value: 42 }), + }, + ] + + const { container } = render() + const rows = container.querySelectorAll("[data-dashboard-row]") + + expect(rows).toHaveLength(2) + expect(rows[0]).toHaveStyle({ height: "624px" }) + expect(rows[0].querySelectorAll("[data-card-id]")).toHaveLength(1) + expect(rows[1].querySelectorAll("[data-card-id]")).toHaveLength(1) + }) + + it("restores a saved paired row for a responsive custom item", () => { + const items: DashboardItem[] = [ + { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + allowRowSharing: true, + x: 0, + y: 0, + itemHeight: 700, + renderContent: () =>
Map content
, + }, + { + id: "clock-events", + type: "chart", + title: "Clock events by workplace", + x: 6, + y: 0, + itemHeight: 700, + chart: { type: "bar" }, + fetchData: async () => ({ categories: [], series: [] }), + }, + ] + + const { container } = render() + const rows = container.querySelectorAll("[data-dashboard-row]") + + expect(rows).toHaveLength(1) + expect(rows[0].querySelectorAll("[data-card-id]")).toHaveLength(2) + expect(rows[0]).toHaveStyle({ height: "700px" }) + }) + it("recomputes row height when itemHeight changes for existing items", async () => { const { container, rerender } = render( @@ -224,6 +588,61 @@ describe("DashboardGrid", () => { expect(getDashboardRowHeight(container)).toBe("150px") }) + it("resizes a row with the keyboard and emits the layout", () => { + const onLayoutChange = vi.fn() + const { container } = render( + + ) + + const handle = getResizeHandle(container) + expect(handle).toHaveAttribute("role", "separator") + fireEvent.keyDown(handle, { key: "ArrowDown" }) + + expect(getDashboardRowHeight(container)).toBe("224px") + expect(onLayoutChange).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: "headcount", itemHeight: 224 }), + ]) + ) + }) + + it("resizes a row with one-click decrease and increase controls", () => { + const { container } = render( + + ) + const decrease = container.querySelector("[data-dashboard-row-decrease]") + const increase = container.querySelector("[data-dashboard-row-increase]") + if ( + !(decrease instanceof HTMLButtonElement) || + !(increase instanceof HTMLButtonElement) + ) { + throw new Error("Expected click resize controls") + } + + fireEvent.click(decrease) + expect(getDashboardRowHeight(container)).toBe("176px") + + fireEvent.click(increase) + expect(getDashboardRowHeight(container)).toBe("200px") + }) + + it("does not shrink a restored row that is already above the resize cap", () => { + const { container } = render( + + ) + const handle = getResizeHandle(container) + + fireEvent.keyDown(handle, { key: "ArrowDown" }) + + expect(getDashboardRowHeight(container)).toBe("2000px") + expect(handle).toHaveAttribute("aria-valuemax", "2000") + }) + it("shrinks a row back after growing it", () => { const { container } = render( @@ -247,6 +666,33 @@ describe("DashboardGrid", () => { expect(getDashboardRowHeight(container)).toBe("120px") }) + it("clamps a custom row to its item-specific minimum height", () => { + const items: DashboardItem[] = [ + { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + itemHeight: 700, + minItemHeight: 624, + renderContent: () =>
Map content
, + }, + { + id: "headcount", + type: "metric", + title: "Headcount", + fetchData: async () => ({ value: 42 }), + }, + ] + const { container } = render( + + ) + + dragResizeHandle(getResizeHandle(container), -500) + + const row = container.querySelector("[data-dashboard-row]") + expect(row).toHaveStyle({ height: "624px" }) + }) + it("clamps shrinking to overflowing content height", () => { vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation( function getScrollHeight(this: HTMLElement) { @@ -465,6 +911,118 @@ describe("DashboardGrid", () => { expect(rowOrder(container)).toEqual(["category-totals", "expenses"]) }) + it("reorders a widget with arrow keys, preserves focus, and emits the layout", async () => { + const onLayoutChange = vi.fn() + const { container } = render( + + ) + const grip = container.querySelector('[aria-label="Drag to reorder"]') + if (!(grip instanceof HTMLButtonElement)) { + throw new Error("Expected an operable reorder button") + } + + fireEvent.keyDown(grip, { key: "ArrowDown" }) + + expect(rowOrder(container)).toEqual(["category-totals", "expenses"]) + expect(onLayoutChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: "expenses", y: 10 }), + ]) + ) + await waitFor(() => + expect(document.activeElement).toHaveAttribute( + "data-reorder-id", + "expenses" + ) + ) + expect(container).toHaveTextContent("Move down: Expenses.") + }) + + it("reorders with one-click controls and preserves full-row packing", () => { + const items: DashboardItem[] = [ + { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + renderContent: () =>
Map content
, + }, + ...makeMetricItems(144), + ] + const { container } = render( + + ) + const moveLater = container.querySelector( + '[aria-label="Move down: Clock activity by location"]' + ) + if (!(moveLater instanceof HTMLButtonElement)) { + throw new Error("Expected a click reorder control") + } + + fireEvent.click(moveLater) + + expect(rowOrder(container)).toEqual([ + "headcount", + "clock-activity", + "turnover", + ]) + expect(rowOrder(container).every((row) => !row.includes("+"))).toBe(true) + }) + + it("keeps a full-row custom item isolated during pointer drag", () => { + const items: DashboardItem[] = [ + { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + renderContent: () =>
Map content
, + }, + { + id: "headcount", + type: "metric", + title: "Headcount", + fetchData: async () => ({ value: 42 }), + }, + ] + const { container } = render( + + ) + const rows = container.querySelectorAll( + "[data-dashboard-row]" + ) + rows[0].getBoundingClientRect = () => + ({ top: 0, bottom: 300, height: 300 }) as DOMRect + rows[1].getBoundingClientRect = () => + ({ top: 312, bottom: 456, height: 144 }) as DOMRect + const grips = container.querySelectorAll( + '[aria-label="Drag to reorder"]' + ) + + fireEvent.pointerDown(grips[1], { button: 0 }) + fireEvent( + document, + new MouseEvent("pointermove", { + clientX: 500, + clientY: 150, + bubbles: true, + }) + ) + fireEvent( + document, + new MouseEvent("pointerup", { + clientX: 500, + clientY: 150, + bubbles: true, + }) + ) + + expect(rowOrder(container)).toEqual(["clock-activity", "headcount"]) + }) + it("does not reorder when the gesture ends over the AI chat drop zone", () => { const { container } = render( diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/F0AnalyticsDashboard.test.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/F0AnalyticsDashboard.test.tsx index 93b99d2328..2fdea055ea 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/F0AnalyticsDashboard.test.tsx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/F0AnalyticsDashboard.test.tsx @@ -1,28 +1,48 @@ import { userEvent } from "@testing-library/user-event" import { describe, expect, it, vi } from "vitest" -import { - screen, - waitFor, - within, - zeroRender as render, -} from "@/testing/test-utils" import type { FiltersDefinition, FiltersState, } from "@/patterns/OneFilterPicker/types" + import { AiChatStateProvider, useAiChat, } from "@/kits/ai/F0AiChat/providers/AiChatStateProvider" +import { + screen, + waitFor, + within, + zeroRender as render, +} from "@/testing/test-utils" -import { F0AnalyticsDashboard } from "../F0AnalyticsDashboard" import type { DashboardChartItem, + DashboardCustomItem, DashboardItem, DashboardMetricItem, } from "../types" +import { F0AnalyticsDashboard } from "../F0AnalyticsDashboard" + +// Keep this dashboard integration test at the map boundary: jsdom has no +// browser Worker, while location behavior has its own focused test suite. +vi.mock("@/patterns/F0Map", () => ({ + f0MapDensityColors: { low: "red", medium: "red", high: "red" }, + f0MapDensityColorSteps: { low: 10, medium: 50, high: 70 }, + f0MapDensityPalette: { + low: { color: "red", colorStep: 10 }, + medium: { color: "red", colorStep: 50 }, + high: { color: "red", colorStep: 70 }, + }, + f0MapStyles: { + light: { version: 8, sources: {}, layers: [] }, + dark: { version: 8, sources: {}, layers: [] }, + }, + F0Map: () =>
, +})) + // Keep this dashboard integration test at the chart boundary: jsdom has no // canvas context, while ChartItem's keyboard point surface is ordinary DOM. vi.mock("@/kits/F0DataChart", async (importOriginal) => { @@ -248,7 +268,7 @@ describe("F0AnalyticsDashboard report filters", () => { }) describe("F0AnalyticsDashboard item filters", () => { - it("resolves metric controls without enabling fullscreen and preserves single-item and undefined opt-outs", () => { + it("keeps metric filters and fullscreen independent while preserving single-item and undefined opt-outs", () => { const headcount = metricItem(vi.fn().mockResolvedValue({ value: 42 })) const turnover: DashboardMetricItem = { ...metricItem(vi.fn().mockResolvedValue({ value: 7 })), @@ -287,11 +307,11 @@ describe("F0AnalyticsDashboard item filters", () => { within(turnoverCard).queryByRole("button", { name: "Filters" }) ).toBeNull() expect( - within(headcountCard).queryByRole("button", { name: "Expand" }) - ).toBeNull() + within(headcountCard).getByRole("button", { name: "Expand" }) + ).toBeVisible() expect( - within(turnoverCard).queryByRole("button", { name: "Expand" }) - ).toBeNull() + within(turnoverCard).getByRole("button", { name: "Expand" }) + ).toBeVisible() view.rerender( @@ -303,6 +323,7 @@ describe("F0AnalyticsDashboard item filters", () => { .closest("[class*='dashitem']") as HTMLElement ).getByRole("button", { name: "Filters" }) ).toBeVisible() + expect(screen.queryByRole("button", { name: "Expand" })).toBeNull() expect(resolver).toHaveBeenCalledWith( expect.objectContaining({ id: "headcount" }) ) @@ -441,6 +462,114 @@ describe("F0AnalyticsDashboard item filters", () => { }) }) +describe("F0AnalyticsDashboard custom items", () => { + it("renders host content in the standard item shell with applied filters", async () => { + const user = userEvent.setup() + const renderContent = vi.fn((currentFilters: DashboardFilters) => ( +
Mapped locations: {currentFilters.department?.join(", ")}
+ )) + const item: DashboardCustomItem = { + id: "clock-activity", + type: "custom", + title: "Clock activity by location", + description: "Last 30 days · Europe", + explanation: "Clock events grouped by workplace.", + renderContent, + } + + render( + + ) + + expect( + screen.getByRole("heading", { name: "Clock activity by location" }) + ).toBeVisible() + expect(screen.getByText("Last 30 days · Europe")).toBeVisible() + expect(screen.getByText("Mapped locations: engineering")).toBeVisible() + expect(renderContent).toHaveBeenLastCalledWith({ + department: ["engineering"], + }) + + await user.click(screen.getByRole("button", { name: "Other actions" })) + expect( + screen.getByRole("menuitem", { + name: "Where does this data come from?", + }) + ).toBeVisible() + }) + + it("withholds dashboard filters from an opted-out custom item", () => { + const renderContent = vi.fn(() =>
Custom content
) + + render( + + ) + + expect(renderContent).toHaveBeenLastCalledWith({}) + }) + + it("updates custom content when controlled dashboard filters change", () => { + const filteredContent = vi.fn(() =>
Filtered content
) + const unfilteredContent = vi.fn(() =>
Global content
) + const items: DashboardCustomItem[] = [ + { + id: "filtered-custom", + type: "custom", + title: "Filtered activity", + renderContent: filteredContent, + }, + { + id: "unfiltered-custom", + type: "custom", + title: "Global activity", + useDashboardFilters: false, + renderContent: unfilteredContent, + }, + ] + const { rerender } = render( + + ) + + rerender( + + ) + + expect(filteredContent).toHaveBeenLastCalledWith({ + department: ["product"], + }) + expect(unfilteredContent.mock.calls).not.toHaveLength(0) + expect( + unfilteredContent.mock.calls.every( + ([value]) => Object.keys(value).length === 0 + ) + ).toBe(true) + }) +}) + describe("F0AnalyticsDashboard Ask One", () => { it("passes the public host handler through to a rendered widget", async () => { const user = userEvent.setup() diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/LocationItem.test.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/LocationItem.test.tsx new file mode 100644 index 0000000000..cd6a0d4ccb --- /dev/null +++ b/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/LocationItem.test.tsx @@ -0,0 +1,922 @@ +import { baseColors } from "@factorialco/f0-core" +import { userEvent } from "@testing-library/user-event" +import { describe, expect, it, vi } from "vitest" + +import { ChartLine, ClockBack, Computer } from "@/icons/app" +import { ClockIn } from "@/icons/modules" +import { + act, + zeroRender as render, + screen, + waitFor, +} from "@/testing/test-utils" + +import type { + DashboardLocationConfig, + DashboardLocationData, + DashboardLocationItem, +} from "../types" + +import { LocationVisualization } from "../components/LocationItem/LocationVisualization" +import { F0AnalyticsDashboard } from "../F0AnalyticsDashboard" + +vi.mock("@/patterns/F0Map", () => ({ + f0MapDensityColors: { low: "red", medium: "red", high: "red" }, + f0MapDensityColorSteps: { low: 10, medium: 50, high: 70 }, + f0MapDensityPalette: { + low: { color: "red", colorStep: 10 }, + medium: { color: "red", colorStep: 50 }, + high: { color: "red", colorStep: 70 }, + }, + f0MapStyles: { + light: { version: 8, sources: {}, layers: [] }, + dark: { version: 8, sources: {}, layers: [] }, + }, + resolveF0MapDensityStyle: (style: { color: string; colorStep: number }) => + style.color === "malibu" && style.colorStep === 60 + ? { ...style, colorStep: 70 } + : style, + f0MapDensitySurfaceStyle: (style: { + color: keyof typeof baseColors + colorStep: 10 | 50 | 60 | 70 + }) => + style.colorStep === 10 + ? { + backgroundColor: "hsl(var(--neutral-0))", + boxShadow: `inset 0 0 0 999px hsl(${baseColors[style.color][50]} / 0.1)`, + } + : { backgroundColor: `hsl(${baseColors[style.color][style.colorStep]})` }, + F0Map: ({ + markers, + onMarkerSelect, + onFallbackChange, + ariaLabel, + selectedMarkerId, + }: { + markers: Array<{ + id: string + ariaLabel?: string + level?: string + style?: { color: string; colorStep: number } + }> + onMarkerSelect?: (id: string) => void + onFallbackChange?: (visible: boolean) => void + ariaLabel?: string + selectedMarkerId?: string | null + }) => ( + + ), +})) + +vi.mock("@/kits/F0DataChart", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + F0DataChart: ({ + categories, + series, + }: { + categories: string[] + series: Array<{ name: string }> + }) => ( +
+ {categories.length} categories ·{" "} + {series.map((item) => item.name).join(", ")} +
+ ), + } +}) + +const config: DashboardLocationConfig = { + summaryMetrics: [ + { id: "in", label: "Clock ins", icon: ClockIn, tone: "positive" }, + { id: "out", label: "Clock outs", icon: ClockBack, tone: "critical" }, + { id: "peak", label: "Density", icon: ChartLine, tone: "selected" }, + ], + densityLabel: "Density", + densityLowLabel: (below) => `1–${below - 1}`, + densityMediumLabel: (from, below) => `${from}–${below - 1}`, + densityHighLabel: (from) => `${from}+`, + timelineTitle: "24-hour activity", + timelineAriaLabel: "Activity by hour", + mapAriaLabel: "Activity by location", + selectLocationLabel: "Select a location", + viewLocationDetailsLabel: (name) => `View activity for ${name}`, + closeLocationDetailsLabel: "Close location activity", + noDataLabel: "No activity", + exportLabels: { + location: "Location", + density: "Density", + details: "Details", + item: "Employee", + description: "Workplace", + }, +} + +const data: DashboardLocationData = { + summary: { in: 120, out: 98, peak: "Peak 09:00" }, + locations: [ + { + id: "barcelona", + name: "Barcelona · HQ", + coordinates: [2.17, 41.38], + density: 39, + detailsLabel: "1 person", + details: [ + { + id: "alex", + title: "Alex Rivera", + description: "Workplace", + avatar: { type: "person", firstName: "Alex", lastName: "Rivera" }, + values: [ + { label: "Clock in", value: "09:02", icon: ClockIn }, + { label: "Clock out", value: "18:07", icon: ClockBack }, + ], + }, + ], + }, + { + id: "paris", + name: "Paris · République", + coordinates: [2.35, 48.85], + density: 18, + detailsLabel: "1 person", + details: [ + { + id: "lea", + title: "Léa Bernard", + avatar: { type: "person", firstName: "Léa", lastName: "Bernard" }, + values: [{ label: "Clock in", value: "09:01" }], + }, + ], + }, + ], + timeline: { + categories: ["00:00", "12:00", "24:00"], + series: [ + { name: "Clock ins", data: [0, 12, 0], color: "viridian" }, + { name: "Clock outs", data: [0, 8, 0], color: "red", dashed: true }, + ], + }, +} + +const makeItem = ( + overrides: Partial = {} +): DashboardLocationItem => ({ + id: "location", + type: "location", + title: "Activity by location", + description: "Last 30 days · Europe", + location: config, + fetchData: async () => data, + ...overrides, +}) + +describe("F0AnalyticsDashboard location item", () => { + it("renders generic summary, location details, and timeline data", async () => { + render() + + expect(await screen.findByText("Activity by location")).toBeInTheDocument() + expect(screen.getByText("120")).toBeInTheDocument() + expect(screen.getByText("Peak 09:00")).toBeInTheDocument() + expect(screen.getByText("Alex Rivera")).toBeInTheDocument() + expect(screen.getByText("24-hour activity")).toBeInTheDocument() + expect(screen.getByTestId("location-timeline-chart")).toHaveTextContent( + "Clock ins, Clock outs" + ) + }) + + it("changes the selected location through the map's operable list", async () => { + const onLocationSelect = vi.fn() + render() + + await userEvent.click( + await screen.findByRole("button", { + name: /Paris · République · Density: 18/, + }) + ) + + expect(onLocationSelect).toHaveBeenCalledWith("paris") + expect(screen.getByText("Léa Bernard")).toBeInTheDocument() + }) + + it("supports controlled and default location selection", async () => { + const onLocationSelect = vi.fn() + const controlled = render( + + ) + + expect(await screen.findByText("Léa Bernard")).toBeInTheDocument() + await userEvent.click( + screen.getByRole("button", { name: /Barcelona · HQ · Density: 39/ }) + ) + expect(onLocationSelect).toHaveBeenCalledWith("barcelona") + expect(screen.getByText("Léa Bernard")).toBeInTheDocument() + controlled.unmount() + + render( + + ) + expect(await screen.findByText("Léa Bernard")).toBeInTheDocument() + }) + + it("does not expose the details disclosure until a location is selected", async () => { + render( + + ) + + const location = await screen.findByRole("button", { + name: /Barcelona · HQ · Density: 39/, + }) + expect( + screen.queryByRole("button", { + name: "View activity for Barcelona · HQ", + }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("complementary")).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Select a location" }) + ).not.toBeInTheDocument() + + await userEvent.click(location) + + expect( + screen.getByRole("complementary", { name: "Barcelona · HQ" }) + ).toBeInTheDocument() + }) + + it("clears an uncontrolled selection when populated data becomes empty", async () => { + const onLocationSelect = vi.fn() + const { rerender } = render( + + ) + expect(await screen.findByText("Alex Rivera")).toBeInTheDocument() + + rerender( + + ) + + expect(await screen.findByText("No activity")).toBeInTheDocument() + expect(onLocationSelect).toHaveBeenCalledWith(null) + }) + + it("normalizes density policy and uses host formatters", async () => { + const { rerender } = render( + `D${value}`, + formatSummaryValue: (value) => `S${value}`, + }, + }), + ]} + /> + ) + + expect(await screen.findByText("S120")).toBeInTheDocument() + expect( + screen.getByRole("button", { + name: /Barcelona · HQ · Density: D39/, + }) + ).toHaveAttribute("data-density-level", "high") + expect(screen.getByText("1–5")).toBeInTheDocument() + + rerender( + + ) + expect( + await screen.findByRole("button", { + name: /Barcelona · HQ · Density: 39/, + }) + ).toHaveAttribute("data-density-level", "low") + }) + + it("uses one partially overridden F0 palette for markers and the legend", async () => { + const paletteData: DashboardLocationData = { + ...data, + locations: [ + { ...data.locations[0], density: 3 }, + { ...data.locations[1], density: 10 }, + { + ...data.locations[1], + id: "paris-high", + name: "Paris · Bastille", + density: 30, + }, + ], + } + const { container } = render( + + ) + + expect( + await screen.findByRole("button", { name: /Barcelona · HQ · Density: 3/ }) + ).toHaveAttribute("data-density-color", "red") + expect( + screen.getByRole("button", { name: /Paris · République · Density: 10/ }) + ).toHaveAttribute("data-density-color", "malibu") + expect( + screen.getByRole("button", { name: /Paris · République · Density: 10/ }) + ).toHaveAttribute("data-density-color-step", "70") + expect( + screen.getByRole("button", { name: /Paris · Bastille · Density: 30/ }) + ).toHaveAttribute("data-density-color-step", "70") + + const legend = container.querySelector("[data-location-density-legend]") + expect(legend?.querySelector('[data-density-level="low"]')).toHaveStyle({ + backgroundColor: "hsl(var(--neutral-0))", + }) + expect(legend?.querySelector('[data-density-level="medium"]')).toHaveStyle({ + backgroundColor: "hsl(216 48% 44%)", + }) + expect(legend?.querySelector('[data-density-level="high"]')).toHaveStyle({ + backgroundColor: "hsl(3 71% 41%)", + }) + }) + + it("independently hides summary, legend, and an absent timeline", async () => { + const { container } = render( + + ) + + await screen.findByRole("navigation", { name: "Locations" }) + expect(container.querySelector("[data-location-summary]")).toBeNull() + expect(container.querySelector("[data-location-density-legend]")).toBeNull() + expect(container.querySelector("[data-location-timeline]")).toBeNull() + expect(screen.queryByTestId("location-timeline-chart")).toBeNull() + }) + + it("hides an existing timeline when its section is disabled", async () => { + const { container } = render( + + ) + + await screen.findByRole("button", { + name: /Barcelona · HQ · Density: 39/, + }) + expect(container.querySelector("[data-location-timeline]")).toBeNull() + }) + + it("keeps location selection operable when details are disabled", async () => { + const onLocationSelect = vi.fn() + render( + + ) + + await userEvent.click( + await screen.findByRole("button", { + name: /Paris · République · Density: 18/, + }) + ) + expect(onLocationSelect).toHaveBeenCalledWith("paris") + expect(screen.queryByRole("complementary")).toBeNull() + expect( + screen.queryByRole("button", { name: /View activity for/ }) + ).toBeNull() + }) + + it("renders a map-only configuration in both map and fallback modes", async () => { + const { container } = render( + + ) + + await userEvent.click( + await screen.findByRole("button", { name: "Use map fallback" }) + ) + expect( + screen.getByRole("region", { name: "Activity by location" }) + ).toBeInTheDocument() + for (const selector of [ + "[data-location-summary]", + "[data-location-details]", + "[data-location-details-trigger]", + "[data-location-density-legend]", + "[data-location-timeline]", + ]) { + expect(container.querySelector(selector)).toBeNull() + } + }) + + it("restores focus before disabling location details at runtime", async () => { + const { rerender } = render( + + ) + await userEvent.click( + screen.getByRole("button", { + name: "View activity for Barcelona · HQ", + }) + ) + const close = await screen.findByRole("button", { + name: "Close location activity", + }) + close.focus() + + rerender( + + ) + + const selectedLocation = screen.getByRole("button", { + name: /Barcelona · HQ · Density: 39/, + }) + await waitFor(() => expect(selectedLocation).toHaveFocus()) + expect(screen.queryByRole("complementary")).toBeNull() + }) + + it("restores focus before disabling a focused timeline at runtime", async () => { + const { container, rerender } = render( + + ) + const legend = await screen.findByRole("list", { + name: "Clock ins, Clock outs", + }) + legend.tabIndex = 0 + legend.focus() + + rerender( + + ) + + const selectedLocation = screen.getByRole("button", { + name: /Barcelona · HQ · Density: 39/, + }) + await waitFor(() => expect(selectedLocation).toHaveFocus()) + expect(container.querySelector("[data-location-timeline]")).toBeNull() + }) + + it("restores timeline focus to the widget when no location is selected", async () => { + const { container, rerender } = render( + + ) + const legend = await screen.findByRole("list", { + name: "Clock ins, Clock outs", + }) + legend.tabIndex = 0 + legend.focus() + + rerender( + + ) + + const widget = container.querySelector( + "[data-location-visualization]" + ) + await waitFor(() => expect(widget).toHaveFocus()) + expect(container.querySelector("[data-location-timeline]")).toBeNull() + }) + + it("exposes timeline summaries and preserves disclosure focus by input modality", async () => { + render( + ({ + ...data, + timeline: { + ...data.timeline, + accessibleLabels: [ + "Midnight has no events", + "Noon has twenty events", + "End of day has no events", + ], + }, + }), + }), + ]} + /> + ) + + expect( + await screen.findByText("Noon has twenty events") + ).toBeInTheDocument() + await userEvent.click( + screen.getByRole("button", { + name: "View activity for Barcelona · HQ", + }) + ) + const close = screen.getByRole("button", { + name: "Close location activity", + }) + close.focus() + await userEvent.keyboard("{Enter}") + const open = screen.getByRole("button", { + name: "View activity for Barcelona · HQ", + }) + await waitFor(() => expect(open).toHaveFocus()) + await userEvent.keyboard("{Enter}") + await waitFor(() => + expect( + screen.getByRole("button", { name: "Close location activity" }) + ).toHaveFocus() + ) + + await userEvent.click( + screen.getByRole("button", { name: "Close location activity" }) + ) + await waitFor(() => + expect( + screen.getByRole("button", { + name: "View activity for Barcelona · HQ", + }) + ).not.toHaveFocus() + ) + + await userEvent.click( + screen.getByRole("button", { + name: "View activity for Barcelona · HQ", + }) + ) + await waitFor(() => + expect( + screen.getByRole("button", { name: "Close location activity" }) + ).not.toHaveFocus() + ) + }) + + it("adapts its responsive disclosure as the widget width changes", async () => { + const OriginalResizeObserver = globalThis.ResizeObserver + const callbacks: ResizeObserverCallback[] = [] + globalThis.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + callbacks.push(callback) + } + observe() {} + unobserve() {} + disconnect() {} + } + + try { + render() + expect(await screen.findByText("Alex Rivera")).toBeInTheDocument() + + act(() => { + for (const callback of callbacks) { + callback( + [ + { + contentRect: { width: 900 }, + } as ResizeObserverEntry, + ], + {} as ResizeObserver + ) + } + }) + + const close = await screen.findByRole("button", { + name: "Close location activity", + }) + close.focus() + + act(() => { + for (const callback of callbacks) { + callback( + [ + { + contentRect: { width: 680 }, + } as ResizeObserverEntry, + ], + {} as ResizeObserver + ) + } + }) + expect(close).toHaveFocus() + expect(close).toBeInTheDocument() + + screen + .getByRole("button", { name: /Barcelona · HQ · Density: 39/ }) + .focus() + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Close location activity" }) + ).not.toBeInTheDocument() + ) + + act(() => { + for (const callback of callbacks) { + callback( + [ + { + contentRect: { width: 900 }, + } as ResizeObserverEntry, + ], + {} as ResizeObserver + ) + } + }) + await waitFor(() => + expect( + screen.getByRole("button", { name: "Close location activity" }) + ).toBeInTheDocument() + ) + } finally { + globalThis.ResizeObserver = OriginalResizeObserver + } + }) + + it("renders the embedded fallback when WebGL is unavailable", async () => { + render() + + await userEvent.click( + await screen.findByRole("button", { name: "Use map fallback" }) + ) + + expect( + screen.getByRole("region", { name: "Activity by location" }) + ).toBeInTheDocument() + expect(screen.getAllByText("Barcelona · HQ").length).toBeGreaterThan(0) + expect(screen.getByText("24-hour activity")).toBeInTheDocument() + }) + + it("keeps fallback details and their grid column absent without a selection", async () => { + const { container } = render( + + ) + + await userEvent.click( + await screen.findByRole("button", { name: "Use map fallback" }) + ) + + const fallbackLayout = container.querySelector( + "[data-location-fallback-layout]" + ) + expect(fallbackLayout).not.toHaveClass( + "@4xl:grid-cols-[minmax(0,1fr)_400px]" + ) + expect(screen.queryByRole("complementary")).not.toBeInTheDocument() + + await userEvent.click( + screen.getByRole("button", { name: /Barcelona · HQ Density: 39/ }) + ) + + expect( + screen.getByRole("complementary", { name: "Barcelona · HQ" }) + ).toBeInTheDocument() + expect(fallbackLayout).toHaveClass("@4xl:grid-cols-[minmax(0,1fr)_400px]") + }) + + it("shows the location skeleton until its fetcher resolves", async () => { + let resolveData: ((value: DashboardLocationData) => void) | undefined + const fetchData = vi.fn( + () => + new Promise((resolve) => { + resolveData = resolve + }) + ) + + const { container } = render( + + ) + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() + expect( + container.querySelector("[data-location-summary-skeleton]") + ).toBeInTheDocument() + + await act(async () => resolveData?.(data)) + expect(await screen.findByText("Alex Rivera")).toBeInTheDocument() + expect(container.querySelector('[aria-busy="true"]')).toBeNull() + }) + + it("keeps disabled location sections out of the loading skeleton", () => { + const fetchData = () => new Promise(() => undefined) + const item = makeItem({ fetchData }) + + const { container } = render( + + ) + + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() + expect( + container.querySelector("[data-location-summary-skeleton]") + ).toBeNull() + expect( + container.querySelector("[data-location-details-skeleton]") + ).toBeNull() + expect( + container.querySelector("[data-location-timeline-skeleton]") + ).toBeNull() + }) + + it("renders an unrelated IT inventory dataset through the same item type", async () => { + const inventoryData: DashboardLocationData = { + summary: { assigned: 322, available: 48, attention: 17 }, + locations: [ + { + id: "inventory-barcelona", + name: "Barcelona · HQ", + coordinates: [2.17, 41.38], + density: 116, + detailsLabel: "116 devices", + details: [ + { + id: "device-1", + title: 'MacBook Pro 14" · IT-1842', + description: "Alex Rivera", + avatar: { type: "icon", icon: Computer }, + values: [{ label: "Status", value: "Healthy", tone: "positive" }], + }, + ], + }, + ], + timeline: { + categories: ["00:00", "12:00", "24:00"], + series: [{ name: "Assignments", data: [0, 6, 0] }], + }, + } + + render( + `View inventory for ${name}`, + }, + fetchData: async () => inventoryData, + }), + ]} + /> + ) + + expect( + await screen.findByText("IT inventory by location") + ).toBeInTheDocument() + expect(screen.getAllByText("116 devices")).not.toHaveLength(0) + expect(screen.getByText('MacBook Pro 14" · IT-1842')).toBeInTheDocument() + expect(screen.getByTestId("location-timeline-chart")).toHaveTextContent( + "Assignments" + ) + }) + + it("uses the dashboard loading and retry contract", async () => { + const fetchData = vi + .fn() + .mockRejectedValueOnce(new Error("Unavailable")) + .mockResolvedValueOnce(data) + + render() + + await waitFor(() => + expect(screen.getByText("Unavailable")).toBeInTheDocument() + ) + await userEvent.click(screen.getByRole("button", { name: "Retry" })) + + expect(await screen.findByText("Alex Rivera")).toBeInTheDocument() + expect(fetchData).toHaveBeenCalledTimes(2) + }) + + it("shows the configured empty state without inventing domain copy", async () => { + render( + ({ + summary: {}, + locations: [], + timeline: { categories: [], series: [] }, + }), + }), + ]} + /> + ) + + expect(await screen.findByText("No activity")).toBeInTheDocument() + }) +}) diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/useDashboardExport.test.ts b/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/useDashboardExport.test.ts index 91f2b12b64..973537943b 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/useDashboardExport.test.ts +++ b/packages/react/src/patterns/F0AnalyticsDashboard/__tests__/useDashboardExport.test.ts @@ -1,13 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { act, zeroRenderHook } from "@/testing/test-utils" import type { FiltersDefinition, FiltersState, } from "@/patterns/OneFilterPicker/types" +import { ChartLine } from "@/icons/app" +import { act, zeroRenderHook } from "@/testing/test-utils" + +import type { DashboardItem, DashboardLocationConfig } from "../types" + import { useDashboardExport } from "../hooks/useDashboardExport" -import type { DashboardItem } from "../types" import * as downloadHelpers from "../utils/downloadHelpers" const filtersDefinition = { @@ -26,6 +29,32 @@ const activeFilters: TestFilters = { department: ["engineering"], } +const locationConfig: DashboardLocationConfig = { + summaryMetrics: [ + { id: "devices", label: "Devices", icon: ChartLine }, + { id: "available", label: "Available", icon: ChartLine }, + { id: "attention", label: "Attention", icon: ChartLine }, + ], + densityLabel: "Devices", + densityLowLabel: () => "Low", + densityMediumLabel: () => "Medium", + densityHighLabel: () => "High", + timelineTitle: "Asset movement", + timelineAriaLabel: "Asset movement", + mapAriaLabel: "Inventory by location", + selectLocationLabel: "Select a location", + viewLocationDetailsLabel: (name) => `View ${name}`, + closeLocationDetailsLabel: "Close", + noDataLabel: "No inventory", + exportLabels: { + location: "Location", + density: "Devices", + details: "Inventory", + item: "Item", + description: "Owner", + }, +} + async function runExport(items: DashboardItem[]) { const { result } = zeroRenderHook(() => useDashboardExport({ @@ -144,4 +173,298 @@ describe("useDashboardExport", () => { expect(filteredCreateSource).toHaveBeenCalledWith(activeFilters) expect(unfilteredCreateSource).toHaveBeenCalledWith({}) }) + + it("exports built-in location detail rows with dashboard filters", async () => { + const fetchData = vi.fn().mockResolvedValue({ + summary: { devices: 1 }, + locations: [ + { + id: "barcelona", + name: "Barcelona · HQ", + coordinates: [2.17, 41.38], + density: 39, + detailsLabel: "1 device", + details: [ + { + id: "device-1", + title: "MacBook Pro · IT-1842", + description: "Alex Rivera", + avatar: { type: "icon", icon: ChartLine }, + values: [{ label: "Status", value: "Healthy" }], + }, + ], + }, + ], + timeline: { categories: [], series: [] }, + }) + + await runExport([ + { + id: "inventory-map", + title: "IT inventory by location", + type: "location", + location: locationConfig, + fetchData, + }, + ]) + + expect(fetchData).toHaveBeenCalledWith(activeFilters) + expect(downloadHelpers.downloadMultiSheetExcel).toHaveBeenCalledWith( + [ + expect.objectContaining({ + name: "IT inventory by location", + columns: [ + "Location", + "Devices", + "Inventory", + "Item", + "Owner", + "Status", + ], + keys: [ + "location:name", + "location:density", + "location:details", + "location:item", + "location:description", + "location:value:Status", + ], + rows: [ + { + "location:name": "Barcelona · HQ", + "location:density": 39, + "location:details": "1 device", + "location:item": "MacBook Pro · IT-1842", + "location:description": "Alex Rivera", + "location:value:Status": "Healthy", + }, + ], + }), + ], + "test-dashboard" + ) + }) + + it("honors location filter opt-out and exports locations without detail rows", async () => { + const fetchData = vi.fn().mockResolvedValue({ + summary: {}, + locations: [ + { + id: "madrid", + name: "Madrid · Castellana", + coordinates: [-3.7, 40.41], + density: 4, + detailsLabel: "No assigned devices", + details: [], + }, + { + id: "paris", + name: "Paris · République", + coordinates: [2.35, 48.85], + density: 1, + detailsLabel: "1 device", + details: [ + { + id: "device-2", + title: "MacBook Air · IT-0091", + avatar: { type: "icon", icon: ChartLine }, + values: [{ label: "Status", value: "Healthy" }], + }, + ], + }, + ], + timeline: { categories: [], series: [] }, + }) + + await runExport([ + { + id: "inventory-map", + title: "IT inventory by location", + type: "location", + useDashboardFilters: false, + location: locationConfig, + fetchData, + }, + ]) + + expect(fetchData).toHaveBeenCalledWith({}) + expect(downloadHelpers.downloadMultiSheetExcel).toHaveBeenCalledWith( + [ + expect.objectContaining({ + columns: ["Location", "Devices", "Inventory", "Item", "Status"], + keys: [ + "location:name", + "location:density", + "location:details", + "location:item", + "location:value:Status", + ], + rows: [ + { + "location:name": "Madrid · Castellana", + "location:density": 4, + "location:details": "No assigned devices", + }, + { + "location:name": "Paris · République", + "location:density": 1, + "location:details": "1 device", + "location:item": "MacBook Air · IT-0091", + "location:value:Status": "Healthy", + }, + ], + }), + ], + "test-dashboard" + ) + }) + + it("does not download a sheet for an empty location collection", async () => { + await runExport([ + { + id: "empty-map", + title: "Empty locations", + type: "location", + location: locationConfig, + fetchData: async () => ({ + summary: {}, + locations: [], + timeline: { categories: [], series: [] }, + }), + }, + ]) + + expect(downloadHelpers.downloadMultiSheetExcel).not.toHaveBeenCalled() + }) + + it("preserves location values when localized export labels are identical", async () => { + await runExport([ + { + id: "duplicate-location-labels", + title: "Duplicate labels", + type: "location", + location: { + ...locationConfig, + exportLabels: { + location: "Value", + density: "Value", + details: "Value", + item: "Value", + description: "Value", + }, + }, + fetchData: async () => ({ + summary: {}, + locations: [ + { + id: "barcelona", + name: "Barcelona · HQ", + coordinates: [2.17, 41.38], + density: 39, + detailsLabel: "1 person", + details: [ + { + id: "alex", + title: "Alex Rivera", + description: "Workplace", + avatar: { type: "icon", icon: ChartLine }, + values: [], + }, + ], + }, + ], + }), + }, + ]) + + expect(downloadHelpers.downloadMultiSheetExcel).toHaveBeenCalledWith( + [ + expect.objectContaining({ + columns: ["Value", "Value", "Value", "Value", "Value"], + keys: [ + "location:name", + "location:density", + "location:details", + "location:item", + "location:description", + ], + rows: [ + { + "location:name": "Barcelona · HQ", + "location:density": 39, + "location:details": "1 person", + "location:item": "Alex Rivera", + "location:description": "Workplace", + }, + ], + }), + ], + "test-dashboard" + ) + }) + + it("warns and skips a rejected location export", async () => { + const error = new Error("Location export unavailable") + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await runExport([ + { + id: "failed-map", + title: "Failed locations", + type: "location", + location: locationConfig, + fetchData: async () => Promise.reject(error), + }, + ]) + + expect(warning).toHaveBeenCalledWith( + '[useDashboardExport] Failed to export location item "Failed locations":', + error + ) + expect(downloadHelpers.downloadMultiSheetExcel).not.toHaveBeenCalled() + }) + + it("omits custom bodies while exporting supported items", async () => { + const renderContent = vi.fn(() => null) + + await runExport([ + { + id: "headcount", + title: "Headcount", + type: "metric", + fetchData: async () => ({ value: 42 }), + }, + { + id: "clock-map", + title: "Clock activity by location", + type: "custom", + renderContent, + }, + ]) + + expect(downloadHelpers.downloadMultiSheetExcel).toHaveBeenCalledTimes(1) + expect(downloadHelpers.downloadMultiSheetExcel).toHaveBeenCalledWith( + [ + expect.objectContaining({ + name: "Metrics", + rows: [{ Metric: "Headcount", Value: 42 }], + }), + ], + "test-dashboard" + ) + expect(renderContent).not.toHaveBeenCalled() + }) + + it("does not download an empty workbook for custom-only dashboards", async () => { + await runExport([ + { + id: "clock-map", + title: "Clock activity by location", + type: "custom", + renderContent: () => null, + }, + ]) + + expect(downloadHelpers.downloadMultiSheetExcel).not.toHaveBeenCalled() + }) }) diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/components/CustomItem/CustomItem.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/components/CustomItem/CustomItem.tsx new file mode 100644 index 0000000000..51679673fe --- /dev/null +++ b/packages/react/src/patterns/F0AnalyticsDashboard/components/CustomItem/CustomItem.tsx @@ -0,0 +1,70 @@ +import type { + FiltersDefinition, + FiltersState, +} from "@/patterns/OneFilterPicker/types" +import type { DropdownItem } from "@/experimental/Navigation/Dropdown" + +import type { + DashboardCustomItem as DashboardCustomItemType, + DashboardItemFiltersConfig, + F0AnalyticsDashboardAskAiTarget, + F0AnalyticsDashboardAskAiTargetWithQuote, +} from "../../types" + +import { DashboardItem } from "../DashboardItem/DashboardItem" + +interface CustomItemProps { + item: DashboardCustomItemType + filters: FiltersState + actions?: DropdownItem[] + itemFilters?: DashboardItemFiltersConfig + editMode?: boolean + handleDelete?: (itemId: string) => void + onAskAi?: (item: F0AnalyticsDashboardAskAiTarget) => void + onAskAiTarget?: (item: F0AnalyticsDashboardAskAiTargetWithQuote) => void + isFullscreen?: boolean + onFullscreenChange?: (fullscreen: boolean) => void +} + +/** + * Keeps domain-specific content inside the same shell and grid contracts as + * every built-in analytics item. + */ +export function CustomItem({ + item, + filters, + actions, + itemFilters, + editMode, + handleDelete, + onAskAi, + onAskAiTarget, + isFullscreen, + onFullscreenChange, +}: CustomItemProps) { + const effectiveFilters = + item.useDashboardFilters === false ? ({} as FiltersState) : filters + + return ( + +
+ {item.renderContent(effectiveFilters)} +
+
+ ) +} diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/components/CustomItem/__tests__/CustomItem.test.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/components/CustomItem/__tests__/CustomItem.test.tsx new file mode 100644 index 0000000000..93eaa5e4b1 --- /dev/null +++ b/packages/react/src/patterns/F0AnalyticsDashboard/components/CustomItem/__tests__/CustomItem.test.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from "react" +import { describe, expect, it, vi } from "vitest" + +import type { + FiltersDefinition, + FiltersState, +} from "@/patterns/OneFilterPicker/types" +import { zeroRender } from "@/testing/test-utils" + +import type { DashboardCustomItem } from "../../../types" +import { CustomItem } from "../CustomItem" + +const dashboardItemProps = vi.hoisted(() => vi.fn()) + +vi.mock( + "@/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem", + () => ({ + DashboardItem: (props: { children: ReactNode }) => { + dashboardItemProps(props) + return
{props.children}
+ }, + }) +) + +const filtersDefinition = { + department: { + type: "in", + label: "Department", + options: { options: [{ value: "engineering", label: "Engineering" }] }, + }, +} as const satisfies FiltersDefinition + +type TestFilters = FiltersState + +describe("CustomItem", () => { + it("forwards dashboard shell behavior and the resolved filter scope", () => { + const filters: TestFilters = { department: ["engineering"] } + const renderContent = vi.fn(() =>
Host content
) + const handleDelete = vi.fn() + const onAskAi = vi.fn() + const onFullscreenChange = vi.fn() + const item: DashboardCustomItem = { + id: "custom", + type: "custom", + title: "Host visualization", + description: "Current period", + info: "Host-owned content", + explanation: "Calculated by the host.", + renderContent, + } + + const { rerender } = zeroRender( + + ) + + expect(renderContent).toHaveBeenLastCalledWith(filters) + expect(dashboardItemProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + title: "Host visualization", + description: "Current period", + info: "Host-owned content", + explanation: "Calculated by the host.", + editMode: true, + handleDelete, + onAskAi, + itemId: "custom", + isFullscreen: true, + onFullscreenChange, + }) + ) + + rerender( + + ) + expect(renderContent).toHaveBeenLastCalledWith({}) + }) +}) diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardGrid/DashboardGrid.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardGrid/DashboardGrid.tsx index 3227baf060..95fd0713b1 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardGrid/DashboardGrid.tsx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardGrid/DashboardGrid.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react" import type { DropdownItem as DropdownItemType } from "@/experimental/Navigation/Dropdown" import type { @@ -7,9 +7,11 @@ import type { } from "@/patterns/OneFilterPicker/types" import { F0Icon } from "@/components/F0Icon" +import { Add, ArrowDown, ArrowUp, Minus } from "@/icons/app" import Handle from "@/icons/app/Handle" import { WIDGET_DRAG_END, WIDGET_DRAG_START } from "@/lib/dnd/widgetDragEvents" -import { cn } from "@/lib/utils" +import { useI18n } from "@/lib/providers/i18n" +import { cn, focusRing } from "@/lib/utils" import type { DashboardItem as DashboardItemType, @@ -21,19 +23,25 @@ import type { import { ChartItem, chartItemFitsContent } from "../ChartItem/ChartItem" import { CollectionItem } from "../CollectionItem/CollectionItem" +import { CustomItem } from "../CustomItem/CustomItem" import { DashboardItem } from "../DashboardItem/DashboardItem" +import { LocationItem } from "../LocationItem/LocationItem" import { MetricItem } from "../MetricItem/MetricItem" const GAP = 12 const MAX_PER_ROW = 4 const NARROW_THRESHOLD = 640 const DRAG_START_THRESHOLD = 4 +const MAX_ROW_HEIGHT = 1600 +const REORDER_KEY_SHORTCUTS = "ArrowUp ArrowDown ArrowLeft ArrowRight" // i18n-exempt -- standardized ARIA key names, not UI copy /** Default row height in px, determined by the tallest item type. */ const ROW_HEIGHTS: Record = { chart: 336, metric: 144, collection: 480, + location: 700, + custom: 700, } const DEFAULT_ROW_HEIGHT = 336 @@ -42,6 +50,8 @@ const MIN_ROW_HEIGHTS: Record = { chart: 240, metric: 120, collection: 300, + location: 640, + custom: 480, } const DEFAULT_MIN_ROW_HEIGHT = 120 @@ -104,8 +114,11 @@ export function DashboardGrid({ onFullscreenChange, }: DashboardGridProps) { const containerRef = useRef(null) - const [isNarrow, setIsNarrow] = useState(false) + const fullscreenFocusItemRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(null) const [fullscreenItemId, setFullscreenItemId] = useState(null) + const [layoutAnnouncement, setLayoutAnnouncement] = useState("") + const translations = useI18n() // Notify the parent whenever click-fullscreen state flips so it can apply // the fill-height layout (same chain that single-item dashboards use). @@ -113,6 +126,33 @@ export function DashboardGrid({ onFullscreenChange?.(!!fullscreenItemId) }, [fullscreenItemId, onFullscreenChange]) + useEffect(() => { + const itemId = fullscreenFocusItemRef.current + if (!itemId) return + fullscreenFocusItemRef.current = null + + queueMicrotask(() => { + const toggles = + containerRef.current?.querySelectorAll( + "[data-dashboard-fullscreen-toggle]" + ) ?? [] + Array.from(toggles) + .find((toggle) => toggle.dataset.dashboardFullscreenToggle === itemId) + ?.focus() + }) + }, [fullscreenItemId]) + + const setItemFullscreen = useCallback( + (itemId: string, fullscreen: boolean) => { + const focusedToggle = + document.activeElement instanceof HTMLElement && + document.activeElement.dataset.dashboardFullscreenToggle === itemId + fullscreenFocusItemRef.current = focusedToggle ? itemId : null + setFullscreenItemId(fullscreen ? itemId : null) + }, + [] + ) + // Build item lookup const itemMap = useMemo(() => { const map = new Map>() @@ -148,7 +188,7 @@ export function DashboardGrid({ if (!el) return const observer = new ResizeObserver((entries) => { for (const entry of entries) { - setIsNarrow(entry.contentRect.width < NARROW_THRESHOLD) + setContainerWidth(entry.contentRect.width) } }) observer.observe(el) @@ -271,17 +311,22 @@ export function DashboardGrid({ } else if (target.rowIdx >= next.length) { next.push({ ids: [draggedId], height: newRowHeight }) } else { - const adjPos = Math.min( - target.position, - next[target.rowIdx].ids.length - ) - next[target.rowIdx].ids.splice(adjPos, 0, draggedId) - const minHeight = getMinRowHeight( - next[target.rowIdx], - itemMapRef.current - ) - if (next[target.rowIdx].height < minHeight) { - next[target.rowIdx] = { ...next[target.rowIdx], height: minHeight } + const targetRow = next[target.rowIdx] + if ( + !item || + !canAddItemToRow(targetRow.ids, item, itemMapRef.current) + ) { + next.splice(target.rowIdx + 1, 0, { + ids: [draggedId], + height: newRowHeight, + }) + } else { + const adjPos = Math.min(target.position, targetRow.ids.length) + targetRow.ids.splice(adjPos, 0, draggedId) + const minHeight = getMinRowHeight(targetRow, itemMapRef.current) + if (targetRow.height < minHeight) { + next[target.rowIdx] = { ...targetRow, height: minHeight } + } } } @@ -354,7 +399,18 @@ export function DashboardGrid({ // Middle third → merge into the row. if (isFromThisRow && row.ids.length === 1) return null - if (row.ids.length >= MAX_PER_ROW && !isFromThisRow) + const draggedItem = draggedId + ? itemMapRef.current.get(draggedId) + : undefined + if ( + !draggedItem || + !canAddItemToRow( + row.ids, + draggedItem, + itemMapRef.current, + isFromThisRow ? (draggedId ?? undefined) : undefined + ) + ) return { type: "new-row", afterRowIdx: i } const cards = rowEls[i].querySelectorAll("[data-card-id]") @@ -476,6 +532,72 @@ export function DashboardGrid({ [commitDrop, onAskAi, onAskAiTarget, resolveDropTarget] ) + const moveItemByStep = useCallback( + (id: string, direction: -1 | 1) => { + setRows((prev) => { + const orderedIds = prev.flatMap((row) => row.ids) + const fromIndex = orderedIds.indexOf(id) + const toIndex = fromIndex + direction + if (fromIndex < 0 || toIndex < 0 || toIndex >= orderedIds.length) { + return prev + } + + const rowHeightById = new Map() + for (const row of prev) { + for (const itemId of row.ids) rowHeightById.set(itemId, row.height) + } + orderedIds.splice(fromIndex, 1) + orderedIds.splice(toIndex, 0, id) + + const next = buildRowsFromOrder( + orderedIds, + itemMapRef.current, + rowHeightById + ) + emitLayout(next) + + const title = itemMapRef.current.get(id)?.title ?? "" + queueMicrotask(() => { + setLayoutAnnouncement( + `${ + direction < 0 + ? translations.actions.moveUp + : translations.actions.moveDown + }: ${title}.` + ) + requestAnimationFrame(() => { + const reorderButtons = + containerRef.current?.querySelectorAll( + "[data-reorder-id]" + ) ?? [] + Array.from(reorderButtons) + .find((button) => button.dataset.reorderId === id) + ?.focus() + }) + }) + return next + }) + }, + [emitLayout, translations.actions.moveDown, translations.actions.moveUp] + ) + + const handleGripKeyDown = useCallback( + (id: string, event: React.KeyboardEvent) => { + const direction = + event.key === "ArrowLeft" || event.key === "ArrowUp" + ? -1 + : event.key === "ArrowRight" || event.key === "ArrowDown" + ? 1 + : 0 + if (direction === 0) return + + event.preventDefault() + event.stopPropagation() + moveItemByStep(id, direction) + }, + [moveItemByStep] + ) + // A drag in flight when this unmounts (navigating away, switching // dashboards) has to be retracted too, or the announcement outlives the grid // that made it. @@ -493,8 +615,12 @@ export function DashboardGrid({ getMinRowHeight(rows[rowIdx], itemMap), contentMinHeight ) + const maxHeight = Math.max(MAX_ROW_HEIGHT, startHeight) const onMove = (e: MouseEvent) => { - const newHeight = Math.max(minHeight, startHeight + e.clientY - startY) + const newHeight = Math.min( + maxHeight, + Math.max(minHeight, startHeight + e.clientY - startY) + ) setRows((prev) => prev.map((row, i) => i === rowIdx ? { ...row, height: newHeight } : row @@ -515,17 +641,58 @@ export function DashboardGrid({ [rows, emitLayout, itemMap] ) + const resizeRowByStep = useCallback( + ( + rowIdx: number, + delta: number, + rowEl: HTMLElement | null, + measurableCardIds: ReadonlySet + ) => { + setRows((prev) => { + const row = prev[rowIdx] + if (!row) return prev + + const renderedHeight = Math.max( + row.height, + rowEl?.getBoundingClientRect().height ?? 0 + ) + const minHeight = Math.max( + getMinRowHeight(row, itemMapRef.current), + getRowContentMinHeight(rowEl, measurableCardIds) + ) + const maxHeight = Math.max(MAX_ROW_HEIGHT, renderedHeight) + const height = Math.min( + maxHeight, + Math.max(minHeight, renderedHeight + delta) + ) + if (height === row.height) return prev + + const next = prev.map((currentRow, index) => + index === rowIdx ? { ...currentRow, height } : currentRow + ) + emitLayout(next) + return next + }) + }, + [emitLayout] + ) + // ─── Render ───────────────────────────────────────────────── - const displayRows = isNarrow - ? rows.flatMap((row) => - row.ids.map((id) => ({ + const displayRows = rows.flatMap((row) => + shouldStackRow(row, containerWidth, itemMap) + ? row.ids.map((id) => ({ ids: [id], height: row.height, })) - ) - : rows + : [row] + ) + const displayOrder = displayRows.flatMap((row) => row.ids) + const hasResponsiveStacking = displayRows.length > rows.length - const canDrag = !!editMode && !isNarrow + // Responsive stacking is a presentation-only projection of the persisted + // row. Disable row mutation while projected rows differ from state, so drag + // and resize never operate against mismatched row indexes. + const canDrag = !!editMode && !hasResponsiveStacking const isNewRowTarget = (afterIdx: number) => dragId && dropTarget?.type === "new-row" && @@ -544,7 +711,15 @@ export function DashboardGrid({ if (items.length === 1) { const soleItem = items[0] return ( -
+
({ onAskAi={onAskAi} onAskAiTarget={onAskAiTarget} isFullscreen - onFullscreenChange={(fs) => - setFullscreenItemId(fs ? fullscreenItemId : null) - } + onFullscreenChange={(fs) => setItemFullscreen(fullscreenItemId, fs)} />
) @@ -624,16 +797,23 @@ export function DashboardGrid({ const hasCollection = row.ids.some( (id) => itemMap.get(id)?.type === "collection" ) + const rowTitle = row.ids + .map((id) => itemMap.get(id)?.title) + .filter(Boolean) + .join(", ") + const measurableCardIds = new Set( + row.ids.filter((id) => itemMap.get(id)?.type !== "chart") + ) return ( -
+
{/* Drop line before this row. The first row also gets one so an item can be reordered to the very top (afterRowIdx -1). */} {canDrag && }
({ 0} + canMoveLater={ + displayOrder.indexOf(id) < displayOrder.length - 1 + } onContentHeightChange={handleItemContentHeightChange} > ({ onTransformChart={onTransformChart} onAskAi={onAskAi} onAskAiTarget={onAskAiTarget} - onFullscreenChange={(fs) => - setFullscreenItemId(fs ? id : null) - } + onFullscreenChange={(fs) => setItemFullscreen(id, fs)} /> ) @@ -694,39 +879,105 @@ export function DashboardGrid({
{/* Row resize handle — only in edit mode */} {canDrag && ( -
{ - e.preventDefault() - const rowEl = - e.currentTarget.parentElement?.querySelector( - "[data-dashboard-row]" +
+ + +
)}
@@ -750,6 +1001,9 @@ export function DashboardGrid({ {itemMap.get(dragId)?.title ?? ""}
)} +

+ {layoutAnnouncement} +

) } @@ -758,24 +1012,36 @@ export function DashboardGrid({ function RowItem({ id, + title, isDragging, showIndicatorBefore, showIndicatorAfter, draggable: canDrag, onGripPointerDown, + onGripKeyDown, + onMove, + canMoveEarlier, + canMoveLater, onContentHeightChange, children, }: { id: string + title: string isDragging: boolean showIndicatorBefore: boolean showIndicatorAfter: boolean draggable: boolean onGripPointerDown: (id: string, e: React.PointerEvent) => void + onGripKeyDown: (id: string, e: React.KeyboardEvent) => void + onMove: (id: string, direction: -1 | 1) => void + canMoveEarlier: boolean + canMoveLater: boolean onContentHeightChange: (id: string, height: number) => void children: React.ReactNode }) { const itemRef = useRef(null) + const reorderLabelId = useId() + const translations = useI18n() useEffect(() => { const el = itemRef.current @@ -840,7 +1106,7 @@ function RowItem({ ref={itemRef} data-card-id={id} className={cn( - "group/rowitem relative min-w-0 flex-1 transition-opacity duration-150", + "group/rowitem relative min-w-0 flex-1 transition-opacity duration-150 motion-reduce:transition-none", isDragging && "opacity-40 scale-[0.97]" )} > @@ -851,12 +1117,50 @@ function RowItem({ // arming depended on the grip sitting inside the draggable card's // box, so the grip's outer half and full-width charts never dragged. // `touch-none` stops touch scrolling from stealing the gesture. -
onGripPointerDown(id, e)} - className="shadow-sm absolute -left-3 top-2.5 z-20 flex cursor-grab touch-none items-center justify-center rounded bg-f1-background p-2 opacity-0 transition-opacity hover:bg-f1-background-hover active:cursor-grabbing group-hover/rowitem:opacity-100" - aria-label="Drag to reorder" - > - +
+ + {translations.collections.editableTable.reorderRow}: {title} + + + +
)} {children} @@ -883,12 +1187,12 @@ function DropIndicator() { function RowGapDropZone({ active }: { active: boolean }) { return (
@@ -927,9 +1231,12 @@ function buildItemLayoutSignature( item.type, item.itemHeight ?? null, item.rowSpan ?? null, + item.minItemHeight ?? null, item.x ?? null, item.y ?? null, item.colSpan ?? null, + item.type === "custom" ? (item.allowRowSharing ?? false) : null, + item.type === "location" ? (item.minItemWidth ?? 720) : null, ]) ) } @@ -943,25 +1250,21 @@ function buildRowsFromPositions( (a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0) ) - const rowMap = new Map() + const rowMap = new Map[]>() for (const item of sorted) { const y = item.y ?? 0 - const h = resolveItemHeight(item) - - let entry = rowMap.get(y) - if (!entry) { - entry = { ids: [], maxHeight: 0 } - rowMap.set(y, entry) - } - entry.ids.push(item.id) - if (h > entry.maxHeight) entry.maxHeight = h + const entry = rowMap.get(y) ?? [] + entry.push(item) + rowMap.set(y, entry) } - // Convert map to sorted array of rows + // Persisted positions can come from older layouts that did not understand + // custom-item row sharing. Re-pack each saved y-band so restoring one keeps + // full-width custom items isolated and shareable custom items paired at most. return [...rowMap.entries()] .sort(([a], [b]) => a - b) - .map(([, entry]) => ({ ids: entry.ids, height: entry.maxHeight })) + .flatMap(([, rowItems]) => buildRowsGreedy(rowItems)) } /** Greedy bin-packing for items without saved positions. */ @@ -970,22 +1273,21 @@ function buildRowsGreedy( ): Row[] { const rows: Row[] = [] let currentIds: string[] = [] - let currentSlots = 0 + let currentItems: DashboardItemType[] = [] let currentMaxHeight = 0 for (const item of items) { - const weight = getSlotWeight(item) const h = resolveItemHeight(item) - if (currentSlots + weight > MAX_PER_ROW && currentIds.length > 0) { + if (!canPackItems(currentItems, item) && currentIds.length > 0) { rows.push({ ids: currentIds, height: currentMaxHeight }) currentIds = [] - currentSlots = 0 + currentItems = [] currentMaxHeight = 0 } currentIds.push(item.id) - currentSlots += weight + currentItems.push(item) if (h > currentMaxHeight) currentMaxHeight = h } if (currentIds.length > 0) { @@ -995,6 +1297,40 @@ function buildRowsGreedy( return rows } +/** Re-pack a user-defined order without ever violating full-row slot weights. */ +function buildRowsFromOrder( + orderedIds: string[], + itemMap: Map>, + rowHeightById: ReadonlyMap +): Row[] { + const rows: Row[] = [] + let ids: string[] = [] + let height = 0 + + const flush = () => { + if (ids.length === 0) return + rows.push({ ids, height }) + ids = [] + height = 0 + } + + for (const id of orderedIds) { + const item = itemMap.get(id) + if (!item) continue + + if (ids.length > 0 && !canAddItemToRow(ids, item, itemMap)) flush() + + ids.push(id) + height = Math.max( + height, + resolveItemHeight(item), + rowHeightById.get(id) ?? 0 + ) + } + flush() + return rows +} + /** Minimum height for a row based on the item types it contains. */ function getMinRowHeight( row: Row, @@ -1004,12 +1340,82 @@ function getMinRowHeight( for (const id of row.ids) { const item = itemMap.get(id) if (!item) continue - const h = MIN_ROW_HEIGHTS[item.type] ?? DEFAULT_MIN_ROW_HEIGHT + const h = getItemMinHeight(item) if (h > min) min = h } return min } +function getItemMinHeight( + item: DashboardItemType +): number { + return Math.max( + MIN_ROW_HEIGHTS[item.type] ?? DEFAULT_MIN_ROW_HEIGHT, + item.minItemHeight ?? 0 + ) +} + +function shouldStackRow( + row: Row, + containerWidth: number | null, + itemMap: Map> +): boolean { + if (containerWidth === null || row.ids.length < 2) return false + if (containerWidth < NARROW_THRESHOLD) return true + + const availableItemWidth = + (containerWidth - GAP * (row.ids.length - 1)) / row.ids.length + + return row.ids.some((id) => { + const item = itemMap.get(id) + const minItemWidth = + item?.type === "location" + ? (item.minItemWidth ?? 720) + : item?.type === "custom" && item.allowRowSharing + ? item.minItemWidth + : undefined + return ( + typeof minItemWidth === "number" && + Number.isFinite(minItemWidth) && + minItemWidth > 0 && + availableItemWidth < minItemWidth + ) + }) +} + +function canAddItemToRow( + rowIds: string[], + candidate: DashboardItemType, + itemMap: Map>, + excludedId?: string +): boolean { + const items = rowIds.flatMap((id) => { + if (id === excludedId) return [] + const item = itemMap.get(id) + return item ? [item] : [] + }) + return canPackItems(items, candidate) +} + +function canPackItems( + existingItems: DashboardItemType[], + candidate: DashboardItemType +): boolean { + const combined = [...existingItems, candidate] + const slotWeight = combined.reduce( + (weight, item) => weight + getSlotWeight(item), + 0 + ) + if (slotWeight > MAX_PER_ROW) return false + + const hasTwoItemLimit = combined.some( + (item) => + item.type === "location" || + (item.type === "custom" && item.allowRowSharing) + ) + return !hasTwoItemLimit || combined.length <= 2 +} + /** * Minimum height the row's content requires, measured from the live DOM at * resize start. Only the given cards are measured — callers pass every @@ -1063,6 +1469,8 @@ function getSlotWeight( if (item.type === "metric") return 1 if (item.type === "chart") return 2 if (item.type === "collection") return MAX_PER_ROW + if (item.type === "location") return 2 + if (item.type === "custom") return item.allowRowSharing ? 2 : MAX_PER_ROW return 2 } @@ -1079,9 +1487,14 @@ function getSlotWeight( function resolveItemHeight( item: DashboardItemType ): number { - if (item.itemHeight && item.itemHeight > 0) return item.itemHeight - if (item.rowSpan) return item.rowSpan * 48 - return ROW_HEIGHTS[item.type] ?? DEFAULT_ROW_HEIGHT + const configuredHeight = + item.itemHeight && item.itemHeight > 0 + ? item.itemHeight + : item.rowSpan + ? item.rowSpan * 48 + : (ROW_HEIGHTS[item.type] ?? DEFAULT_ROW_HEIGHT) + + return Math.max(configuredHeight, getItemMinHeight(item)) } // ─── Item renderer ────────────────────────────────────────────── @@ -1162,6 +1575,36 @@ function DashboardGridItem({ onFullscreenChange={onFullscreenChange} /> ) + case "location": + return ( + + ) + case "custom": + return ( + + ) default: { const unknownItem = item as DashboardItemType return ( diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem.tsx index 872ff15328..1f449ae44a 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem.tsx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/components/DashboardItem/DashboardItem.tsx @@ -192,7 +192,7 @@ export function DashboardItem({ const actionsClassName = cn( "flex flex-shrink-0 gap-0.5", !isFullscreen && - "opacity-100 transition-opacity delay-150 duration-150 focus-within:delay-0 group-hover/dashitem:delay-0 sm:[@media(hover:hover)]:opacity-0 focus-within:sm:opacity-100 group-hover/dashitem:sm:opacity-100", + "opacity-100 transition-opacity delay-150 duration-150 motion-reduce:transition-none focus-within:delay-0 group-hover/dashitem:delay-0 sm:[@media(hover:hover)]:opacity-0 focus-within:sm:opacity-100 group-hover/dashitem:sm:opacity-100", !isFullscreen && (isDropdownOpen || isFiltersOpen) && "delay-0 !opacity-100" ) @@ -399,6 +399,7 @@ export function DashboardItem({ )} {hasFullscreen && ( { + item: DashboardLocationItem + filters: FiltersState + actions?: DropdownItem[] + itemFilters?: DashboardItemFiltersConfig + editMode?: boolean + handleDelete?: (itemId: string) => void + onAskAi?: (item: F0AnalyticsDashboardAskAiTarget) => void + onAskAiTarget?: (item: F0AnalyticsDashboardAskAiTargetWithQuote) => void + isFullscreen?: boolean + onFullscreenChange?: (fullscreen: boolean) => void +} + +function LocationItemSkeleton({ config }: { config: DashboardLocationConfig }) { + const summaryVisible = config.sections?.summary !== false + const detailsVisible = config.sections?.locationDetails !== false + const timelineVisible = config.sections?.timeline !== false + + return ( +
+ {summaryVisible ? ( + + ) : null} + {detailsVisible ? ( + + ) : null} + {timelineVisible ? ( + + ) : null} +
+ ) +} + +export function LocationItem({ + item, + filters, + actions, + itemFilters, + editMode, + handleDelete, + onAskAi, + onAskAiTarget, + isFullscreen, + onFullscreenChange, +}: LocationItemProps) { + const itemFiltersKey = JSON.stringify(itemFilters?.value ?? {}) + const { data, isLoading, error, retry } = useDashboardItemData( + item.fetchData, + filters, + item.useDashboardFilters !== false, + itemFiltersKey + ) + + return ( + } + actions={actions} + itemFilters={itemFilters} + editMode={editMode} + handleDelete={handleDelete} + onAskAi={onAskAi} + onAskAiTarget={onAskAiTarget} + itemId={item.id} + isFullscreen={isFullscreen} + onFullscreenChange={onFullscreenChange} + > + {data ? ( + + ) : null} + + ) +} diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/components/LocationItem/LocationVisualization.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/components/LocationItem/LocationVisualization.tsx new file mode 100644 index 0000000000..4972a3d31e --- /dev/null +++ b/packages/react/src/patterns/F0AnalyticsDashboard/components/LocationItem/LocationVisualization.tsx @@ -0,0 +1,1148 @@ +import type { ExpressionSpecification } from "maplibre-gl" + +import { useControllableState } from "@radix-ui/react-use-controllable-state" +import { + type MouseEvent, + type Ref, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" + +import { F0Avatar } from "@/components/avatars/F0Avatar" +import { F0Button } from "@/components/F0Button" +import { F0Heading } from "@/components/F0Heading" +import { F0Icon, type IconType } from "@/components/F0Icon" +import { F0Text } from "@/components/F0Text" +import { ChevronDown, ChevronUp, Pin } from "@/icons/app" +import { F0DataChart } from "@/kits/F0DataChart" +import { + paletteColor, + resolveChartColorToken, +} from "@/kits/F0DataChart/utils/colors" +import { useContainerSize } from "@/kits/F0DataChart/utils/useContainerSize" +import { cn, focusRing } from "@/lib/utils" +import { + F0Map, + f0MapDensityPalette, + f0MapDensitySurfaceStyle, + f0MapStyles, + resolveF0MapDensityStyle, + type F0MapDensityLevel, + type F0MapDensityPalette, + type F0MapPoint, + type F0MapStylePair, +} from "@/patterns/F0Map" +import { Text } from "@/ui/Text" + +import type { + DashboardLocationConfig, + DashboardLocationData, + DashboardLocationPoint, + DashboardLocationSummaryTone, + DashboardLocationTimelineData, +} from "../../types" + +const DEFAULT_DENSITY_SCALE = { mediumAt: 6, highAt: 16 } +const DEFAULT_FORMAT_COUNT = (value: number) => value.toLocaleString() +const normalizeCount = (value: number) => + Number.isFinite(value) ? Math.max(0, value) : 0 +const TIMELINE_GRID_LINES = [0, 25, 50, 75] as const +const TIMELINE_GRID_BACKGROUND = + "repeating-linear-gradient(to right, hsl(var(--neutral-30)) 0, hsl(var(--neutral-30)) 1px, transparent 1px, transparent 11px)" +type DetailsPanelState = "responsive" | "open" | "closed" + +const densityLevel = ( + value: number, + scale: NonNullable +): F0MapDensityLevel => { + const { mediumAt, highAt } = scale + if (value >= highAt) return "high" + if (value >= mediumAt) return "medium" + return "low" +} + +const subdueMapLabels = ( + style: F0MapStylePair["light"] +): F0MapStylePair["light"] => { + if (typeof style === "string") return style + + return { + ...style, + layers: style.layers.map((layer) => { + if (layer.type !== "symbol") return layer + + const currentOpacity = layer.paint?.["text-opacity"] + const textOpacity = + currentOpacity === undefined + ? 0.38 + : typeof currentOpacity === "number" + ? Math.min(currentOpacity, 0.38) + : Array.isArray(currentOpacity) + ? (["*", currentOpacity, 0.38] as ExpressionSpecification) + : 0.38 + + return { + ...layer, + paint: { + ...layer.paint, + "text-opacity": textOpacity, + }, + } + }), + } +} + +const locationMapStyle = (style: F0MapStylePair): F0MapStylePair => ({ + light: subdueMapLabels(style.light), + dark: subdueMapLabels(style.dark), +}) + +const Summary = ({ + label, + value, + icon, + tone, + density = "regular", +}: { + label: string + value: string + icon: IconType + tone: DashboardLocationSummaryTone + density?: "regular" | "compact" | "responsive" +}) => ( +
+
+ +
+
+ + + + {value} + +
+
+) + +const DensityLegend = ({ + config, + scale, + palette, + embedded = false, + summaryVisible = true, + timelineVisible = true, + detailsSpaceReserved = true, +}: { + config: DashboardLocationConfig + scale: NonNullable + palette: F0MapDensityPalette + embedded?: boolean + summaryVisible?: boolean + timelineVisible?: boolean + detailsSpaceReserved?: boolean +}) => ( +
+ + {config.densityLabel} + + {[ + { + level: "low", + label: config.densityLowLabel(scale.mediumAt), + style: palette.low, + }, + { + level: "medium", + label: config.densityMediumLabel(scale.mediumAt, scale.highAt), + style: palette.medium, + }, + { + level: "high", + label: config.densityHighLabel(scale.highAt), + style: palette.high, + }, + ].map((item) => ( + + + ))} +
+) + +const LocationDetailsPanel = ({ + location, + config, + id, + embedded = false, + panelState = "responsive", + summaryVisible = true, + timelineVisible = true, + onDismiss, + dismissRef, +}: { + location: DashboardLocationPoint | undefined + config: DashboardLocationConfig + id?: string + embedded?: boolean + panelState?: DetailsPanelState + summaryVisible?: boolean + timelineVisible?: boolean + onDismiss?: (event: MouseEvent) => void + dismissRef?: Ref +}) => ( + +) + +const LocationDetailsTrigger = ({ + location, + config, + controlsId, + onOpen, + triggerRef, + summaryVisible = true, + responsiveUnmeasured = false, +}: { + location: DashboardLocationPoint + config: DashboardLocationConfig + controlsId: string + onOpen: (event: MouseEvent) => void + triggerRef: Ref + summaryVisible?: boolean + responsiveUnmeasured?: boolean +}) => ( + +) + +const LocationTimeline = ({ + timeline, + config, + embedded = false, +}: { + timeline: DashboardLocationTimelineData + config: DashboardLocationConfig + embedded?: boolean +}) => { + const categories = [...timeline.categories] + const series = timeline.series.map((item) => ({ + ...item, + data: [...item.data], + })) + const lastIndex = Math.max(0, categories.length - 1) + const fineStep = Math.max(1, Math.ceil(lastIndex / 12)) + const mediumStep = Math.max(fineStep, Math.ceil(lastIndex / 6)) + const coarseStep = Math.max(mediumStep, Math.ceil(lastIndex / 4)) + const legendRef = useRef(null) + const { width: legendWidth } = useContainerSize(legendRef) + const [legendScrollable, setLegendScrollable] = useState(false) + const legendContentKey = series.map((item) => item.name).join("\u0000") + const axisIndexes = categories + .map((_, index) => index) + .filter( + (index) => index === 0 || index === lastIndex || index % fineStep === 0 + ) + + useLayoutEffect(() => { + const legend = legendRef.current + setLegendScrollable( + legend !== null && legend.scrollWidth > legend.clientWidth + ) + }, [legendContentKey, legendWidth]) + + return ( +
+
+ + + +
    item.name).join(", ")} + tabIndex={legendScrollable ? 0 : undefined} + className={cn( + "flex min-w-0 flex-1 flex-nowrap items-center justify-end gap-x-3 overflow-x-auto", + legendScrollable && + focusRing( + "rounded-sm focus-visible:ring-inset focus-visible:ring-offset-0" + ) + )} + > + {series.map((item, index) => { + const color = item.color + ? resolveChartColorToken(item.color) + : paletteColor(index) + return ( +
  • +
  • + ) + })} +
+
+ + + +
    + {categories.map((category, index) => ( +
  • + {timeline.accessibleLabels?.[index] ?? + `${category}: ${series + .map((item) => `${item.name} ${item.data[index] ?? 0}`) + .join(", ")}`} +
  • + ))} +
+
+ ) +} + +const MapFallback = ({ + locations, + selectedLocationId, + onSelect, + config, + formatCount, + densityScale, + densityPalette, + showDensityLegend, +}: { + locations: readonly DashboardLocationPoint[] + selectedLocationId: string | null + onSelect: (id: string) => void + config: DashboardLocationConfig + formatCount: (value: number) => string + densityScale: NonNullable + densityPalette: F0MapDensityPalette + showDensityLegend: boolean +}) => ( +
+
+ +
+ {locations.length > 0 ? ( +
    + {locations.map((location) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ {config.noDataLabel} +

+ )} + {locations.length > 0 && showDensityLegend && ( +
+ +
+ )} +
+) + +export interface LocationVisualizationProps { + data: DashboardLocationData + config: DashboardLocationConfig + selectedLocationId?: string | null + defaultSelectedLocationId?: string | null + onLocationSelect?: (locationId: string | null) => void +} + +export function LocationVisualization({ + data, + config, + selectedLocationId, + defaultSelectedLocationId, + onLocationSelect, +}: LocationVisualizationProps) { + const { locations, summary, timeline } = data + const { + densityScale = DEFAULT_DENSITY_SCALE, + formatDensity = DEFAULT_FORMAT_COUNT, + formatSummaryValue = DEFAULT_FORMAT_COUNT, + } = config + const summaryVisible = config.sections?.summary !== false + const requestedDetailsVisible = config.sections?.locationDetails !== false + const densityLegendVisible = config.sections?.densityLegend !== false + const requestedTimeline = + config.sections?.timeline !== false ? timeline : undefined + const hasExplicitDefault = defaultSelectedLocationId !== undefined + const [selection, setSelection] = useControllableState({ + prop: selectedLocationId, + defaultProp: hasExplicitDefault + ? defaultSelectedLocationId + : (locations[0]?.id ?? null), + onChange: onLocationSelect, + }) + const previouslyHadLocations = useRef(locations.length > 0) + const [mapFallbackVisible, setMapFallbackVisible] = useState(false) + const [detailsPanelState, setDetailsPanelState] = + useState("responsive") + const [responsiveDetailsOpen, setResponsiveDetailsOpen] = useState< + boolean | null + >(null) + const [detailsVisible, setDetailsVisible] = useState(requestedDetailsVisible) + const [renderedTimeline, setRenderedTimeline] = useState(requestedTimeline) + const widgetRef = useRef(null) + const detailsTriggerRef = useRef(null) + const detailsDismissRef = useRef(null) + const detailsPanelId = useId() + const { width: widgetWidth } = useContainerSize(widgetRef) + + useEffect( + function updateResponsiveDetails() { + if (detailsPanelState !== "responsive" || widgetWidth <= 0) return + const nextOpen = widgetWidth >= 720 + if (nextOpen === responsiveDetailsOpen) return + + const activeElement = document.activeElement + const panel = widgetRef.current?.querySelector("[data-location-details]") + const trigger = widgetRef.current?.querySelector( + "[data-location-details-trigger]" + ) + const wouldHideFocusedSurface = nextOpen + ? trigger?.contains(activeElement) + : panel?.contains(activeElement) + if (!wouldHideFocusedSurface) { + setResponsiveDetailsOpen(nextOpen) + return + } + + const focusedSurface = nextOpen ? trigger : panel + let animationFrame = 0 + const retryAfterFocusLeaves = () => { + cancelAnimationFrame(animationFrame) + animationFrame = requestAnimationFrame(() => { + if (!focusedSurface?.contains(document.activeElement)) { + setResponsiveDetailsOpen(nextOpen) + } + }) + } + focusedSurface?.addEventListener("focusout", retryAfterFocusLeaves) + return () => { + cancelAnimationFrame(animationFrame) + focusedSurface?.removeEventListener("focusout", retryAfterFocusLeaves) + } + }, + [detailsPanelState, responsiveDetailsOpen, widgetWidth] + ) + + useEffect( + function updateDetailsVisibility() { + if (requestedDetailsVisible === detailsVisible) return + if (!requestedDetailsVisible) { + const activeElement = document.activeElement + const detailsSurface = widgetRef.current?.querySelector( + "[data-location-details], [data-location-details-trigger]" + ) + if (detailsSurface?.contains(activeElement)) { + widgetRef.current + ?.querySelector( + '[aria-current="true"], [aria-pressed="true"]' + ) + ?.focus() + } + } + setDetailsVisible(requestedDetailsVisible) + }, + [detailsVisible, requestedDetailsVisible] + ) + + useEffect( + function updateTimelineVisibility() { + if (requestedTimeline === renderedTimeline) return + if (!requestedTimeline) { + const activeElement = document.activeElement + const timelineSurface = widgetRef.current?.querySelector( + "[data-location-timeline]" + ) + if (timelineSurface?.contains(activeElement)) { + const focusTarget = + widgetRef.current?.querySelector( + '[aria-current="true"], [aria-pressed="true"]' + ) ?? widgetRef.current + focusTarget?.focus() + } + } + setRenderedTimeline(requestedTimeline) + }, + [renderedTimeline, requestedTimeline] + ) + + useEffect(() => { + if (locations.length === 0) { + setDetailsPanelState("responsive") + setResponsiveDetailsOpen(false) + } + }, [locations.length]) + + useEffect(() => { + const hadLocations = previouslyHadLocations.current + previouslyHadLocations.current = locations.length > 0 + if (selectedLocationId !== undefined) return + if (locations.length === 0) { + if (hadLocations && selection !== null) setSelection(null) + return + } + const selectionExists = locations.some( + (location) => location.id === selection + ) + if (selection !== null && !selectionExists) { + setSelection(locations[0].id) + return + } + if (selection === null && !hadLocations && !hasExplicitDefault) { + setSelection(locations[0].id) + } + }, [ + hasExplicitDefault, + locations, + selectedLocationId, + selection, + setSelection, + ]) + + const resolvedDensityScale = useMemo(() => { + if ( + densityScale && + Number.isFinite(densityScale.mediumAt) && + Number.isFinite(densityScale.highAt) && + densityScale.highAt > densityScale.mediumAt + ) { + return densityScale + } + return DEFAULT_DENSITY_SCALE + }, [densityScale]) + + const resolvedDensityPalette = useMemo( + () => ({ + low: resolveF0MapDensityStyle( + config.densityPalette?.low ?? f0MapDensityPalette.low + ), + medium: resolveF0MapDensityStyle( + config.densityPalette?.medium ?? f0MapDensityPalette.medium + ), + high: resolveF0MapDensityStyle( + config.densityPalette?.high ?? f0MapDensityPalette.high + ), + }), + [config.densityPalette] + ) + + const selectedLocation = locations.find( + (location) => location.id === selection + ) + const markers = useMemo( + () => + locations.map((location) => { + const density = normalizeCount(location.density) + return { + id: location.id, + coordinates: location.coordinates, + label: location.name, + ariaLabel: `${location.name} · ${config.densityLabel}: ${formatDensity(density)}`, + variant: "density", + value: density, + level: densityLevel(density, resolvedDensityScale), + style: + resolvedDensityPalette[densityLevel(density, resolvedDensityScale)], + } + }), + [ + config.densityLabel, + formatDensity, + locations, + resolvedDensityPalette, + resolvedDensityScale, + ] + ) + const resolvedMapStyle = useMemo( + () => locationMapStyle(config.mapStyle ?? f0MapStyles), + [config.mapStyle] + ) + const handleLocationSelect = (locationId: string | null) => { + setSelection(locationId) + if (detailsVisible) { + setDetailsPanelState(locationId === null ? "responsive" : "open") + } + } + const openDetails = (event: MouseEvent) => { + setDetailsPanelState("open") + if (event.detail === 0) { + queueMicrotask(() => detailsDismissRef.current?.focus()) + } + } + const closeDetails = (event: MouseEvent) => { + setDetailsPanelState("closed") + if (event.detail === 0) { + queueMicrotask(() => detailsTriggerRef.current?.focus()) + } + } + const summaryDensity = mapFallbackVisible ? "regular" : "responsive" + const detailsPanelOpen = + detailsPanelState === "open" || + (detailsPanelState === "responsive" && responsiveDetailsOpen === true) + const detailsTriggerVisible = + detailsVisible && Boolean(selectedLocation) && !detailsPanelOpen + const responsiveDetailsUnmeasured = + detailsPanelState === "responsive" && responsiveDetailsOpen === null + const timelineVisible = Boolean(renderedTimeline) + const formatSummary = (value: string | number | undefined) => + typeof value === "number" + ? formatSummaryValue(value) + : value === undefined + ? "—" + : value + + return ( +
+ + {selectedLocation + ? `${selectedLocation.name}, ${selectedLocation.detailsLabel}` + : config.selectLocationLabel} + + {summaryVisible && ( +
+ {config.summaryMetrics.map((metric) => ( + + ))} +
+ )} + + {!mapFallbackVisible && ( +
+ +
+ )} + + {mapFallbackVisible && ( +
+ + {detailsVisible && selectedLocation && ( + + )} + {renderedTimeline && ( +
+ +
+ )} +
+ )} + + {!mapFallbackVisible && locations.length === 0 && ( +
+

+ {config.noDataLabel} +

+
+ )} + {!mapFallbackVisible && ( + <> + {detailsTriggerVisible && selectedLocation && ( + + )} + {(detailsVisible || densityLegendVisible) && ( +
+ {detailsVisible && selectedLocation && ( + + )} + {densityLegendVisible && locations.length > 0 && ( + + )} +
+ )} + {renderedTimeline && ( + + )} + + )} +
+ ) +} + +LocationVisualization.displayName = "LocationVisualization" diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/components/MetricItem/MetricItem.tsx b/packages/react/src/patterns/F0AnalyticsDashboard/components/MetricItem/MetricItem.tsx index 26331dc4e1..2dbbb0abda 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/components/MetricItem/MetricItem.tsx +++ b/packages/react/src/patterns/F0AnalyticsDashboard/components/MetricItem/MetricItem.tsx @@ -196,6 +196,8 @@ export function MetricItem({ handleDelete, onAskAi, onAskAiTarget, + isFullscreen, + onFullscreenChange, }: MetricItemProps) { const enabled = item.useDashboardFilters !== false const itemFiltersKey = JSON.stringify(itemFilters?.value ?? {}) @@ -223,6 +225,8 @@ export function MetricItem({ onAskAi={onAskAi} onAskAiTarget={onAskAiTarget} itemId={item.id} + isFullscreen={isFullscreen} + onFullscreenChange={onFullscreenChange} > {data && ( { items: DashboardItem[] filters: FiltersState @@ -157,6 +167,74 @@ async function buildAllSheets( } } + if (item.type === "location") { + try { + const labels = item.location.exportLabels + const data: DashboardLocationData = await item.fetchData( + getItemFilters(item, filters) + ) + const rows = data.locations.flatMap((location) => { + if (location.details.length === 0) { + return [ + { + [LOCATION_EXPORT_KEYS.location]: location.name, + [LOCATION_EXPORT_KEYS.density]: location.density, + [LOCATION_EXPORT_KEYS.details]: location.detailsLabel, + }, + ] + } + + return location.details.map((detail) => { + const row: Record = { + [LOCATION_EXPORT_KEYS.location]: location.name, + [LOCATION_EXPORT_KEYS.density]: location.density, + [LOCATION_EXPORT_KEYS.details]: location.detailsLabel, + [LOCATION_EXPORT_KEYS.item]: detail.title, + } + if (detail.description) { + row[LOCATION_EXPORT_KEYS.description] = detail.description + } + for (const value of detail.values) { + row[`${LOCATION_DETAIL_VALUE_PREFIX}${value.label}`] = + value.value + } + return row + }) + }) + if (rows.length === 0) return null + const keys = Array.from( + new Set( + rows.flatMap((row) => + Object.keys(row).filter((key) => !key.startsWith("_")) + ) + ) + ) + const headersByKey: Record = { + [LOCATION_EXPORT_KEYS.location]: labels.location, + [LOCATION_EXPORT_KEYS.density]: labels.density, + [LOCATION_EXPORT_KEYS.details]: labels.details, + [LOCATION_EXPORT_KEYS.item]: labels.item, + [LOCATION_EXPORT_KEYS.description]: labels.description, + } + return { + name: item.title, + columns: keys.map( + (key) => + headersByKey[key] ?? + key.slice(LOCATION_DETAIL_VALUE_PREFIX.length) + ), + keys, + rows, + } + } catch (err) { + console.warn( + `[useDashboardExport] Failed to export location item "${item.title}":`, + err + ) + return null + } + } + return null } ) diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/index.ts b/packages/react/src/patterns/F0AnalyticsDashboard/index.ts index df35d0343c..f29b38267d 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/index.ts +++ b/packages/react/src/patterns/F0AnalyticsDashboard/index.ts @@ -8,11 +8,24 @@ export type { DashboardChartData, DashboardChartItem, DashboardCollectionItem, + DashboardCustomItem, DashboardItem, DashboardItemBase, DashboardItemFiltersConfig, DashboardItemFiltersDefinition, DashboardItemFiltersState, + DashboardLocationConfig, + DashboardLocationData, + DashboardLocationDetailRow, + DashboardLocationDetailValue, + DashboardLocationDetailValueTone, + DashboardLocationExportLabels, + DashboardLocationItem, + DashboardLocationPoint, + DashboardLocationSections, + DashboardLocationSummaryMetric, + DashboardLocationSummaryTone, + DashboardLocationTimelineData, DashboardMetricData, DashboardMetricItem, F0AnalyticsDashboardAskAiTarget, @@ -29,6 +42,10 @@ export type { RadarChartConfig, ScatterChartConfig, } from "./types" +export { + dashboardLocationDetailValueTones, + dashboardLocationSummaryTones, +} from "./types" /** * @experimental This is an experimental component use it at your own risk diff --git a/packages/react/src/patterns/F0AnalyticsDashboard/types.ts b/packages/react/src/patterns/F0AnalyticsDashboard/types.ts index 092271c9f0..47875682ce 100644 --- a/packages/react/src/patterns/F0AnalyticsDashboard/types.ts +++ b/packages/react/src/patterns/F0AnalyticsDashboard/types.ts @@ -1,3 +1,8 @@ +import type { ReactNode } from "react" + +import type { AvatarVariant } from "@/components/avatars/F0Avatar" +import type { F0IconProps, IconType } from "@/components/F0Icon" +import type { PendingQuote } from "@/kits/ai/F0AiChat/types" import type { ChartColorToken, F0DataChartBarSeries, @@ -9,8 +14,8 @@ import type { F0DataChartRadarSeries, F0DataChartScatterSeries, } from "@/kits/F0DataChart" -import type { PendingQuote } from "@/kits/ai/F0AiChat/types" import type { InfoHintContent } from "@/lib/InfoHint" +import type { F0MapDensityPalette, F0MapStylePair } from "@/patterns/F0Map" import type { NavigationFiltersDefinition } from "@/patterns/OneDataCollection/navigationFilters/types" import type { FiltersDefinition, @@ -288,19 +293,27 @@ export interface DashboardItemBase { * row height in the grid is `max(itemHeight)` across all items in the row, * so a single tall item makes the whole row tall. When neither * `itemHeight` nor `rowSpan` is provided, the grid falls back to a - * type-specific default (chart 336, metric 144, collection 480). + * type-specific default (chart 336, metric 144, collection 480, location or + * custom 700). * * Should be a multiple of 48 to align with the grid's snap unit, but the * field accepts any positive number for pixel-accurate persisted resizes. */ itemHeight?: number + /** + * Smallest height the designer may persist for this item, in pixels. + * Use this for host-composed bodies with a real intrinsic layout floor. + * The grid clamps initial, restored, and interactively resized rows to it. + */ + minItemHeight?: number /** Grid column position (0-based). When set, skip auto-packing. */ x?: number /** Grid row position (0-based). When set, skip auto-packing. */ y?: number /** - * Whether this item receives dashboard-level filters in its fetchData. - * When false, fetchData receives an empty object. + * Whether this item receives dashboard-level filters in `fetchData` or a + * custom item's `renderContent`. When false, the callback receives an empty + * object. * @default true */ useDashboardFilters?: boolean @@ -401,6 +414,221 @@ export interface DashboardCollectionItem< visualizations: ReadonlyArray } +// --------------------------------------------------------------------------- +// Location item +// --------------------------------------------------------------------------- + +/** Semantic treatment for one of the three summary metrics. */ +export const dashboardLocationSummaryTones = [ + "default", + "positive", + "critical", + "selected", +] as const +export type DashboardLocationSummaryTone = + (typeof dashboardLocationSummaryTones)[number] + +export const dashboardLocationDetailValueTones = [ + "default", + "positive", + "critical", +] as const +export type DashboardLocationDetailValueTone = + (typeof dashboardLocationDetailValueTones)[number] + +/** Configuration for one metric in the location item's summary strip. */ +export interface DashboardLocationSummaryMetric { + /** Key used to read this metric from `DashboardLocationData.summary`. */ + id: string + label: string + icon: IconType + /** @default "default" */ + tone?: DashboardLocationSummaryTone +} + +/** One labelled value shown at the end of a location detail row. */ +export interface DashboardLocationDetailValue { + /** Accessible label announced before the value. */ + label: string + /** Preformatted value supplied by the host. */ + value: string + icon?: IconType + iconColor?: F0IconProps["color"] + /** @default "default" */ + tone?: DashboardLocationDetailValueTone +} + +/** A domain-neutral row in the selected location panel. */ +export interface DashboardLocationDetailRow { + id: string + title: string + description?: string + avatar: AvatarVariant + /** Compact labelled values; additional values wrap within the row. */ + values: readonly DashboardLocationDetailValue[] +} + +/** A density-weighted point and the rows revealed when it is selected. */ +export interface DashboardLocationPoint { + id: string + name: string + /** `[longitude, latitude]`, resolved by the host. */ + coordinates: [number, number] + /** Host-defined value used to select the marker's density bucket. */ + density: number + /** Preformatted count or status shown below the location name. */ + detailsLabel: string + details: readonly DashboardLocationDetailRow[] +} + +/** Generic line timeline displayed along the bottom of the map. */ +export interface DashboardLocationTimelineData { + categories: readonly string[] + series: readonly F0DataChartLineSeries[] + /** Optional screen-reader summaries, one per category. */ + accessibleLabels?: readonly string[] +} + +/** Data returned by a location item's fetcher. */ +export interface DashboardLocationData { + /** Values keyed by `DashboardLocationSummaryMetric.id`. */ + summary: Readonly> + locations: readonly DashboardLocationPoint[] + /** Omit when this dataset has no meaningful timeline. */ + timeline?: DashboardLocationTimelineData +} + +/** Optional surfaces within the map-led location visualization. */ +export interface DashboardLocationSections { + /** Show the three-metric summary strip. @default true */ + summary?: boolean + /** Show the selected-location details panel and disclosure. @default true */ + locationDetails?: boolean + /** Show the density scale legend. @default true */ + densityLegend?: boolean + /** Show the timeline when timeline data also exists. @default true */ + timeline?: boolean +} + +/** Host-localized spreadsheet column labels for a location item export. */ +export interface DashboardLocationExportLabels { + /** Location-name column. */ + location: string + /** Density-value column. */ + density: string + /** Location summary column. */ + details: string + /** Detail-row title column. */ + item: string + /** Detail-row description column. */ + description: string +} + +/** Labels and visual policy shared by every response for one location item. */ +export interface DashboardLocationConfig { + /** Exactly three peer metrics rendered in the summary strip. */ + summaryMetrics: readonly [ + DashboardLocationSummaryMetric, + DashboardLocationSummaryMetric, + DashboardLocationSummaryMetric, + ] + densityLabel: string + densityLowLabel: (below: number) => string + densityMediumLabel: (from: number, below: number) => string + densityHighLabel: (from: number) => string + timelineTitle: string + timelineAriaLabel: string + mapAriaLabel: string + selectLocationLabel: string + viewLocationDetailsLabel: (locationName: string) => string + closeLocationDetailsLabel: string + noDataLabel: string + exportLabels: DashboardLocationExportLabels + /** Surface visibility. @default all sections visible */ + sections?: DashboardLocationSections + densityScale?: { + mediumAt: number + highAt: number + } + /** + * Optional F0 palette overrides by density level. Missing levels keep the + * default red heat scale. @default f0MapDensityPalette + */ + densityPalette?: Partial + /** Formats density values in marker accessibility labels and fallback rows. */ + formatDensity?: (value: number) => string + /** Formats numeric summary values. String values are displayed unchanged. */ + formatSummaryValue?: (value: number) => string + /** Optional map style pair for host-specific or deterministic maps. */ + mapStyle?: F0MapStylePair +} + +/** + * A map-led analytical widget for comparing activity or inventory by location. + * + * The item is intentionally domain-neutral: workforce events, devices, + * incidents, visitors, and other location-based datasets all use the same + * summary, point, detail-row, and timeline contract. + */ +export interface DashboardLocationItem< + Filters extends FiltersDefinition = FiltersDefinition, +> extends DashboardItemBase { + type: "location" + location: DashboardLocationConfig + /** Async data fetcher — receives dashboard filters when enabled. */ + fetchData: (filters: FiltersState) => Promise + selectedLocationId?: string | null + defaultSelectedLocationId?: string | null + onLocationSelect?: (locationId: string | null) => void + /** + * Smallest equal-width slot this item can use before the dashboard stacks + * the row. @default 720 + */ + minItemWidth?: number +} + +// --------------------------------------------------------------------------- +// Custom item +// --------------------------------------------------------------------------- + +/** + * A host-composed dashboard widget for domain-specific visualizations that do + * not fit the built-in chart, metric, or collection renderers. + * + * The dashboard still owns the standard item header, menu, edit controls, + * layout, and fullscreen behavior. The host owns only the content rendered + * inside that shell. + */ +export interface DashboardCustomItem< + Filters extends FiltersDefinition = FiltersDefinition, +> extends DashboardItemBase { + type: "custom" + /** + * Allows this custom item to share a row with one peer widget. + * + * Custom items reserve a full-width row by default because their host-owned + * content can have an intrinsic minimum width. Enable this only when the + * custom body has explicit responsive states for a half-width dashboard + * slot. Rows containing a shareable custom item are capped at two items, so + * later metrics cannot squeeze the custom body below half width. + * + * @default false + */ + allowRowSharing?: boolean + /** + * Smallest equal-width slot this custom item can use while sharing a row, + * in pixels. When the available slot would be narrower, the dashboard + * stacks every item in that row instead. Ignored unless + * `allowRowSharing` is true. + */ + minItemWidth?: number + /** + * Renders the item body with the dashboard's currently applied filters. + * When `useDashboardFilters` is false, receives an empty object. + */ + renderContent: (filters: FiltersState) => ReactNode +} + // --------------------------------------------------------------------------- // Item union — discriminated on `type` // --------------------------------------------------------------------------- @@ -408,8 +636,8 @@ export interface DashboardCollectionItem< /** * A single dashboard item. Discriminated on `type`. * - * Currently supports `"chart"`, `"metric"`, and `"collection"`. - * Future types (e.g. `"custom"`) extend this union. + * Supports built-in chart, metric, collection, and location renderers plus a + * host-composed custom body that keeps the standard dashboard item shell. */ export type DashboardItem< Filters extends FiltersDefinition = FiltersDefinition, @@ -417,6 +645,8 @@ export type DashboardItem< | DashboardChartItem | DashboardMetricItem | DashboardCollectionItem + | DashboardLocationItem + | DashboardCustomItem /** Report-style definitions accepted by a dashboard item's filter control. */ export type DashboardItemFiltersDefinition = @@ -517,10 +747,11 @@ export type F0AnalyticsDashboardAskAiTargetWithQuote = * * The entire dashboard is defined declaratively via `filters` (optional shared * filter definitions), `presets`, and `items` (an ordered array of chart / - * collection configs). + * collection / location configs). * - * An LLM can generate the full `items` array as JSON (minus the `fetchData` - * functions) to build dashboards on the fly. + * An LLM can generate the declarative configuration for built-in items; + * hosts attach data functions, localized formatters, icons, and any custom + * item renderers. */ export interface F0AnalyticsDashboardProps< Filters extends FiltersDefinition = FiltersDefinition, diff --git a/packages/react/src/patterns/F0Map/F0Map.tsx b/packages/react/src/patterns/F0Map/F0Map.tsx index a57c5a7238..2476bb503f 100644 --- a/packages/react/src/patterns/F0Map/F0Map.tsx +++ b/packages/react/src/patterns/F0Map/F0Map.tsx @@ -149,6 +149,8 @@ export interface F0MapProps extends WithDataTestIdProps { projection?: F0MapProjection /** Show the skeleton instead of the map. */ loading?: boolean + /** Reports when the accessible HTML list replaces an unavailable WebGL map. */ + onFallbackChange?: (visible: boolean) => void /** Accessible label for the map region. */ ariaLabel?: string /** @private */ @@ -224,6 +226,7 @@ const F0MapBase = forwardRef(function F0Map( fullScreen = false, projection = "mercator", loading = false, + onFallbackChange, ariaLabel, dataTestId, className, @@ -240,6 +243,11 @@ const F0MapBase = forwardRef(function F0Map( const [tileError, setTileError] = useState(false) const listId = useId() const reduceMotion = useReducedMotion() + const fallbackVisible = !loading && webglFailed + + useEffect(() => { + onFallbackChange?.(fallbackVisible) + }, [fallbackVisible, onFallbackChange]) // Dark detection needs a callback ref: with `loading` the container doesn't // exist on mount, and a plain RefObject effect would never re-run. @@ -442,7 +450,17 @@ const F0MapBase = forwardRef(function F0Map( const handleBackgroundClick = () => selectRef.current(null) map.on("click", handleBackgroundClick) + // The map can mount before a container-query layout reaches its final + // dimensions. Keep MapLibre's canvas aligned when the host surface changes + // size (dashboard grids, drawers, and responsive split panes all do this). + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => map.resize()) + resizeObserver?.observe(container) + return () => { + resizeObserver?.disconnect() mapRef.current = null setMapInstance(null) map.remove() @@ -514,12 +532,14 @@ const F0MapBase = forwardRef(function F0Map( > {/* Keyboard: jump past the opaque canvas straight to the operable list. Shown only while focused. */} - - {i18n.map.skipToList} - + {!fallbackVisible && ( + + {i18n.map.skipToList} + + )} {/* Announces the marker count to screen readers when it changes. */}
{`${markers.length} ${ @@ -601,7 +621,7 @@ const F0MapBase = forwardRef(function F0Map( points={markers} selectedId={selectedId} onSelect={handleListSelect} - visible={webglFailed} + visible={fallbackVisible} />
)} diff --git a/packages/react/src/patterns/F0Map/__stories__/F0Map.mdx b/packages/react/src/patterns/F0Map/__stories__/F0Map.mdx index 0de3d82063..eb25f2bf88 100644 --- a/packages/react/src/patterns/F0Map/__stories__/F0Map.mdx +++ b/packages/react/src/patterns/F0Map/__stories__/F0Map.mdx @@ -81,8 +81,9 @@ A Map is composed of: 2. **Markers** - DOM pins from your `markers`, one per record, by variant. 3. **Clusters** - nearby markers gathered into a pile that expands on click. 4. **Controls** - locate, fit-to-markers, zoom in / out (bottom-left). -5. **List** - a synchronized, screen-reader-operable list of every marker, - which also becomes the visible fallback if the map can't render. +5. **List** - a synchronized, keyboard- and screen-reader-operable list of + every marker, which appears over the map when keyboard focus enters it and + becomes the visible fallback if the map can't render. --- @@ -96,11 +97,25 @@ size, label placement, selection) stays uniform across the map. - **`company`** - a company avatar. - **`default`** - a plain colored pin. - **`stop`** - a lettered route stop (A, B, C…) matching the route/arc lines. +- **`density`** - an aggregate marker that requires a numeric `value` and a + `level` of `low`, `medium`, or `high` for the Factorial heat scale. The + levels use one sequential red scale: the Factorial critical tint, `red.50`, + and `red.70`. Composing analytical patterns may pass a `style` with an F0 + `color` and `colorStep`; raw CSS colors are not accepted. Count ink is + resolved from the actual opaque palette tone. If the requested step cannot + reach AA contrast with sanctioned F0 ink, it resolves to the nearest + accessible step of the same hue. Use that resolved palette for markers and + any accompanying legend. + Values are rounded, negative or non-finite values become `0`, and visible + counts above 99 are capped at `99+`. Keep `label` concise for the map and use + `ariaLabel` when the operable list needs richer location-and-count context. - **`current_location`** - the "you are here" dot (see below). Selecting a marker grows it to a pinned, lifted state; neighbors it would cover fold to a dot until it's deselected. + + --- ## Behaviors @@ -262,17 +277,18 @@ Clustering is automatic (no prop) - see [Clustering](#clustering). ### Presentation & interaction -| Prop | Type | Default | Description | -| --------------------- | --------------------------- | --------------- | --------------------------------------------------------- | -| `mapStyle` | `F0MapStylePair` | f0 styles | Light/dark style pair override. | -| `interactive` | `boolean` | `true` | Allow pan/zoom. | -| `gestureHandling` | `"cooperative" \| "greedy"` | `"cooperative"` | Cooperative requires Ctrl/⌘ + wheel to zoom (embed-safe). | -| `fullScreen` | `boolean` | `false` | `false` frames as a card; `true` is edge-to-edge. | -| `projection` | `"mercator" \| "globe"` | `"mercator"` | `"globe"` renders the world as a 3D sphere. | -| `showControls` | `boolean` | `true` | Show the locate / fit / zoom toolbar (when interactive). | -| `showCurrentLocation` | `boolean` | `false` | Show the "you are here" dot when location is granted. | -| `loading` | `boolean` | `false` | Show the skeleton instead of the map. | -| `ariaLabel` | `string` | `"Map"` (i18n) | Accessible name for the map region. | +| Prop | Type | Default | Description | +| --------------------- | ---------------------------- | --------------- | --------------------------------------------------------- | +| `mapStyle` | `F0MapStylePair` | f0 styles | Light/dark style pair override. | +| `interactive` | `boolean` | `true` | Allow pan/zoom. | +| `gestureHandling` | `"cooperative" \| "greedy"` | `"cooperative"` | Cooperative requires Ctrl/⌘ + wheel to zoom (embed-safe). | +| `fullScreen` | `boolean` | `false` | `false` frames as a card; `true` is edge-to-edge. | +| `projection` | `"mercator" \| "globe"` | `"mercator"` | `"globe"` renders the world as a 3D sphere. | +| `showControls` | `boolean` | `true` | Show the locate / fit / zoom toolbar (when interactive). | +| `showCurrentLocation` | `boolean` | `false` | Show the "you are here" dot when location is granted. | +| `loading` | `boolean` | `false` | Show the skeleton instead of the map. | +| `onFallbackChange` | `(visible: boolean) => void` | — | Reports when the HTML list replaces unavailable WebGL. | +| `ariaLabel` | `string` | `"Map"` (i18n) | Accessible name for the map region. | ### Imperative handle (`ref`) diff --git a/packages/react/src/patterns/F0Map/__stories__/F0Map.stories.tsx b/packages/react/src/patterns/F0Map/__stories__/F0Map.stories.tsx index 4048e01445..3f42717709 100644 --- a/packages/react/src/patterns/F0Map/__stories__/F0Map.stories.tsx +++ b/packages/react/src/patterns/F0Map/__stories__/F0Map.stories.tsx @@ -1,9 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite" + import { expect, userEvent, within } from "storybook/test" -import { F0Map } from "../F0Map" +import { withSnapshot } from "@/lib/storybook-utils/parameters" + import type { F0MapPoint } from "../types" +import { F0MapMarker } from "../components/F0MapMarker" +import { F0Map } from "../F0Map" + // Barcelona-area points: the four product-semantic marker variants. const BARCELONA: F0MapPoint[] = [ { @@ -80,6 +85,7 @@ const meta = { selectedMarkerId: { table: { disable: true } }, defaultSelectedMarkerId: { table: { disable: true } }, onMarkerSelect: { table: { disable: true } }, + onFallbackChange: { table: { disable: true } }, fitToMarkers: { table: { disable: true } }, }, } satisfies Meta @@ -159,12 +165,46 @@ const ALL_VARIANTS: F0MapPoint[] = [ letter: "A", label: "Stop", }, + { + id: "density-low", + coordinates: [-1.13, 37.99], + variant: "density", + value: 4, + level: "low", + label: "Low density", + }, + { + id: "density-medium", + coordinates: [-0.88, 41.65], + variant: "density", + value: 12, + level: "medium", + label: "Medium density", + }, + { + id: "density-high", + coordinates: [-2.93, 43.26], + variant: "density", + value: 42, + level: "high", + label: "High density", + }, + { + id: "density-custom", + coordinates: [-3.7, 40.42], + variant: "density", + value: 28, + level: "high", + style: { color: "purple", colorStep: 70 }, + label: "Custom density palette", + }, ] /** * Every product-semantic marker variant on one map: `default`, `workplace`, - * `employee`, `company` and `stop`. Workplace and company render as rounded - * squares (the entity shape); default, employee and stop stay circular. + * `employee`, `company`, `stop` and default/custom `density`. Workplace and + * company render as rounded squares (the entity shape); the remaining + * variants stay circular. */ export const MarkerVariants: Story = { args: { markers: ALL_VARIANTS }, @@ -375,13 +415,54 @@ export const Loading: Story = { /** * Chromatic visual-regression target. The live map is non-deterministic - * (WebGL + network tiles), so this snapshots the deterministic skeleton - the - * one stable frame worth regressing. Re-enables Chromatic (disabled on the meta - * for the map stories). + * (WebGL + network tiles), so this snapshots the deterministic skeleton and a + * static light and dark rows of the three density levels. Re-enables Chromatic + * (disabled on the meta for the map stories). */ export const Snapshot: Story = { args: { loading: true }, - parameters: { chromatic: { disableSnapshot: false } }, + parameters: withSnapshot({}), + render: (args) => ( +
+
+ + + + +
+
+ + + + +
+
+ +
+
+ ), } /** diff --git a/packages/react/src/patterns/F0Map/__tests__/F0Map.test.tsx b/packages/react/src/patterns/F0Map/__tests__/F0Map.test.tsx index 0ed3e4f787..dc5931d08b 100644 --- a/packages/react/src/patterns/F0Map/__tests__/F0Map.test.tsx +++ b/packages/react/src/patterns/F0Map/__tests__/F0Map.test.tsx @@ -1,7 +1,12 @@ import { createRef } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { fireEvent, screen, zeroRender as render } from "@/testing/test-utils" +import { + fireEvent, + screen, + waitFor, + zeroRender as render, +} from "@/testing/test-utils" import { F0Map, type F0MapHandle } from "../F0Map" import type { F0MapArc, F0MapPoint, F0MapRoute } from "../types" @@ -11,6 +16,7 @@ import type { F0MapArc, F0MapPoint, F0MapRoute } from "../types" // a machine without WebGL (the map constructor throwing). const mock = vi.hoisted(() => { const instances: MockMap[] = [] + const markerElements: HTMLElement[] = [] const state = { throwOnCreate: false } class MockMap { @@ -23,6 +29,7 @@ const mock = vi.hoisted(() => { fitBounds: [] as unknown[], setStyle: [] as unknown[], setProjection: [] as unknown[], + resize: 0, zoomIn: 0, zoomOut: 0, } @@ -61,7 +68,9 @@ const mock = vi.hoisted(() => { return this } remove() {} - resize() {} + resize() { + this.calls.resize++ + } setStyle(s: unknown) { this.calls.setStyle.push(s) } @@ -139,6 +148,14 @@ const mock = vi.hoisted(() => { } } class MockMarker { + constructor({ element }: { element: HTMLElement }) { + // Match the accessibility attributes MapLibre adds to custom marker + // wrappers so the component has to remove them in this test too. + element.setAttribute("role", "button") + element.setAttribute("tabindex", "0") + element.setAttribute("aria-label", "Map marker") + markerElements.push(element) + } setLngLat() { return this } @@ -156,6 +173,7 @@ const mock = vi.hoisted(() => { return { instances, + markerElements, state, Map: MockMap, Marker: MockMarker, @@ -202,6 +220,7 @@ const LINE_LAYERS = ["f0-map-lines-solid", "f0-map-lines-dashed"] describe("F0Map", () => { beforeEach(() => { mock.instances.length = 0 + mock.markerElements.length = 0 mock.state.throwOnCreate = false }) @@ -221,6 +240,60 @@ describe("F0Map", () => { expect(screen.getByRole("button", { name: "Office" })).toBeInTheDocument() }) + it("removes MapLibre button semantics from visual marker wrappers", async () => { + render() + + await waitFor(() => expect(mock.markerElements).toHaveLength(2)) + for (const marker of mock.markerElements) { + expect(marker).toHaveAttribute("aria-hidden", "true") + expect(marker).not.toHaveAttribute("role") + expect(marker).not.toHaveAttribute("tabindex") + expect(marker).not.toHaveAttribute("aria-label") + } + }) + + it("normalizes an unlabeled density marker in the accessible list", () => { + render( + + ) + + expect( + screen.getByRole("button", { name: "Location · 99+" }) + ).toBeInTheDocument() + }) + + it("keeps a concise map label while exposing richer list context", () => { + render( + + ) + + expect( + screen.getByRole("button", { name: "Barcelona HQ · Density: 42" }) + ).toBeInTheDocument() + }) + it("announces the marker count in a live region", () => { render() expect(screen.getByRole("status")).toHaveTextContent("2 locations") @@ -232,6 +305,18 @@ describe("F0Map", () => { const list = screen.getByRole("navigation", { name: "Locations" }) expect(skip.getAttribute("href")).toBe(`#${list.id}`) }) + + it("removes the skip link when the visible fallback already owns focus", () => { + mock.state.throwOnCreate = true + render() + + expect( + screen.queryByRole("link", { name: /skip to location list/i }) + ).not.toBeInTheDocument() + expect( + screen.getByRole("navigation", { name: "Locations" }) + ).toBeVisible() + }) }) describe("imperative handle", () => { @@ -268,6 +353,57 @@ describe("F0Map", () => { }) }) + describe("container resizing", () => { + it("resizes the map and disconnects its observer on unmount", () => { + const observe = vi.fn() + const disconnect = vi.fn() + let resizeCallback: ResizeObserverCallback | undefined + const originalResizeObserver = Object.getOwnPropertyDescriptor( + globalThis, + "ResizeObserver" + ) + + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + + observe = observe + unobserve = vi.fn() + disconnect = disconnect + } + + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + writable: true, + value: TestResizeObserver, + }) + + try { + const { unmount } = render() + const map = mock.instances[0] + const resizeCallsBeforeObserver = map.calls.resize + + resizeCallback?.([], {} as ResizeObserver) + + expect(observe).toHaveBeenCalledOnce() + expect(map.calls.resize).toBe(resizeCallsBeforeObserver + 1) + unmount() + expect(disconnect).toHaveBeenCalledOnce() + } finally { + if (originalResizeObserver) { + Object.defineProperty( + globalThis, + "ResizeObserver", + originalResizeObserver + ) + } else { + Reflect.deleteProperty(globalThis, "ResizeObserver") + } + } + }) + }) + describe("list interaction", () => { it("activating a list item selects that marker", () => { const onMarkerSelect = vi.fn() @@ -288,6 +424,33 @@ describe("F0Map", () => { expect(list).not.toHaveClass("sr-only") expect(screen.getByRole("button", { name: "HQ" })).toBeInTheDocument() }) + + it("reports when the visible fallback replaces the map", async () => { + const onFallbackChange = vi.fn() + mock.state.throwOnCreate = true + + const result = render( + + ) + + await waitFor(() => + expect(onFallbackChange).toHaveBeenLastCalledWith(true) + ) + + result.rerender( + + ) + await waitFor(() => + expect(onFallbackChange).toHaveBeenLastCalledWith(false) + ) + + mock.state.throwOnCreate = false + result.rerender( + + ) + await waitFor(() => expect(mock.instances).toHaveLength(1)) + expect(onFallbackChange).toHaveBeenLastCalledWith(false) + }) }) describe("routes & arcs", () => { diff --git a/packages/react/src/patterns/F0Map/components/F0MapList/F0MapList.tsx b/packages/react/src/patterns/F0Map/components/F0MapList/F0MapList.tsx index e08fa65710..ef888381f4 100644 --- a/packages/react/src/patterns/F0Map/components/F0MapList/F0MapList.tsx +++ b/packages/react/src/patterns/F0Map/components/F0MapList/F0MapList.tsx @@ -2,12 +2,14 @@ import { forwardRef } from "react" import { DataTestIdWrapper, type WithDataTestIdProps } from "@/lib/data-testid" import { useI18n } from "@/lib/providers/i18n" -import { cn } from "@/lib/utils" +import { cn, focusRing } from "@/lib/utils" import type { F0MapPoint } from "../../types" +import { formatF0MapDensityValue } from "../F0MapMarker/F0MapMarker" /** Human-readable name for a point, for the accessible list / fallback. */ const pointLabel = (p: F0MapPoint, fallback: string): string => { + if (p.ariaLabel) return p.ariaLabel if (p.label) return p.label switch (p.variant) { case "employee": @@ -16,6 +18,8 @@ const pointLabel = (p: F0MapPoint, fallback: string): string => { return p.name case "stop": return p.letter.charAt(0).toUpperCase() + case "density": + return `${fallback} · ${formatF0MapDensityValue(p.value)}` default: return fallback } @@ -30,7 +34,7 @@ export interface F0MapListProps extends WithDataTestIdProps { * screen-reader-only - present in the DOM and operable, but not shown. */ visible?: boolean - /** Accessible name for the list landmark (and heading when visible). */ + /** Accessible name for the list landmark and visible list label. */ label?: string /** Anchor id, so a "skip to list" link can move focus here. */ id?: string @@ -71,17 +75,15 @@ export const F0MapList = forwardRef( className={cn( "outline-none", visible - ? "absolute inset-0 z-20 overflow-auto bg-f1-background p-4" - : "sr-only", + ? "absolute inset-0 z-30 overflow-auto bg-f1-background p-4" + : "sr-only focus-within:not-sr-only focus-within:absolute focus-within:inset-0 focus-within:z-30 focus-within:overflow-auto focus-within:bg-f1-background focus-within:p-4", className )} > - {visible && ( -

- {listLabel} -

- )} -
    +

    + {listLabel} +

    +
      {points.map((p) => (
    • + presentational ? ( + { + if (event.button === 0) onClick?.() + }} + className={cn( + "relative inline-flex cursor-pointer bg-transparent p-0", + "transition-transform duration-150 hover:scale-[1.05]", + className + )} + > + {body} + + ) : ( + + ) ) : ( {body} diff --git a/packages/react/src/patterns/F0Map/components/internal/BaseMapMarker/__tests__/BaseMapMarker.test.tsx b/packages/react/src/patterns/F0Map/components/internal/BaseMapMarker/__tests__/BaseMapMarker.test.tsx new file mode 100644 index 0000000000..76fef6ccbb --- /dev/null +++ b/packages/react/src/patterns/F0Map/components/internal/BaseMapMarker/__tests__/BaseMapMarker.test.tsx @@ -0,0 +1,251 @@ +import { baseColors } from "@factorialco/f0-core" +import { describe, expect, it, vi } from "vitest" + +import { fireEvent, screen, zeroRender as render } from "@/testing/test-utils" + +import { resolveF0MapDensityStyle } from "../../../F0MapMarker" +import { + BaseMapMarker, + countForegroundContrast, + countForegroundColor, + markerColors, + markerColorSteps, +} from "../BaseMapMarker" + +const hslToLuminance = (triplet: string) => { + const [hue, saturation, lightness] = triplet + .split("/")[0] + .trim() + .split(/\s+/) + .map((part) => Number.parseFloat(part)) + const saturationRatio = saturation / 100 + const lightnessRatio = lightness / 100 + const chroma = (1 - Math.abs(2 * lightnessRatio - 1)) * saturationRatio + const hueSection = (((hue % 360) + 360) % 360) / 60 + const intermediate = chroma * (1 - Math.abs((hueSection % 2) - 1)) + const [red, green, blue] = + hueSection < 1 + ? [chroma, intermediate, 0] + : hueSection < 2 + ? [intermediate, chroma, 0] + : hueSection < 3 + ? [0, chroma, intermediate] + : hueSection < 4 + ? [0, intermediate, chroma] + : hueSection < 5 + ? [intermediate, 0, chroma] + : [chroma, 0, intermediate] + const match = lightnessRatio - chroma / 2 + const linearize = (channel: number) => { + const value = channel + match + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + } + return ( + 0.2126 * linearize(red) + + 0.7152 * linearize(green) + + 0.0722 * linearize(blue) + ) +} + +const contrastRatio = (first: string, second: string) => { + const lighter = Math.max(hslToLuminance(first), hslToLuminance(second)) + const darker = Math.min(hslToLuminance(first), hslToLuminance(second)) + return (lighter + 0.05) / (darker + 0.05) +} + +describe("BaseMapMarker", () => { + it("renders the complete count and applies the requested palette step", () => { + render( + + ) + + const count = screen.getByText("42") + expect(count).toBeInTheDocument() + expect(count.parentElement).toHaveStyle({ + backgroundColor: "hsl(216 48% 44%)", + }) + }) + + it("uses contrast-aware count ink across palette steps", () => { + const { rerender } = render( + + ) + + expect(screen.getByText("4").parentElement).toHaveStyle({ + backgroundColor: "hsl(var(--neutral-0))", + }) + expect(screen.getByText("4").parentElement?.style.boxShadow).toContain( + "hsl(5 100% 65% / 0.1)" + ) + expect(screen.getByText("4")).toHaveStyle({ + color: "hsl(var(--neutral-100))", + }) + + rerender( + + ) + expect(screen.getByText("12")).toHaveStyle({ + color: countForegroundColor("red", 50), + }) + + rerender( + + ) + expect(screen.getByText("22")).toHaveStyle({ + color: "hsl(var(--white-100))", + }) + }) + + it("keeps every opaque palette count at AA text contrast", () => { + const paletteColors = markerColors.filter( + (color) => color !== "neutral" && color !== "grey" + ) + const opaqueSteps = markerColorSteps.filter((step) => step !== 10) + + for (const color of paletteColors) { + for (const step of opaqueSteps) { + const resolved = resolveF0MapDensityStyle({ color, colorStep: step }) + const foreground = countForegroundColor( + resolved.color, + resolved.colorStep + ) + const foregroundTriplet = foreground.includes("white") + ? baseColors.white[100] + : baseColors.grey[100] + expect([ + "hsl(var(--white-100))", + `hsl(${baseColors.grey[100]})`, + ]).toContain(foreground) + expect( + contrastRatio( + baseColors[resolved.color][resolved.colorStep], + foregroundTriplet + ), + `${color}.${step}` + ).toBeGreaterThanOrEqual(4.5) + expect( + countForegroundContrast(resolved.color, resolved.colorStep), + `${color}.${step}` + ).toBeGreaterThanOrEqual(4.5) + } + expect(countForegroundColor(color, 10)).toBe("hsl(var(--neutral-100))") + expect(countForegroundContrast(color, 10)).toBeGreaterThanOrEqual(4.5) + } + }) + + it("uses semantic ink without a glow for count labels", () => { + render( + + ) + + const label = screen.getByText("Barcelona HQ") + expect(label).toHaveClass("text-f1-foreground") + expect(label.style.color).toBe("") + expect(label.style.textShadow).toBe("") + }) + + it("uses its visible label and count as the image name", () => { + const { rerender } = render( + + ) + + expect(screen.getByRole("img", { name: "Barcelona HQ, 39" })).toBeVisible() + + rerender() + expect(screen.getByRole("img", { name: "39" })).toBeVisible() + }) + + it("uses an explicit accessible name in preference to visible content", () => { + render( + + ) + + expect( + screen.getByRole("img", { + name: "Barcelona headquarters, high density, 39 clock-ins", + }) + ).toBeVisible() + }) + + it("uses the visible label for non-count markers", () => { + const { rerender } = render( + + ) + + expect(screen.getByRole("img", { name: "Madrid office" })).toBeVisible() + + rerender() + expect(screen.queryByRole("img")).not.toBeInTheDocument() + expect(document.querySelector("[aria-hidden='true']")).toBeVisible() + }) + + it("names an interactive marker from its visible content", () => { + const onClick = vi.fn() + render( + + ) + + const marker = screen.getByRole("button", { name: "Barcelona HQ, 39" }) + fireEvent.click(marker) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it("keeps route-stop letters limited to one uppercase character", () => { + render() + expect(screen.getByText("A")).toBeInTheDocument() + expect(screen.queryByText("AB")).not.toBeInTheDocument() + }) + + it("keeps presentational pointer targets out of the accessibility tree", () => { + const onClick = vi.fn() + const { container } = render( + + ) + + const marker = screen.getByText("12").closest("[aria-hidden='true']") + expect(marker).toHaveProperty("tagName", "SPAN") + expect(marker).not.toHaveAttribute("tabindex") + expect(screen.queryByRole("button")).not.toBeInTheDocument() + + const pointerTarget = container.querySelector("span.cursor-pointer") + if (!(pointerTarget instanceof HTMLElement)) { + throw new Error("Expected a presentational pointer target") + } + fireEvent( + pointerTarget, + new MouseEvent("pointerup", { button: 0, bubbles: true }) + ) + expect(onClick).toHaveBeenCalledTimes(1) + + fireEvent( + pointerTarget, + new MouseEvent("pointerup", { button: 2, bubbles: true }) + ) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react/src/patterns/F0Map/components/internal/BaseMapMarker/index.ts b/packages/react/src/patterns/F0Map/components/internal/BaseMapMarker/index.ts index f5202b2884..00c3fb9d7b 100644 --- a/packages/react/src/patterns/F0Map/components/internal/BaseMapMarker/index.ts +++ b/packages/react/src/patterns/F0Map/components/internal/BaseMapMarker/index.ts @@ -1,12 +1,16 @@ export { BaseMapMarker, + countForegroundContrast, + countForegroundColor, getMarkerMetrics, getSelectedHeadGroupY, markerSizes, markerColors, + markerColorSteps, markerVariants, markerLabelPlacements, markerColorTriplet, + markerFillStyle, MARKER_SHADOW_HSL, SELECTED_DOT_R, SELECTED_DOT_GAP, @@ -19,6 +23,7 @@ export type { BaseMapMarkerEffectiveSize, BaseMapMarkerSize, BaseMapMarkerColor, + BaseMapMarkerColorStep, BaseMapMarkerVariant, BaseMapMarkerLabelPlacement, } from "./BaseMapMarker" diff --git a/packages/react/src/patterns/F0Map/components/internal/F0MapCluster/F0MapCluster.tsx b/packages/react/src/patterns/F0Map/components/internal/F0MapCluster/F0MapCluster.tsx index e4d7ad1118..b2340ce415 100644 --- a/packages/react/src/patterns/F0Map/components/internal/F0MapCluster/F0MapCluster.tsx +++ b/packages/react/src/patterns/F0Map/components/internal/F0MapCluster/F0MapCluster.tsx @@ -50,13 +50,27 @@ export interface F0MapClusterProps extends WithDataTestIdProps { members: F0MapMarkerVariantProps[] onClick?: () => void ariaLabel?: string + /** + * Render outside the tab order and accessibility tree while preserving the + * pointer target. F0Map uses this for canvas clusters because F0MapList is + * the single operable representation of map content. + */ + presentational?: boolean /** @private */ className?: string } const F0MapClusterBase = forwardRef( function F0MapCluster( - { count, members, onClick, ariaLabel, dataTestId, className }, + { + count, + members, + onClick, + ariaLabel, + presentational = false, + dataTestId, + className, + }, ref ) { const i18n = useI18n() @@ -85,20 +99,29 @@ const F0MapClusterBase = forwardRef(
      { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - onClick?.() - } - }} + onKeyDown={ + presentational + ? undefined + : (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onClick?.() + } + } + } onPointerEnter={() => setActive(true)} onPointerLeave={() => setActive(false)} - onFocus={() => setActive(true)} - onBlur={() => setActive(false)} + onFocus={presentational ? undefined : () => setActive(true)} + onBlur={presentational ? undefined : () => setActive(false)} // `group`: the div is the focusable element but it is 0x0, so the // focus ring renders on the sized target span below via // `group-focus-visible:` (a ring on the div itself would be invisible). @@ -126,7 +149,7 @@ const F0MapClusterBase = forwardRef( className="absolute left-0 top-0 flex leading-none" style={{ zIndex: i, ...place(i) }} > - + ))} {/* Overflow counter: f0's avatar-list "+N" circle (secondary surface, diff --git a/packages/react/src/patterns/F0Map/components/internal/F0MapCluster/__tests__/F0MapCluster.test.tsx b/packages/react/src/patterns/F0Map/components/internal/F0MapCluster/__tests__/F0MapCluster.test.tsx new file mode 100644 index 0000000000..2a2d85edd9 --- /dev/null +++ b/packages/react/src/patterns/F0Map/components/internal/F0MapCluster/__tests__/F0MapCluster.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest" + +import { fireEvent, screen, zeroRender } from "@/testing/test-utils" + +import { F0MapCluster } from "../F0MapCluster" + +const members = [{ variant: "default" }] as const + +describe("F0MapCluster", () => { + it("is keyboard-operable when rendered as a standalone cluster", () => { + const onClick = vi.fn() + + zeroRender() + + const cluster = screen.getByRole("button", { + name: "Cluster of 1 locations", + }) + fireEvent.keyDown(cluster, { key: "Enter" }) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it("keeps a map-managed visual cluster out of the accessibility tree", () => { + const { container } = zeroRender( + + ) + + const cluster = container.querySelector('[aria-hidden="true"]') + expect(cluster).toBeInTheDocument() + expect(cluster).not.toHaveAttribute("role") + expect(cluster).not.toHaveAttribute("tabindex") + expect(cluster?.querySelectorAll("button, [tabindex]")).toHaveLength(0) + }) +}) diff --git a/packages/react/src/patterns/F0Map/index.tsx b/packages/react/src/patterns/F0Map/index.tsx index 2e48d84c66..e9f1e24c21 100644 --- a/packages/react/src/patterns/F0Map/index.tsx +++ b/packages/react/src/patterns/F0Map/index.tsx @@ -15,12 +15,25 @@ export type { } from "./types" /** * Product-semantic marker variant identity, used to describe a point's marker - * (`default` / `workplace` / `employee` / `company` / `stop`). `F0Map` owns - * rendering markers for its points, so the marker component itself is internal - * and not exposed - see the `MarkerVariants` story for how each one looks. + * (`default` / `workplace` / `employee` / `company` / `stop` / `density`). + * `F0Map` owns rendering markers for its points, so the marker component itself + * is internal and not exposed - see the `MarkerVariants` story for each one. */ -export { f0MapMarkerVariants } from "./components/F0MapMarker" +export { + f0MapDensityColorSteps, + f0MapDensityColors, + f0MapDensityLevels, + f0MapDensityPalette, + f0MapDensitySurfaceStyle, + f0MapMarkerVariants, + resolveF0MapDensityStyle, +} from "./components/F0MapMarker" export type { + F0MapDensityColor, + F0MapDensityColorStep, + F0MapDensityLevel, + F0MapDensityPalette, + F0MapDensityStyle, F0MapMarkerVariant, F0MapMarkerVariantProps, } from "./components/F0MapMarker" diff --git a/packages/react/src/patterns/F0Map/types.ts b/packages/react/src/patterns/F0Map/types.ts index c1da5c0841..3cb05e065b 100644 --- a/packages/react/src/patterns/F0Map/types.ts +++ b/packages/react/src/patterns/F0Map/types.ts @@ -64,5 +64,11 @@ export type F0MapPoint = { id: string /** `[longitude, latitude]`. */ coordinates: [number, number] + /** Concise visual label rendered beside the marker. */ label?: string + /** + * Optional richer name for the operable HTML list. Defaults to `label` or + * the variant's localized fallback. + */ + ariaLabel?: string } & F0MapMarkerVariantProps