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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions packages/react/.scripts/untranslated-copy-debt.json
Original file line number Diff line number Diff line change
@@ -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:"
Expand Down Expand Up @@ -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"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<OneTable>
<TableBody>
<TableRow>
<TableCell>Engineering</TableCell>
</TableRow>
</TableBody>
</OneTable>
)

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")
})
})
37 changes: 30 additions & 7 deletions packages/react/src/experimental/OneTable/Table/index.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -23,33 +23,56 @@ 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<HTMLDivElement>(null)

useEffect(() => {
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 (
<TableContext.Provider
value={{ isScrolled, setIsScrolled, isScrolledRight, setIsScrolledRight }}
>
<div ref={containerRef} className="relative h-full w-full overflow-auto">
<div
ref={containerRef}
tabIndex={isScrollable ? 0 : undefined}
className={cn(
"relative h-full w-full overflow-auto",
focusRing(
"rounded-sm focus-visible:ring-inset focus-visible:ring-offset-0"
)
)}
>
<TableRoot
className={cn(loading && "select-none opacity-50 transition-opacity")}
aria-live={loading ? "polite" : undefined}
Expand Down
10 changes: 10 additions & 0 deletions packages/react/src/kits/F0DataChart/__stories__/F0DataChart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ Use `dashed: true` on a series to render a target / projection line.

<Canvas of={LineStories.WithDots} />

### 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"`.

<Canvas of={LineStories.SparseGrid} />

### 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,30 @@ 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 = {
component: F0DataChart,
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<typeof F0DataChart>

export default meta
Expand Down Expand Up @@ -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) => <F0DataChart {...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
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading