Compare commits

...
Author SHA1 Message Date
Ken 1a504a1f9f perf: Optimize test 2026-10-22 13:40:42 +02:00
Ken 2be908b51e feat: Dynamic axislabel view 2026-10-22 13:25:18 +02:00
6 changed files with 337 additions and 61 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@navikt/ds-react": patch
"@navikt/ds-css": patch
---
Timeline: Adapt axis label density to available width
+27
View File
@@ -13,6 +13,7 @@
box-sizing: content-box;
grid-column: 2;
margin-bottom: var(--ax-space-4);
overflow-x: clip;
}
.aksel-timeline__row-label {
@@ -30,13 +31,39 @@
align-self: flex-end;
}
.aksel-timeline__axislabels-tier {
position: absolute;
top: 0;
container-name: aksel-timeline-axis-labels;
container-type: inline-size;
}
.aksel-timeline__axislabels-label {
position: absolute;
width: 100%;
color: var(--ax-text-neutral-subtle);
white-space: nowrap;
overflow-x: clip;
}
.aksel-timeline__axislabels-tier > .aksel-timeline__axislabels-tier {
display: none;
}
/*
* 4.5rem leaves room for the longest default abbreviated date label.
* Below that, hide this tier and reveal the next coarser one in the same query.
*/
@container aksel-timeline-axis-labels (inline-size < 4.5rem) {
.aksel-timeline__axislabels-tier:not([data-axis-labels-coarsest]) > .aksel-timeline__axislabels-label {
display: none;
}
.aksel-timeline__axislabels-tier > .aksel-timeline__axislabels-tier {
display: block;
}
}
.aksel-timeline__row {
display: flex;
background: var(--ax-bg-neutral-softA);
@@ -599,7 +599,15 @@ export const CustomLabel: StoryFn = () => {
export const OverflowingAxisLabels: StoryFn = () => {
return (
<div style={{ width: "800px" }}>
<div
style={{
width: "800px",
maxWidth: "100%",
minWidth: "200px",
resize: "horizontal",
overflow: "auto",
}}
>
<Timeline
startDate={new Date("2026-05-04")}
endDate={new Date("2026-05-31")}
@@ -97,6 +97,40 @@ function TwoRowTimelineWithPins() {
);
}
function ResponsiveAxisTimeline() {
return (
<>
{(["left", "right"] as const).flatMap((direction) =>
[
["2026-05-04", "2026-05-31"],
["2024-02-15", "2025-03-05"],
["2022-06-15", "2047-07-05"],
].map(([start, end]) => (
<div
key={`${direction}-${start}`}
data-axis-size
style={{ width: "320px" }}
>
<Timeline
direction={direction}
startDate={new Date(start)}
endDate={new Date(end)}
>
<Timeline.Row label="Row">
<Timeline.Period
start={new Date(start)}
end={new Date(end)}
status="success"
/>
</Timeline.Row>
</Timeline>
</div>
)),
)}
</>
);
}
export const HorizontalPeriodNavigation: Story = {
render: () => <TwoRowTimeline />,
play: async ({ canvasElement }) => {
@@ -179,3 +213,86 @@ export const PinNavigation: Story = {
expect(pins[0]).toHaveFocus();
},
};
export const AxisLabelsAdaptToWidth: Story = {
render: () => <ResponsiveAxisTimeline />,
play: async ({ canvasElement }) => {
const containers =
canvasElement.querySelectorAll<HTMLElement>("[data-axis-size]");
expect(containers).toHaveLength(6);
for (const container of containers) {
const axis = container.querySelector<HTMLElement>(
".aksel-timeline__axislabels",
);
const finestTier = axis?.querySelector<HTMLElement>(
".aksel-timeline__axislabels-tier",
);
if (!axis || !finestTier) {
throw new Error("Timeline axis or label tier not found");
}
const markup = axis.innerHTML;
const height = axis.getBoundingClientRect().height;
const selectedDensities = new Set<string | null>();
const labelColumnWidth =
container.getBoundingClientRect().width -
axis.getBoundingClientRect().width;
const minSlotWidth =
4.5 *
Number.parseFloat(
window.getComputedStyle(document.documentElement).fontSize,
);
const breakpoint =
(minSlotWidth * 100) / Number.parseFloat(finestTier.style.width) +
labelColumnWidth;
for (const width of [200, breakpoint, 2400]) {
container.style.width = `${width}px`;
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
const visibleLabels = [
...axis.querySelectorAll<HTMLElement>(
".aksel-timeline__axislabels-label",
),
].filter((label) => label.getClientRects().length > 0);
const densities = new Set(
visibleLabels.map((label) =>
label.getAttribute("data-axis-labels-density"),
),
);
expect(densities.size, `Axis density at container width ${width}`).toBe(
1,
);
for (const density of densities) {
selectedDensities.add(density);
}
const axisBounds = axis.getBoundingClientRect();
const textBounds = visibleLabels
.map((label) => {
const range = document.createRange();
range.selectNodeContents(label);
return range.getBoundingClientRect();
})
.sort((a, b) => a.left - b.left);
const labelsFit = textBounds.every(
(bounds, index) =>
bounds.left >= axisBounds.left - 1 &&
bounds.right <= axisBounds.right + 1 &&
(index === 0 || bounds.left >= textBounds[index - 1].right),
);
expect(
labelsFit,
`Labels stay within the axis without overlap at container width ${width}`,
).toBe(true);
expect(axisBounds.height).toBe(height);
expect(axis.innerHTML).toBe(markup);
}
expect(selectedDensities.size).toBeGreaterThan(1);
}
},
};
@@ -4,6 +4,7 @@ import {
addMonths,
addYears,
differenceInDays,
differenceInMilliseconds,
differenceInMonths,
differenceInYears,
endOfMonth,
@@ -14,31 +15,45 @@ import {
startOfYear,
subDays,
} from "date-fns";
import React, { useCallback, useMemo } from "react";
import React, { useMemo } from "react";
import { Detail } from "../../typography/Detail";
import { useDateLocale, useI18n } from "../../utils/i18n/i18n.hooks";
import type { TFunction } from "../../utils/i18n/i18n.types";
import { useTimelineContext } from "../root/TimelineRoot.context";
import { isVisible } from "../utils";
import { horizontalPositionAndWidth } from "../utils/calc";
import type { AxisLabel, AxisLabelTemplates } from "../utils/types.external";
// Use 23 hours as a conservative day length so DST-shortened days still qualify.
const MIN_SAFE_DAY_IN_MILLISECONDS = 23 * 60 * 60 * 1000;
const MAX_LABEL_COUNT = 100;
interface AxisLabelSet {
id: string;
labels: AxisLabel[];
slotWidth: number;
}
const dayLabels = (
start: Date,
end: Date,
totalDays: number,
intervalDays: number,
direction: "left" | "right",
template: string,
locale: Locale,
): AxisLabel[] => {
const increment = Math.ceil(totalDays / 10);
const totalDays = differenceInDays(end, start);
const count = Math.ceil(totalDays / intervalDays) + 1;
if (count > MAX_LABEL_COUNT) {
return [];
}
const lastDay = startOfDay(end);
const count = Math.ceil(totalDays / increment);
return Array.from({ length: count }, (_, i) => {
const day: Date = subDays(lastDay, i * increment);
const day: Date = subDays(lastDay, i * intervalDays);
const { horizontalPosition, width } = horizontalPositionAndWidth(
day,
addDays(day, 1),
addDays(day, intervalDays),
start,
end,
);
@@ -55,18 +70,25 @@ const dayLabels = (
const monthLabels = (
start: Date,
end: Date,
intervalMonths: number,
direction: "left" | "right",
template: string,
locale: Locale,
): AxisLabel[] => {
const startMonth = startOfMonth(start);
const endMonth = endOfMonth(end);
const numberOfMonths = differenceInMonths(endMonth, startMonth) + 1;
// Count calendar boundaries at this interval, not elapsed 30-day periods.
const numberOfMonths =
Math.floor(differenceInMonths(endMonth, startMonth) / intervalMonths) + 1;
if (numberOfMonths > MAX_LABEL_COUNT) {
return [];
}
return Array.from({ length: numberOfMonths }, (_, i) => {
const month: Date = addMonths(startMonth, i);
const month: Date = addMonths(startMonth, i * intervalMonths);
const { horizontalPosition, width } = horizontalPositionAndWidth(
month,
addMonths(month, 1),
addMonths(month, intervalMonths),
start,
end,
);
@@ -83,18 +105,25 @@ const monthLabels = (
const yearLabels = (
start: Date,
end: Date,
intervalYears: number,
direction: "left" | "right",
template: string,
locale: Locale,
): AxisLabel[] => {
const firstYear = startOfYear(start);
const lastYear = endOfYear(end);
const yearCount = differenceInYears(lastYear, start) + 1;
const yearCount =
Math.floor(differenceInYears(lastYear, start) / intervalYears) + 1;
if (yearCount > MAX_LABEL_COUNT) {
return [];
}
return Array.from({ length: yearCount }, (_, i) => {
const year: Date = addYears(firstYear, i);
const year: Date = addYears(firstYear, i * intervalYears);
const { horizontalPosition, width } = horizontalPositionAndWidth(
year,
addYears(year, 1),
addYears(year, intervalYears),
start,
end,
);
@@ -108,32 +137,99 @@ const yearLabels = (
});
};
const getLabels = (
const getLabelSets = (
start: Date,
end: Date,
direction: "left" | "right",
locale: Locale,
translate: TFunction<"Timeline">,
): AxisLabel[] => {
const totalDays = differenceInDays(end, start);
/* Zero or negative total days can break further calculations */
if (totalDays <= 0) {
): AxisLabelSet[] => {
const duration = differenceInMilliseconds(end, start);
if (!Number.isFinite(duration) || duration <= 0) {
return [];
}
if (totalDays < 40) {
const dayTemplate = translate("dayFormat");
return dayLabels(start, end, totalDays, direction, dayTemplate, locale);
}
if (totalDays < 370) {
const monthTemplate = translate("monthFormat");
return monthLabels(start, end, direction, monthTemplate, locale);
}
const dayTemplate = translate("dayFormat");
const monthTemplate = translate("monthFormat");
const yearTemplate = translate("yearFormat");
return yearLabels(start, end, direction, yearTemplate, locale);
const intervals: {
id: string;
template: string;
getLabels: () => AxisLabel[];
}[] = [];
/* CSS progressively reveals nested tiers */
for (const intervalDays of [1, 2, 7, 8, 14]) {
if (intervalDays * MIN_SAFE_DAY_IN_MILLISECONDS > duration) {
continue;
}
intervals.push({
id: `day-${intervalDays}`,
template: dayTemplate,
getLabels: () =>
dayLabels(start, end, intervalDays, direction, dayTemplate, locale),
});
}
for (const intervalMonths of [1, 2, 3, 6]) {
const intervalDays = intervalMonths * 28;
if (intervalDays * MIN_SAFE_DAY_IN_MILLISECONDS > duration) {
continue;
}
intervals.push({
id: `month-${intervalMonths}`,
template: monthTemplate,
getLabels: () =>
monthLabels(
start,
end,
intervalMonths,
direction,
monthTemplate,
locale,
),
});
}
for (let intervalYears = 1; ; intervalYears *= 2) {
const intervalDays = intervalYears * 365;
if (intervalDays * MIN_SAFE_DAY_IN_MILLISECONDS > duration) {
break;
}
intervals.push({
id: `year-${intervalYears}`,
template: yearTemplate,
getLabels: () =>
yearLabels(start, end, intervalYears, direction, yearTemplate, locale),
});
}
return intervals
.map(({ id, template, getLabels }) => {
const labels = getLabels();
/* Use the narrowest calendar slot so this tier fits even in shorter periods */
const slotWidth = Math.min(100, ...labels.map(({ width }) => width));
const visibleLabels = labels
.filter(
({ horizontalPosition }) =>
horizontalPosition >= 0 && horizontalPosition + slotWidth <= 100,
)
.sort((a, b) => a.horizontalPosition - b.horizontalPosition);
/* Coarse interval may have no calendar boundary inside the range */
if (labels.length > 0 && visibleLabels.length === 0) {
visibleLabels.push({
...labels[0],
date: start,
horizontalPosition: 0,
label: format(start, template, { locale }),
});
}
return { id, slotWidth, labels: visibleLabels };
})
.filter(({ labels }) => labels.length > 0);
};
const TimelineAxisLabelsInternal = ({
@@ -150,41 +246,51 @@ const TimelineAxisLabelsInternal = ({
});
const locale = useDateLocale();
const labels = useMemo(
() =>
getLabels(startDate, endDate, direction, locale, translate)
.filter(isVisible)
.sort((a, b) => a.horizontalPosition - b.horizontalPosition),
const labelSets = useMemo(
() => getLabelSets(startDate, endDate, direction, locale, translate),
[startDate, endDate, direction, locale, translate],
);
const getLabelWidth = useCallback(
(index: number): string => {
const next = labels[index + 1];
const bound = next ? next.horizontalPosition : 100;
return `${(bound - labels[index].horizontalPosition).toFixed(3)}%`;
},
[labels],
);
const justifyContent = direction === "left" ? "flex-start" : "flex-end";
return (
<div className="aksel-timeline__axislabels" aria-hidden="true">
{labels.map((etikett, index) => (
<Detail
className="aksel-timeline__axislabels-label"
as="div"
key={etikett.label}
style={{
justifyContent,
[direction]: `${etikett.horizontalPosition.toFixed(3)}%`,
width: getLabelWidth(index),
}}
>
{etikett.label}
</Detail>
))}
{labelSets.reduceRight<React.ReactNode>(
(coarserTier, labelSet, index) => (
<div
className="aksel-timeline__axislabels-tier"
key={labelSet.id}
data-axis-labels-coarsest={
index === labelSets.length - 1 || undefined
}
style={{
[direction]: 0,
width: `${
(labelSet.slotWidth /
(labelSets[index - 1]?.slotWidth ?? 100)) *
100
}%`,
}}
>
{labelSet.labels.map((label) => (
<Detail
className="aksel-timeline__axislabels-label"
as="div"
key={label.date.getTime()}
data-axis-labels-density={labelSet.id}
style={{
textAlign: direction,
[direction]: `${
(label.horizontalPosition / labelSet.slotWidth) * 100
}%`,
}}
>
{label.label}
</Detail>
))}
{coarserTier}
</div>
),
null,
)}
</div>
);
};
@@ -86,6 +86,18 @@ const TimelineRowBase = forwardRef<HTMLOListElement, TimelineRowProps>(
onKeyDown={handleRowKeyDown}
data-timeline-row
>
<span
className="aksel-timeline__row-line"
style={{ left: "12.75%" }}
/>
<span
className="aksel-timeline__row-line"
style={{ left: "24.28%" }}
/>
<span
className="aksel-timeline__row-line"
style={{ left: "49.37%" }}
/>
<ol
{...rest}
ref={ref}