+### 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**
+
+
+
+
+
+
Situation
+
Use 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**
+
+
+
+
+
+
Situation
+
Use instead
+
+
+
+
+
Coordinates are incidental or unavailable
+
A chart or collection dashboard item.
+
+
+
Records do not share one meaningful density measure
+
A chart dashboard item with explicit series.
+
+
+
The visualization has no reusable location contract
+
A 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.
+
+
+
+
+
+
Field
+
Type
+
Default
+
Required
+
Description
+
+
+
+
+
+ id
+
+
+ string
+
+
—
+
Yes
+
Stable item and layout identity.
+
+
+
+ title
+
+
+ string
+
+
—
+
Yes
+
Standard dashboard item heading.
+
+
+
+ type
+
+
+ "location"
+
+
—
+
Yes
+
Selects 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>
+
+
—
+
Yes
+
Fetches the generic location dataset.
+
+
+
+ selectedLocationId
+
+
+ string | null
+
+
—
+
—
+
Controlled selection.
+
+
+
+ defaultSelectedLocationId
+
+
+ string | null
+
+
First location
+
—
+
First 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**
+
+
+
+
+
+
Field
+
Type
+
Default
+
Required
+
Description
+
+
+
+
+
+ summaryMetrics
+
+
+ Tuple of three DashboardLocationSummaryMetric
+
+
—
+
Yes
+
Keys, labels, icons, and tones for the summary strip.
+
+
+
+ densityLabel
+
+
+ string
+
+
—
+
Yes
+
Names the value encoded by marker color.
+
+
+
+ densityLowLabel
+
+
+ (below) => string
+
+
—
+
Yes
+
Formats the low legend range.
+
+
+
+ densityMediumLabel
+
+
+ (from, below) => string
+
+
—
+
Yes
+
Formats the medium legend range.
+
+
+
+ densityHighLabel
+
+
+ (from) => string
+
+
—
+
Yes
+
Formats the high legend range.
+
+
+
+ timelineTitle
+
+
+ string
+
+
—
+
Yes
+
Visible timeline title.
+
+
+
+ timelineAriaLabel
+
+
+ string
+
+
—
+
Yes
+
Names the timeline text alternative.
+
+
+
+ mapAriaLabel
+
+
+ string
+
+
—
+
Yes
+
Names the map and its fallback region.
+
+
+
+ selectLocationLabel
+
+
+ string
+
+
—
+
Yes
+
Empty-selection instruction.
+
+
+
+ viewLocationDetailsLabel
+
+
+ (name) => string
+
+
—
+
Yes
+
Accessible disclosure label.
+
+
+
+ closeLocationDetailsLabel
+
+
+ string
+
+
—
+
Yes
+
Accessible close label.
+
+
+
+ noDataLabel
+
+
+ string
+
+
—
+
Yes
+
Empty-state copy.
+
+
+
+ exportLabels
+
+
+ DashboardLocationExportLabels
+
+
—
+
Yes
+
Host-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 locale
+
—
+
Browser-locale number formatting.
+
+
+
+ formatSummaryValue
+
+
+ (value) => string
+
+
Browser locale
+
—
+
Browser-locale number formatting.
+
+
+
+ mapStyle
+
+
+ F0MapStylePair
+
+
F0 light/dark map styles
+
—
+
Overrides the default light and dark map styles.
+
+
+
+
+
+**Data and nested contracts**
+
+
+
+
+
+
Type
+
Required fields
+
Optional 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.
+
+ )
+ },
+}
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: () => (
+
)}
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 && (
-