@agregio-solutions/design-system 1.97.1 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { Line } from './utils/Timeline.types';
1
+ import { ExpandedGroups, Line } from './utils/Timeline.types';
2
2
  import { CSSProperties, ReactNode } from 'react';
3
3
  export type TimelineProps = {
4
4
  /**
@@ -17,6 +17,18 @@ export type TimelineProps = {
17
17
  * The configuration of the timeline to display in this line
18
18
  */
19
19
  emptyPlaceholder?: ReactNode;
20
+ /**
21
+ * Expanded state of the group lines, keyed by group name (controlled mode).
22
+ * When provided, every group line is controlled and a name missing from the
23
+ * map reads as collapsed. Leave it out to let each group keep its own
24
+ * internal state. Group names must be unique for this to be unambiguous.
25
+ */
26
+ expandedGroups?: ExpandedGroups;
27
+ /**
28
+ * Called with the complete next map when a group line is expanded or
29
+ * collapsed, so it can be passed straight to a state setter.
30
+ */
31
+ onExpandedGroupsChange?: (expandedGroups: ExpandedGroups) => void;
20
32
  /**
21
33
  * The class name of the timeline
22
34
  */
@@ -26,4 +38,4 @@ export type TimelineProps = {
26
38
  */
27
39
  style?: CSSProperties;
28
40
  };
29
- export default function Timeline({ emptyPlaceholder, lines, startDate, endDate, ...props }: TimelineProps): import("react").JSX.Element;
41
+ export default function Timeline({ emptyPlaceholder, lines, startDate, endDate, expandedGroups, onExpandedGroupsChange, ...props }: TimelineProps): import("react").JSX.Element;
@@ -10,6 +10,14 @@ export declare const GroupContainer: import('@emotion/styled').StyledComponent<{
10
10
  theme?: import('@emotion/react').Theme;
11
11
  as?: React.ElementType;
12
12
  }, import('react').DetailedHTMLProps<import('react').HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {}>;
13
+ /**
14
+ * One visual row of an overlap group. Its blocks are absolutely positioned
15
+ * inside it, so blocks that don't overlap can share a row.
16
+ */
17
+ export declare const LaneContainer: import('@emotion/styled').StyledComponent<{
18
+ theme?: import('@emotion/react').Theme;
19
+ as?: React.ElementType;
20
+ }, import('react').DetailedHTMLProps<import('react').HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {}>;
13
21
  export declare const Overlay: import('@emotion/styled').StyledComponent<{
14
22
  theme?: import('@emotion/react').Theme;
15
23
  as?: React.ElementType;
@@ -0,0 +1,18 @@
1
+ import { Block } from '../../../../utils/Timeline.types';
2
+ type Lane = Array<Block>;
3
+ /**
4
+ * Packs the blocks of one overlap group into lanes (the visual rows of a line).
5
+ *
6
+ * Blocks sharing a color share a lane as long as they don't overlap each other.
7
+ * The color carries the entity identity in a Timeline, so an entity reads as a
8
+ * single row instead of being spread over several rows by an unrelated block
9
+ * that happens to bridge two of its periods. A color that overlaps itself
10
+ * consumes as many lanes as needed, so a block is never hidden behind another.
11
+ *
12
+ * Lane order is derived from the data alone — earliest start of the color, then
13
+ * the color string — never from the input order: consumers refetch on an
14
+ * interval, and an insertion-ordered layout would make the rows jump on every
15
+ * refresh.
16
+ */
17
+ export declare function assignBlocksToLanes(blocks: Array<Block>): Array<Lane>;
18
+ export {};
@@ -1,7 +1,7 @@
1
1
  type GetLineHeightParams = {
2
- nbOfBlocks: number;
2
+ nbOfLanes: number;
3
3
  isCollapsed: boolean;
4
4
  indentLevel: number;
5
5
  };
6
- export default function getLineHeight({ nbOfBlocks, isCollapsed, indentLevel, }: GetLineHeightParams): number;
6
+ export default function getLineHeight({ nbOfLanes, isCollapsed, indentLevel, }: GetLineHeightParams): number;
7
7
  export {};
@@ -1,9 +1,19 @@
1
- import { Block, SubLine } from '../../utils/Timeline.types';
1
+ import { Block, GroupHoverTextFormatter, SubLine } from '../../utils/Timeline.types';
2
2
  interface Props {
3
3
  name: string;
4
4
  subLines: Array<SubLine>;
5
5
  lineStartDate: Block["startDate"];
6
6
  lineEndDate: Block["endDate"];
7
+ groupHoverText?: GroupHoverTextFormatter;
8
+ /**
9
+ * Whether the group is expanded (controlled). Leave it undefined to let the
10
+ * group keep its own internal state.
11
+ */
12
+ isExpanded?: boolean;
13
+ /**
14
+ * Called with the next expanded state whenever the group name is clicked.
15
+ */
16
+ onExpandedChange?: (isExpanded: boolean) => void;
7
17
  }
8
- export default function GroupLineContainer({ name, subLines, lineStartDate, lineEndDate, }: Props): import("react").JSX.Element;
18
+ export default function GroupLineContainer({ name, subLines, lineStartDate, lineEndDate, groupHoverText, isExpanded: controlledIsExpanded, onExpandedChange, }: Props): import("react").JSX.Element;
9
19
  export {};
@@ -15,7 +15,8 @@ Base stories:
15
15
  ```tsx
16
16
  import { Meta, StoryObj } from "@storybook/react-vite";
17
17
  import Timeline from "./Timeline";
18
- import { within, userEvent, fn } from "storybook/test";
18
+ import { Block } from "./utils/Timeline.types";
19
+ import { within, userEvent, expect, fn } from "storybook/test";
19
20
  import MockDate from "mockdate";
20
21
  import {
21
22
  expectNotPresent,
@@ -505,6 +506,11 @@ export const GroupSimple: Story = {
505
506
  await canvas.findByText("Installation 1 PRM_1");
506
507
  await canvas.findByTestId("Installation 1 PRM_1 - Indisponibilité 1");
507
508
  await canvas.findByTestId("Installation 1 PRM_1 - Activation 1");
509
+
510
+ // Clicking again collapses it back: the group holds its own state when the
511
+ // consumer doesn't control it.
512
+ await user.click(canvas.getByText("Installation 1"));
513
+ await expectNotPresent(() => canvas.queryByText("Installation 1 PRM_1"));
508
514
  },
509
515
  };
510
516
 
@@ -704,6 +710,52 @@ export const GroupWithOverflow: Story = {
704
710
  },
705
711
  };
706
712
 
713
+ export const GroupCustomHoverText: Story = {
714
+ args: {
715
+ startDate: new Date("2024-01-01T00:00:00.000Z"),
716
+ endDate: new Date("2024-01-08T00:00:00.000Z"),
717
+ lines: [
718
+ {
719
+ type: "group",
720
+ name: "Installation 5",
721
+ groupHoverText: ({ blocks }) => `${blocks.length} indisponibilité(s)`,
722
+ subLines: [
723
+ {
724
+ name: "Installation 5 PRM_1",
725
+ blocks: [
726
+ {
727
+ id: "unavailability-1",
728
+ startDate: new Date("2024-01-01T00:00:00.000Z"),
729
+ endDate: new Date("2024-01-03T00:00:00.000Z"),
730
+ color: "var(--color-content-dataviz-pink-3)",
731
+ hoverText: "Installation 5 PRM_1 - Indisponibilité 1",
732
+ },
733
+ ],
734
+ },
735
+ {
736
+ name: "Installation 5 PRM_2",
737
+ blocks: [
738
+ {
739
+ id: "unavailability-1",
740
+ startDate: new Date("2024-01-02T00:00:00.000Z"),
741
+ endDate: new Date("2024-01-04T00:00:00.000Z"),
742
+ color: "var(--color-content-dataviz-pink-3)",
743
+ hoverText: "Installation 5 PRM_2 - Indisponibilité 1",
744
+ },
745
+ ],
746
+ },
747
+ ],
748
+ },
749
+ ],
750
+ },
751
+ play: async ({ canvasElement, mount }) => {
752
+ MockDate.set("2024-01-05T12:00:00.000Z");
753
+ await mount();
754
+ const canvas = within(canvasElement);
755
+ await canvas.findByTestId("2 indisponibilité(s)");
756
+ },
757
+ };
758
+
707
759
  export const MixedSimpleAndGroup: Story = {
708
760
  args: {
709
761
  startDate: new Date("2024-01-01T00:00:00.000Z"),
@@ -924,6 +976,449 @@ export const GroupFullOverlap: Story = {
924
976
  await user.click(canvas.getByText("Maison Dieu - PRM[50014414454079]"));
925
977
  },
926
978
  };
979
+
980
+ const DAY_START = new Date("2024-01-01T00:00:00.000Z");
981
+ const DAY_END = new Date("2024-01-02T00:00:00.000Z");
982
+
983
+ /**
984
+ * Names the merged blocks of a collapsed group row after the block they were
985
+ * merged from, so the stories can target them by `data-testid`. Using the id
986
+ * rather than a formatted date keeps the testids timezone-independent.
987
+ */
988
+ const groupHoverTextFromFirstBlockId = ({ blocks }: { blocks: Array<Block> }) =>
989
+ blocks[0].id;
990
+
991
+ export const GroupCollapsedSameAggregateTwice: Story = {
992
+ args: {
993
+ startDate: DAY_START,
994
+ endDate: DAY_END,
995
+ lines: [
996
+ {
997
+ type: "group",
998
+ name: "PSN",
999
+ groupHoverText: groupHoverTextFromFirstBlockId,
1000
+ subLines: [
1001
+ {
1002
+ // The reported bug: one aggregate, two solicitations separated by a
1003
+ // 1h30 gap. They must read as a single row.
1004
+ name: "Agrégat A",
1005
+ blocks: [
1006
+ {
1007
+ id: "agregat-a-matin",
1008
+ startDate: new Date("2024-01-01T11:20:00.000Z"),
1009
+ endDate: new Date("2024-01-01T13:00:00.000Z"),
1010
+ color: "var(--color-content-dataviz-purple-3)",
1011
+ hoverText: "Agrégat A - matin",
1012
+ },
1013
+ {
1014
+ id: "agregat-a-apres-midi",
1015
+ startDate: new Date("2024-01-01T14:30:00.000Z"),
1016
+ endDate: new Date("2024-01-01T16:25:00.000Z"),
1017
+ color: "var(--color-content-dataviz-purple-3)",
1018
+ hoverText: "Agrégat A - après-midi",
1019
+ },
1020
+ ],
1021
+ },
1022
+ {
1023
+ // Long blocks that bridge the two purple ones into a single overlap
1024
+ // group — the actual cause of the bug.
1025
+ name: "Agrégat B",
1026
+ blocks: [
1027
+ {
1028
+ id: "agregat-b",
1029
+ startDate: new Date("2024-01-01T10:45:00.000Z"),
1030
+ endDate: new Date("2024-01-01T16:20:00.000Z"),
1031
+ color: "var(--color-content-dataviz-azure-3)",
1032
+ hoverText: "Agrégat B",
1033
+ },
1034
+ ],
1035
+ },
1036
+ {
1037
+ name: "Agrégat C",
1038
+ blocks: [
1039
+ {
1040
+ id: "agregat-c",
1041
+ startDate: new Date("2024-01-01T11:00:00.000Z"),
1042
+ endDate: new Date("2024-01-01T16:20:00.000Z"),
1043
+ color: "var(--color-content-dataviz-pink-3)",
1044
+ hoverText: "Agrégat C",
1045
+ },
1046
+ ],
1047
+ },
1048
+ {
1049
+ name: "Agrégat D",
1050
+ blocks: [
1051
+ {
1052
+ id: "agregat-d",
1053
+ startDate: new Date("2024-01-01T10:45:00.000Z"),
1054
+ endDate: new Date("2024-01-01T16:35:00.000Z"),
1055
+ color: "var(--color-content-dataviz-ocean-3)",
1056
+ hoverText: "Agrégat D",
1057
+ },
1058
+ ],
1059
+ },
1060
+ ],
1061
+ },
1062
+ ],
1063
+ },
1064
+ play: async ({ canvasElement, mount }) => {
1065
+ MockDate.set("2024-01-01T12:00:00.000Z");
1066
+ await mount();
1067
+ const canvas = within(canvasElement);
1068
+
1069
+ const morning = canvas.getByTestId("agregat-a-matin");
1070
+ const afternoon = canvas.getByTestId("agregat-a-apres-midi");
1071
+
1072
+ // Both belong to the same overlap group, bridged by the long blocks…
1073
+ await expect(morning).toHaveAttribute("data-group-index", "0");
1074
+ await expect(afternoon).toHaveAttribute("data-group-index", "0");
1075
+
1076
+ // …and they are two distinct blocks, not a single merged one: the 1h30 gap
1077
+ // between 13:00 and 14:30 stays visible.
1078
+ await expect(morning.getAttribute("data-start")).not.toBe(
1079
+ afternoon.getAttribute("data-start"),
1080
+ );
1081
+
1082
+ // The fix: one lane for the aggregate, instead of one row per solicitation.
1083
+ await expect(morning).toHaveAttribute("data-lane-index", "3");
1084
+ await expect(afternoon).toHaveAttribute("data-lane-index", "3");
1085
+
1086
+ // Lanes are ordered by the earliest start of their color, then by the color
1087
+ // string: azure and ocean both start at 10:45, azure wins the tie.
1088
+ await expect(canvas.getByTestId("agregat-b")).toHaveAttribute(
1089
+ "data-lane-index",
1090
+ "0",
1091
+ );
1092
+ await expect(canvas.getByTestId("agregat-d")).toHaveAttribute(
1093
+ "data-lane-index",
1094
+ "1",
1095
+ );
1096
+ await expect(canvas.getByTestId("agregat-c")).toHaveAttribute(
1097
+ "data-lane-index",
1098
+ "2",
1099
+ );
1100
+ },
1101
+ };
1102
+
1103
+ export const GroupExpandedLanePacking: Story = {
1104
+ args: {
1105
+ startDate: DAY_START,
1106
+ endDate: DAY_END,
1107
+ lines: [
1108
+ {
1109
+ type: "group",
1110
+ name: "PSN",
1111
+ groupHoverText: groupHoverTextFromFirstBlockId,
1112
+ subLines: [
1113
+ {
1114
+ name: "Agrégat A",
1115
+ blocks: [
1116
+ // Same color, disjoint: they share a lane even on a sub-line.
1117
+ {
1118
+ id: "purple-matin",
1119
+ startDate: new Date("2024-01-01T10:00:00.000Z"),
1120
+ endDate: new Date("2024-01-01T12:00:00.000Z"),
1121
+ color: "var(--color-content-dataviz-purple-3)",
1122
+ hoverText: "Sub-line - purple matin",
1123
+ },
1124
+ {
1125
+ id: "purple-apres-midi",
1126
+ startDate: new Date("2024-01-01T12:30:00.000Z"),
1127
+ endDate: new Date("2024-01-01T14:00:00.000Z"),
1128
+ color: "var(--color-content-dataviz-purple-3)",
1129
+ hoverText: "Sub-line - purple après-midi",
1130
+ },
1131
+ // Three more colors, so the sub-line needs four lanes and crosses
1132
+ // its own threshold of three.
1133
+ {
1134
+ id: "azure",
1135
+ startDate: new Date("2024-01-01T10:00:00.000Z"),
1136
+ endDate: new Date("2024-01-01T14:00:00.000Z"),
1137
+ color: "var(--color-content-dataviz-azure-3)",
1138
+ hoverText: "Sub-line - azure",
1139
+ },
1140
+ {
1141
+ id: "ocean",
1142
+ startDate: new Date("2024-01-01T10:00:00.000Z"),
1143
+ endDate: new Date("2024-01-01T14:00:00.000Z"),
1144
+ color: "var(--color-content-dataviz-ocean-3)",
1145
+ hoverText: "Sub-line - ocean",
1146
+ },
1147
+ {
1148
+ id: "pink",
1149
+ startDate: new Date("2024-01-01T10:00:00.000Z"),
1150
+ endDate: new Date("2024-01-01T14:00:00.000Z"),
1151
+ color: "var(--color-content-dataviz-pink-3)",
1152
+ hoverText: "Sub-line - pink",
1153
+ },
1154
+ ],
1155
+ },
1156
+ {
1157
+ name: "Agrégat B",
1158
+ blocks: [
1159
+ {
1160
+ id: "brown",
1161
+ startDate: new Date("2024-01-01T11:00:00.000Z"),
1162
+ endDate: new Date("2024-01-01T13:00:00.000Z"),
1163
+ color: "var(--color-content-dataviz-brown-3)",
1164
+ hoverText: "Sub-line - brown",
1165
+ },
1166
+ ],
1167
+ },
1168
+ ],
1169
+ },
1170
+ ],
1171
+ },
1172
+ play: async ({ canvasElement, mount }) => {
1173
+ MockDate.set("2024-01-01T12:00:00.000Z");
1174
+ await mount();
1175
+ const canvas = within(canvasElement);
1176
+ const user = userEvent.setup();
1177
+
1178
+ await user.click(canvas.getByText("PSN"));
1179
+ await canvas.findByText("Agrégat A");
1180
+
1181
+ // A sub-line collapses at three lanes, not five. Purple sorts last, so it
1182
+ // is the lane that gets hidden.
1183
+ await expect(canvas.getByTestId("Sub-line - azure")).toHaveAttribute(
1184
+ "data-lane-index",
1185
+ "0",
1186
+ );
1187
+ await expect(canvas.getByTestId("Sub-line - ocean")).toHaveAttribute(
1188
+ "data-lane-index",
1189
+ "1",
1190
+ );
1191
+ await expect(canvas.getByTestId("Sub-line - pink")).toHaveAttribute(
1192
+ "data-lane-index",
1193
+ "2",
1194
+ );
1195
+ await expectNotPresent(() =>
1196
+ canvas.queryByTestId("Sub-line - purple matin"),
1197
+ );
1198
+
1199
+ await user.click(canvas.getByRole("button", { name: "Afficher plus" }));
1200
+
1201
+ // Once expanded, the two purple blocks share the fourth lane.
1202
+ await expect(canvas.getByTestId("Sub-line - purple matin")).toHaveAttribute(
1203
+ "data-lane-index",
1204
+ "3",
1205
+ );
1206
+ await expect(
1207
+ canvas.getByTestId("Sub-line - purple après-midi"),
1208
+ ).toHaveAttribute("data-lane-index", "3");
1209
+ },
1210
+ };
1211
+
1212
+ const TWO_PERIODS = [
1213
+ {
1214
+ startDate: new Date("2024-01-01T10:00:00.000Z"),
1215
+ endDate: new Date("2024-01-01T14:00:00.000Z"),
1216
+ },
1217
+ {
1218
+ startDate: new Date("2024-01-01T15:00:00.000Z"),
1219
+ endDate: new Date("2024-01-01T19:00:00.000Z"),
1220
+ },
1221
+ ];
1222
+
1223
+ export const GroupCollapsedExpandButtonCountsLanes: Story = {
1224
+ args: {
1225
+ startDate: DAY_START,
1226
+ endDate: DAY_END,
1227
+ lines: [
1228
+ {
1229
+ type: "group",
1230
+ name: "PSN",
1231
+ groupHoverText: groupHoverTextFromFirstBlockId,
1232
+ subLines: [
1233
+ // Seven blocks, but only five lanes: two aggregates are active over
1234
+ // two disjoint periods each. The collapse threshold is five at this
1235
+ // indent level, so counting blocks would truncate and show the expand
1236
+ // button, while counting lanes shows everything.
1237
+ {
1238
+ name: "Agrégat azure",
1239
+ blocks: [
1240
+ {
1241
+ id: "azure",
1242
+ startDate: new Date("2024-01-01T10:00:00.000Z"),
1243
+ endDate: new Date("2024-01-01T20:00:00.000Z"),
1244
+ color: "var(--color-content-dataviz-azure-3)",
1245
+ hoverText: "Agrégat azure",
1246
+ },
1247
+ ],
1248
+ },
1249
+ {
1250
+ name: "Agrégat brown",
1251
+ blocks: TWO_PERIODS.map((period, index) => ({
1252
+ ...period,
1253
+ id: `brown-${index + 1}`,
1254
+ color: "var(--color-content-dataviz-brown-3)",
1255
+ hoverText: `Agrégat brown ${index + 1}`,
1256
+ })),
1257
+ },
1258
+ {
1259
+ name: "Agrégat negative",
1260
+ blocks: TWO_PERIODS.map((period, index) => ({
1261
+ ...period,
1262
+ id: `negative-${index + 1}`,
1263
+ color: "var(--color-content-dataviz-negative-3)",
1264
+ hoverText: `Agrégat negative ${index + 1}`,
1265
+ })),
1266
+ },
1267
+ {
1268
+ name: "Agrégat neutral",
1269
+ blocks: [
1270
+ {
1271
+ id: "neutral",
1272
+ startDate: new Date("2024-01-01T10:00:00.000Z"),
1273
+ endDate: new Date("2024-01-01T20:00:00.000Z"),
1274
+ color: "var(--color-content-dataviz-neutral-3)",
1275
+ hoverText: "Agrégat neutral",
1276
+ },
1277
+ ],
1278
+ },
1279
+ {
1280
+ name: "Agrégat ocean",
1281
+ blocks: [
1282
+ {
1283
+ id: "ocean",
1284
+ startDate: new Date("2024-01-01T10:00:00.000Z"),
1285
+ endDate: new Date("2024-01-01T20:00:00.000Z"),
1286
+ color: "var(--color-content-dataviz-ocean-3)",
1287
+ hoverText: "Agrégat ocean",
1288
+ },
1289
+ ],
1290
+ },
1291
+ ],
1292
+ },
1293
+ ],
1294
+ },
1295
+ play: async ({ canvasElement, mount }) => {
1296
+ MockDate.set("2024-01-01T12:00:00.000Z");
1297
+ await mount();
1298
+ const canvas = within(canvasElement);
1299
+
1300
+ // Five lanes for seven blocks: nothing is truncated, so no expand button.
1301
+ await expectNotPresent(() =>
1302
+ canvas.queryByRole("button", { name: "Afficher plus" }),
1303
+ );
1304
+
1305
+ // Every block stays visible, including the second period of each of the two
1306
+ // aggregates that have one.
1307
+ for (const id of [
1308
+ "azure",
1309
+ "brown-1",
1310
+ "brown-2",
1311
+ "negative-1",
1312
+ "negative-2",
1313
+ "neutral",
1314
+ "ocean",
1315
+ ]) {
1316
+ await canvas.findByTestId(id);
1317
+ }
1318
+
1319
+ // All five colors start at 10:00, so the color string orders the lanes, and
1320
+ // each aggregate's two periods share its lane.
1321
+ await expect(canvas.getByTestId("azure")).toHaveAttribute(
1322
+ "data-lane-index",
1323
+ "0",
1324
+ );
1325
+ await expect(canvas.getByTestId("brown-1")).toHaveAttribute(
1326
+ "data-lane-index",
1327
+ "1",
1328
+ );
1329
+ await expect(canvas.getByTestId("brown-2")).toHaveAttribute(
1330
+ "data-lane-index",
1331
+ "1",
1332
+ );
1333
+ await expect(canvas.getByTestId("negative-1")).toHaveAttribute(
1334
+ "data-lane-index",
1335
+ "2",
1336
+ );
1337
+ await expect(canvas.getByTestId("negative-2")).toHaveAttribute(
1338
+ "data-lane-index",
1339
+ "2",
1340
+ );
1341
+ await expect(canvas.getByTestId("neutral")).toHaveAttribute(
1342
+ "data-lane-index",
1343
+ "3",
1344
+ );
1345
+ await expect(canvas.getByTestId("ocean")).toHaveAttribute(
1346
+ "data-lane-index",
1347
+ "4",
1348
+ );
1349
+ },
1350
+ };
1351
+
1352
+ export const GroupControlledExpansion: Story = {
1353
+ args: {
1354
+ startDate: new Date("2024-01-01T00:00:00.000Z"),
1355
+ endDate: new Date("2024-01-08T00:00:00.000Z"),
1356
+ // MA is expanded and PSN collapsed from the outside: the consumer owns the state
1357
+ // (e.g. to keep it across data changes that would otherwise remount the group).
1358
+ expandedGroups: { MA: true, PSN: false },
1359
+ onExpandedGroupsChange: fn(),
1360
+ lines: [
1361
+ {
1362
+ type: "group",
1363
+ name: "MA",
1364
+ subLines: [
1365
+ {
1366
+ name: "Agrégat MA 1",
1367
+ blocks: [
1368
+ {
1369
+ id: "ma-1",
1370
+ startDate: new Date("2024-01-02T00:00:00.000Z"),
1371
+ endDate: new Date("2024-01-04T00:00:00.000Z"),
1372
+ color: "var(--color-content-dataviz-azure-3)",
1373
+ hoverText: "Agrégat MA 1 - Sollicitation 1",
1374
+ },
1375
+ ],
1376
+ },
1377
+ ],
1378
+ },
1379
+ {
1380
+ type: "group",
1381
+ name: "PSN",
1382
+ subLines: [
1383
+ {
1384
+ name: "Agrégat PSN 1",
1385
+ blocks: [
1386
+ {
1387
+ id: "psn-1",
1388
+ startDate: new Date("2024-01-05T00:00:00.000Z"),
1389
+ endDate: new Date("2024-01-07T00:00:00.000Z"),
1390
+ color: "var(--color-content-dataviz-purple-3)",
1391
+ hoverText: "Agrégat PSN 1 - Sollicitation 1",
1392
+ },
1393
+ ],
1394
+ },
1395
+ ],
1396
+ },
1397
+ ],
1398
+ },
1399
+ play: async ({ args, canvasElement, mount }) => {
1400
+ MockDate.set("2024-01-05T12:00:00.000Z");
1401
+ await mount();
1402
+ const canvas = within(canvasElement);
1403
+ const user = userEvent.setup();
1404
+
1405
+ // Expanded state comes from the prop only: no click needed for MA, and PSN stays
1406
+ // collapsed even though both groups render the exact same way.
1407
+ await canvas.findByText("Agrégat MA 1");
1408
+ await expectNotPresent(() => canvas.queryByText("Agrégat PSN 1"));
1409
+
1410
+ await user.click(canvas.getByText("MA"));
1411
+
1412
+ // The callback hands back the whole next map, ready for a state setter.
1413
+ expect(args.onExpandedGroupsChange).toHaveBeenCalledWith({
1414
+ MA: false,
1415
+ PSN: false,
1416
+ });
1417
+ // The story's args are static, so the row must stay expanded: the prop is the
1418
+ // single source of truth, the internal state never shadows it.
1419
+ expect(canvas.getByText("Agrégat MA 1")).toBeInTheDocument();
1420
+ },
1421
+ };
927
1422
  ```
928
1423
 
929
1424
  ## Developer notes
@@ -1018,10 +1513,44 @@ Use `type: "group"` with a `subLines` array instead of a `blocks` array.
1018
1513
 
1019
1514
  When collapsed, the group displays a **merged view** of all sub-lines' blocks. Blocks sharing the same color are merged into a single block spanning their combined range. This gives a quick overview of activity across all sub-lines.
1020
1515
 
1516
+ By default, hovering a merged block shows its date range. Provide a `groupHoverText` function on the group line to customize this text. It receives the merged interval's `startDate`, `endDate`, `color`, and the original `blocks` that were merged into it:
1517
+
1518
+ ```ts
1519
+ {
1520
+ type: "group",
1521
+ name: "Installation 3",
1522
+ groupHoverText: ({ startDate, endDate, blocks }) =>
1523
+ `${blocks.length} events between ${startDate} and ${endDate}`,
1524
+ subLines: [ /* ... */ ],
1525
+ }
1526
+ ```
1527
+
1528
+ Give `groupHoverText` a stable identity (`useCallback`, or defined outside the component) — it's used as a dependency to memoize the merge computation, so a new function on every render defeats that memoization.
1529
+
1530
+ <Canvas of={Timeline.GroupCustomHoverText} />
1531
+
1021
1532
  ### Expanded state
1022
1533
 
1023
1534
  Clicking the group name **expands** it, revealing each sub-line on its own indented row. Clicking again collapses it back.
1024
1535
 
1536
+ By default each group owns that state internally. It is therefore lost whenever the group unmounts — which happens more often than it looks, since removing a line shifts its siblings' React keys. Pass `expandedGroups` (a `Record<groupName, boolean>`) and `onExpandedGroupsChange` to hold it yourself instead:
1537
+
1538
+ ```tsx
1539
+ const [expandedGroups, setExpandedGroups] = useState({ MA: true });
1540
+
1541
+ <Timeline
1542
+ lines={lines}
1543
+ startDate={startDate}
1544
+ endDate={endDate}
1545
+ expandedGroups={expandedGroups}
1546
+ onExpandedGroupsChange={setExpandedGroups}
1547
+ />;
1548
+ ```
1549
+
1550
+ `onExpandedGroupsChange` receives the complete next map, so it can be handed straight to a state setter. A group name missing from the map reads as collapsed, and the two props go together: providing `expandedGroups` without `onExpandedGroupsChange` freezes every group. Since the map is keyed by name, **group names must be unique**.
1551
+
1552
+ <Canvas of={Timeline.GroupControlledExpansion} />
1553
+
1025
1554
  ### Simple group (one sub-line)
1026
1555
 
1027
1556
  <Canvas of={Timeline.GroupSimple} />