@surflane/ui 0.11.0 → 0.12.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.
Files changed (40) hide show
  1. package/dist/app-shell.d.ts +10 -0
  2. package/dist/app-shell.js +7 -1
  3. package/dist/bar-list.d.ts +31 -0
  4. package/dist/bar-list.js +25 -0
  5. package/dist/bottom-navigation.d.ts +6 -2
  6. package/dist/bottom-navigation.js +2 -2
  7. package/dist/chart.d.ts +56 -3
  8. package/dist/chart.js +266 -48
  9. package/dist/css/bar-list.css +9 -0
  10. package/dist/css/chart.css +2 -0
  11. package/dist/css/dashboard.css +20 -0
  12. package/dist/css/rules/alert-dialog.css +4 -0
  13. package/dist/css/rules/app-shell.css +10 -0
  14. package/dist/css/rules/bar-list.css +101 -0
  15. package/dist/css/rules/bottom-navigation.css +16 -0
  16. package/dist/css/rules/card.css +5 -0
  17. package/dist/css/rules/chart.css +74 -3
  18. package/dist/css/rules/dashboard.css +66 -0
  19. package/dist/css/rules/dialog.css +11 -0
  20. package/dist/css/rules/filter-bar.css +8 -1
  21. package/dist/css/rules/panel.css +9 -1
  22. package/dist/css/rules/select.css +25 -1
  23. package/dist/css/rules/sizing.css +10 -0
  24. package/dist/css/rules/stat.css +57 -0
  25. package/dist/css/rules/time-range-picker.css +14 -0
  26. package/dist/css/styles.css +3 -0
  27. package/dist/css/time-range-picker.css +14 -0
  28. package/dist/dashboard.d.ts +46 -0
  29. package/dist/dashboard.js +35 -0
  30. package/dist/icon.d.ts +10 -0
  31. package/dist/icon.js +11 -0
  32. package/dist/index.d.ts +6 -3
  33. package/dist/index.js +5 -2
  34. package/dist/select.d.ts +33 -6
  35. package/dist/select.js +94 -21
  36. package/dist/stat.d.ts +20 -0
  37. package/dist/stat.js +15 -1
  38. package/dist/time-range-picker.d.ts +42 -0
  39. package/dist/time-range-picker.js +69 -0
  40. package/package.json +18 -3
package/dist/chart.js CHANGED
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import * as React from 'react';
4
- import { Area, Bar, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, useActiveTooltipCoordinate, useActiveTooltipLabel, useIsTooltipActive, usePlotArea, } from 'recharts';
4
+ import { Area, Bar, CartesianGrid, ComposedChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, useActiveTooltipCoordinate, useActiveTooltipLabel, useIsTooltipActive, usePlotArea, } from 'recharts';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { Table } from './table.js';
7
7
  import { Button } from './button.js';
8
+ import { Popover } from './popover.js';
8
9
  import { cx } from './parts.js';
9
10
  const chartLabels = {
10
11
  missing: 'Not available',
@@ -20,6 +21,8 @@ const chartLabels = {
20
21
  noRows: 'No rows.',
21
22
  legend: (title) => `${title} series`,
22
23
  series: (label) => `${label} series`,
24
+ moreSeries: (count) => `${count} more series`,
25
+ total: 'Total',
23
26
  };
24
27
  /** The round step at or above a raw step: 1, 2, 2.5 or 5 times a power of ten. */
25
28
  function roundStep(raw) {
@@ -37,6 +40,83 @@ function axisStep(low, high) {
37
40
  return step;
38
41
  }
39
42
  const dashes = [undefined, '7 4', '2 4', '10 3 2 3', '12 5', '4 3'];
43
+ /** A series' color: the caller's, or the palette's for its place. */
44
+ function seriesColor(index, palette, color) {
45
+ return (color ??
46
+ (palette === 'categorical'
47
+ ? `var(--surf-chart-category-${(index % 12) + 1})`
48
+ : `var(--surf-chart-series-${(index % 3) + 1})`));
49
+ }
50
+ const toneColors = {
51
+ neutral: 'var(--surf-ink-muted)',
52
+ caution: 'var(--surf-status-caution-fg)',
53
+ danger: 'var(--surf-status-danger-fg)',
54
+ };
55
+ const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
56
+ const timeSteps = [
57
+ ['minute', 1, MINUTE],
58
+ ['minute', 5, 5 * MINUTE],
59
+ ['minute', 15, 15 * MINUTE],
60
+ ['minute', 30, 30 * MINUTE],
61
+ ['hour', 1, HOUR],
62
+ ['hour', 3, 3 * HOUR],
63
+ ['hour', 6, 6 * HOUR],
64
+ ['hour', 12, 12 * HOUR],
65
+ ['day', 1, DAY],
66
+ ['day', 2, 2 * DAY],
67
+ ['day', 7, 7 * DAY],
68
+ ['month', 1, 30 * DAY],
69
+ ['month', 3, 91 * DAY],
70
+ ['month', 6, 182 * DAY],
71
+ ['year', 1, 365 * DAY],
72
+ ];
73
+ /** Ticks on round local times, such as every 6 hours or the first of each month, at
74
+ * most six across the span. */
75
+ function timeTicks(min, max) {
76
+ const [unit, count] = timeSteps.find(([, , ms]) => (max - min) / ms <= 6) ??
77
+ timeSteps[timeSteps.length - 1];
78
+ const date = new Date(min);
79
+ date.setSeconds(0, 0);
80
+ if (unit === 'minute')
81
+ date.setMinutes(Math.floor(date.getMinutes() / count) * count);
82
+ else
83
+ date.setMinutes(0);
84
+ if (unit === 'hour')
85
+ date.setHours(Math.floor(date.getHours() / count) * count);
86
+ else if (unit !== 'minute')
87
+ date.setHours(0);
88
+ if (unit === 'month')
89
+ date.setMonth(Math.floor(date.getMonth() / count) * count, 1);
90
+ if (unit === 'year')
91
+ date.setMonth(0, 1);
92
+ const ticks = [];
93
+ while (date.getTime() <= max && ticks.length < 100) {
94
+ if (date.getTime() >= min)
95
+ ticks.push(date.getTime());
96
+ if (unit === 'minute')
97
+ date.setMinutes(date.getMinutes() + count);
98
+ else if (unit === 'hour')
99
+ date.setHours(date.getHours() + count);
100
+ else if (unit === 'day')
101
+ date.setDate(date.getDate() + count);
102
+ else if (unit === 'month')
103
+ date.setMonth(date.getMonth() + count);
104
+ else
105
+ date.setFullYear(date.getFullYear() + count);
106
+ }
107
+ return { ticks, unit };
108
+ }
109
+ /** Powers of ten that span low to high, with 2 and 5 between them across one decade. */
110
+ function logTicks(low, high) {
111
+ const bottom = Math.floor(Math.log10(low)), top = Math.max(Math.ceil(Math.log10(high)), bottom + 1);
112
+ const ticks = [];
113
+ for (let power = bottom; power <= top; power++)
114
+ for (const factor of top - bottom === 1 ? [1, 2, 5] : [1])
115
+ if (power < top || factor === 1)
116
+ ticks.push(Number((factor * 10 ** power).toPrecision(12)));
117
+ return ticks;
118
+ }
119
+ const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
40
120
  /** True once the font of the tick labels has loaded. Recharts measures a label to keep
41
121
  * it inside the chart and caches that width; measured in the fallback font, the last
42
122
  * label overflows by a few pixels once the real font arrives. */
@@ -79,6 +159,15 @@ function Pattern({ id, index, color, }) {
79
159
  function SeriesMark({ type, index, color, pattern, }) {
80
160
  return (_jsx("svg", { className: 'surf-chart-mark', viewBox: '0 0 32 20', "aria-hidden": 'true', focusable: 'false', children: type === 'bar' ? (_jsxs(_Fragment, { children: [_jsx("defs", { children: _jsx(Pattern, { id: pattern, color: color, index: index }) }), _jsx("rect", { x: '4', y: '3', width: '24', height: '14', rx: '2', fill: `url(#${pattern})` })] })) : (_jsxs(_Fragment, { children: [_jsx("path", { d: 'M1 10h30', stroke: color, strokeWidth: '2', strokeDasharray: dashes[index % dashes.length] }), _jsx(Marker, { cx: 16, cy: 10, index: index, color: color })] })) }));
81
161
  }
162
+ function ReferenceMark({ reference }) {
163
+ const color = toneColors[reference.tone ?? 'neutral'];
164
+ return (_jsx("svg", { className: 'surf-chart-mark', viewBox: '0 0 32 20', "aria-hidden": 'true', focusable: 'false', children: 'y' in reference ? (_jsx("path", { d: 'M1 10h30', stroke: color, strokeWidth: '2', strokeDasharray: '4 3' })) : (_jsx("rect", { x: '4', y: '4', width: '24', height: '12', rx: '2', fill: color, fillOpacity: '0.2' })) }));
165
+ }
166
+ /** A series' swatch as Chart's legend draws it, for a legend or key of your own. */
167
+ export function ChartSeriesMark({ type = 'line', index, palette = 'neutral', color, }) {
168
+ const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, '_');
169
+ return (_jsx(SeriesMark, { type: type, index: index, color: seriesColor(index, palette, color), pattern: `${id}-mark` }));
170
+ }
82
171
  /** The active point's values beside it, for pointer and touch users. The readout
83
172
  * below the plot announces the same values, so this copy stays out of the
84
173
  * accessibility tree. It sits on the side of the point with more room and stays
@@ -95,22 +184,26 @@ function FloatingValues({ host, hidden, render, }) {
95
184
  if (shown && box.current)
96
185
  setWidth(box.current.offsetWidth);
97
186
  }, [shown, label, plot?.width]);
98
- if (!shown || !point || !plot)
187
+ const content = shown ? render(String(label)) : null;
188
+ // A break in sampling has no values to show.
189
+ if (!shown || !point || !plot || content === null)
99
190
  return null;
100
191
  // The gap between the point and the box, in px: the --surf-space-3 step.
101
192
  const gap = 12;
102
193
  const start = point.x > plot.x + plot.width / 2;
103
194
  const left = Math.min(Math.max(start ? point.x - gap - width : point.x + gap, plot.x), plot.x + plot.width - width);
104
- return createPortal(_jsx("div", { ref: box, className: 'surf-chart-tooltip', "aria-hidden": 'true', style: { left, top: plot.y, visibility: width ? undefined : 'hidden' }, children: render(String(label)) }), host);
195
+ return createPortal(_jsx("div", { ref: box, className: 'surf-chart-tooltip', "aria-hidden": 'true', style: { left, top: plot.y, visibility: width ? undefined : 'hidden' }, children: content }), host);
105
196
  }
106
197
  /** Cartesian trends with a persistent, keyboard-readable tooltip region and a
107
198
  * full data-table alternative. Missing values are gaps, never fabricated zeros. */
108
- export const Chart = function Chart({ title, description, data, series, unit, xLabel, yLabel, type = 'line', height = 280, includeZero = true, palette = 'neutral', locale = 'en-US', formatValue, labels, hiddenSeries, defaultHiddenSeries = [], onHiddenSeriesChange, className, ref, ...props }) {
199
+ export const Chart = function Chart({ title, description, data, series, unit, xLabel, yLabel, type = 'line', height, includeZero = true, palette = 'neutral', xScale = 'category', yScale = 'linear', curve = 'linear', stacked = false, references = [], density = 'default', legendPosition = 'top', legendLimit, locale = 'en-US', formatValue, labels, hiddenSeries, defaultHiddenSeries = [], onHiddenSeriesChange, className, ref, ...props }) {
109
200
  const text = { ...chartLabels, ...labels };
110
201
  const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, '_');
111
- const colorFor = (index) => palette === 'categorical'
112
- ? `var(--surf-chart-category-${(index % 6) + 1})`
113
- : `var(--surf-chart-series-${(index % 3) + 1})`;
202
+ const colorFor = (index) => seriesColor(index, palette, series[index]?.color);
203
+ const timeScale = xScale === 'time';
204
+ const logScale = yScale === 'log';
205
+ const stacks = stacked && type !== 'line';
206
+ const compact = density === 'compact';
114
207
  const [internalHidden, setInternalHidden] = React.useState(defaultHiddenSeries);
115
208
  const hidden = new Set(hiddenSeries ?? internalHidden);
116
209
  const visible = series.filter((s) => !hidden.has(s.key));
@@ -118,6 +211,8 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
118
211
  const fontsReady = useFontReady(host, data.map((d) => d.label).join(''));
119
212
  const [floatHost, setFloatHost] = React.useState(null);
120
213
  const [dismissed, setDismissed] = React.useState(false);
214
+ // The table renders once it is first opened, so a page of charts stays light.
215
+ const [tableOpened, setTableOpened] = React.useState(false);
121
216
  const firstLegend = React.useRef(null);
122
217
  const [restoreRequested, setRestoreRequested] = React.useState(false);
123
218
  React.useLayoutEffect(() => {
@@ -128,13 +223,20 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
128
223
  setRestoreRequested(false);
129
224
  }, [restoreRequested, visible.length]);
130
225
  const number = React.useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }), [locale]);
131
- const compact = React.useMemo(() => new Intl.NumberFormat(locale, {
226
+ const compactNumber = React.useMemo(() => new Intl.NumberFormat(locale, {
132
227
  notation: 'compact',
133
228
  maximumFractionDigits: 1,
134
229
  }), [locale]);
135
- const format = (value) => typeof value === 'number' && Number.isFinite(value)
136
- ? `${formatValue ? formatValue(value) : number.format(value)} ${unit}`
137
- : text.missing;
230
+ const formatNumber = (value) => isNumber(value)
231
+ ? formatValue
232
+ ? formatValue(value)
233
+ : number.format(value)
234
+ : null;
235
+ // The readout names the unit after each value; the table names it once, in the header.
236
+ const format = (value) => {
237
+ const formatted = formatNumber(value);
238
+ return formatted === null ? text.missing : `${formatted} ${unit}`;
239
+ };
138
240
  const rows = React.useMemo(() => {
139
241
  const ids = new Set(), keys = new Set();
140
242
  for (const s of series) {
@@ -142,38 +244,130 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
142
244
  throw new Error('Chart series keys must be non-empty and unique');
143
245
  keys.add(s.key);
144
246
  }
145
- return data.map((row) => {
247
+ const result = data.map((row) => {
146
248
  if (!row.id || ids.has(row.id))
147
249
  throw new Error('Chart datum IDs must be non-empty and unique');
148
250
  ids.add(row.id);
251
+ const time = row.time === undefined ? undefined : Number(row.time);
252
+ if (timeScale && !isNumber(time))
253
+ throw new Error('Chart rows need a time when xScale is "time"');
149
254
  return {
150
255
  ...row,
256
+ time,
151
257
  values: Object.fromEntries(series.map((s) => [
152
258
  s.key,
153
- typeof row.values[s.key] === 'number' &&
154
- Number.isFinite(row.values[s.key])
155
- ? row.values[s.key]
156
- : null,
259
+ isNumber(row.values[s.key]) ? row.values[s.key] : null,
157
260
  ])),
158
261
  };
159
262
  });
160
- }, [data, series]);
263
+ return timeScale ? result.sort((a, b) => a.time - b.time) : result;
264
+ }, [data, series, timeScale]);
161
265
  const byId = new Map(rows.map((row) => [row.id, row]));
266
+ const byTime = new Map(rows.map((row) => [row.time, row]));
267
+ const rowFor = (label) => timeScale ? byTime.get(Number(label)) : byId.get(String(label));
268
+ // On a time axis, a wait of more than twice the usual interval is a break in
269
+ // sampling: an empty row there keeps the line from bridging it.
270
+ const interval = React.useMemo(() => {
271
+ if (!timeScale || rows.length < 3)
272
+ return 0;
273
+ const steps = rows
274
+ .slice(1)
275
+ .map((row, index) => row.time - rows[index].time)
276
+ .sort((a, b) => a - b);
277
+ return steps[Math.floor(steps.length / 2)];
278
+ }, [rows, timeScale]);
279
+ const plotRows = React.useMemo(() => {
280
+ if (!interval)
281
+ return rows;
282
+ const empty = Object.fromEntries(series.map((s) => [s.key, null]));
283
+ return rows.flatMap((row, index) => {
284
+ const next = rows[index + 1];
285
+ return next && next.time - row.time > interval * 2
286
+ ? [
287
+ row,
288
+ {
289
+ ...row,
290
+ id: `${row.id}-gap`,
291
+ time: row.time + interval,
292
+ values: empty,
293
+ },
294
+ ]
295
+ : [row];
296
+ });
297
+ }, [rows, series, interval]);
298
+ const valueOf = (row, key) => {
299
+ const value = row.values[key];
300
+ return value === null || (logScale && value <= 0) ? null : value;
301
+ };
302
+ // The value axis spans the visible values (their sums when stacked) and the
303
+ // references, widened to round steps so its ticks read cleanly.
162
304
  const values = rows
163
- .flatMap((row) => visible.map((s) => row.values[s.key]))
164
- .filter((value) => typeof value === 'number');
165
- // Bars grow from zero, so they always keep it on the axis. Either way the axis
166
- // spans the values, widened to round steps so its ticks read cleanly.
167
- const keepZero = includeZero || type === 'bar';
168
- const start = keepZero ? 0 : (values[0] ?? 0);
169
- const low = values.reduce((a, b) => Math.min(a, b), start), high = values.reduce((a, b) => Math.max(a, b), start);
170
- const step = axisStep(low, high);
171
- const min = step ? Math.floor(low / step) * step : low, max = step ? Math.ceil(high / step) * step : high;
172
- const ticks = step
173
- ? Array.from({ length: Math.round((max - min) / step) + 1 },
174
- // Rounded, so float steps never print as 0.30000000000000004.
175
- (_, index) => Number((min + index * step).toPrecision(12)))
176
- : undefined;
305
+ .flatMap((row) => visible.map((s) => valueOf(row, s.key)))
306
+ .filter(isNumber);
307
+ const sums = stacks
308
+ ? rows.flatMap((row) => {
309
+ const parts = visible.map((s) => valueOf(row, s.key)).filter(isNumber);
310
+ return [
311
+ parts.filter((v) => v > 0).reduce((a, b) => a + b, 0),
312
+ parts.filter((v) => v < 0).reduce((a, b) => a + b, 0),
313
+ ];
314
+ })
315
+ : values;
316
+ const referenceValues = references
317
+ .flatMap((r) => ('y' in r ? [r.y] : [r.from, r.to]))
318
+ .filter((v) => isNumber(v) && (!logScale || v > 0));
319
+ const extent = [...sums, ...(values.length ? referenceValues : [])];
320
+ // Bars grow from zero, so they always keep it on a linear axis.
321
+ const keepZero = !logScale && (includeZero || type === 'bar');
322
+ const start = keepZero ? 0 : (extent[0] ?? 0);
323
+ const low = extent.reduce((a, b) => Math.min(a, b), start), high = extent.reduce((a, b) => Math.max(a, b), start);
324
+ let min = low, max = high, ticks;
325
+ if (logScale) {
326
+ ticks = logTicks(low > 0 ? low : 1, high > 0 ? high : 10);
327
+ min = ticks[0];
328
+ max = ticks[ticks.length - 1];
329
+ }
330
+ else {
331
+ const step = axisStep(low, high);
332
+ if (step) {
333
+ min = Math.floor(low / step) * step;
334
+ max = Math.ceil(high / step) * step;
335
+ ticks = Array.from({ length: Math.round((max - min) / step) + 1 },
336
+ // Rounded, so float steps never print as 0.30000000000000004.
337
+ (_, index) => Number((min + index * step).toPrecision(12)));
338
+ }
339
+ }
340
+ const time = React.useMemo(() => {
341
+ if (!timeScale || !rows.length)
342
+ return undefined;
343
+ const first = rows[0].time, last = rows[rows.length - 1].time;
344
+ const { ticks: timeAxisTicks, unit: timeUnit } = timeTicks(first, last);
345
+ // Bars sit on their time, so half an interval on each side keeps the end ones whole.
346
+ const pad = type === 'bar' ? (interval || DAY) / 2 : 0;
347
+ const subDay = timeUnit === 'minute' || timeUnit === 'hour';
348
+ const tickFormat = new Intl.DateTimeFormat(locale, subDay
349
+ ? { hour: 'numeric', minute: '2-digit' }
350
+ : timeUnit === 'day'
351
+ ? { month: 'short', day: 'numeric' }
352
+ : timeUnit === 'month'
353
+ ? { month: 'short', year: 'numeric' }
354
+ : { year: 'numeric' });
355
+ const dayFormat = new Intl.DateTimeFormat(locale, {
356
+ month: 'short',
357
+ day: 'numeric',
358
+ });
359
+ return {
360
+ domain: [first - pad, last + pad],
361
+ ticks: timeAxisTicks,
362
+ // Within a day the ticks name hours; one at midnight names the day instead.
363
+ format: (value) => {
364
+ const date = new Date(value);
365
+ return subDay && date.getHours() === 0 && date.getMinutes() === 0
366
+ ? dayFormat.format(date)
367
+ : tickFormat.format(date);
368
+ },
369
+ };
370
+ }, [rows, timeScale, type, interval, locale]);
177
371
  const hasValues = values.length > 0;
178
372
  const changeHidden = (next) => {
179
373
  if (hiddenSeries === undefined)
@@ -183,14 +377,34 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
183
377
  const toggle = (key) => changeHidden(series
184
378
  .filter((s) => (s.key === key ? !hidden.has(key) : hidden.has(s.key)))
185
379
  .map((s) => s.key));
186
- const chartHeight = Number.isFinite(height) ? Math.max(200, height) : 280;
187
- return (_jsxs("figure", { ...props, ref: ref, className: cx('surf-chart', className), "aria-labelledby": `${id}-title`, children: [_jsxs("figcaption", { className: 'surf-chart-caption', children: [_jsx("strong", { id: `${id}-title`, children: title }), _jsx("p", { id: `${id}-summary`, children: description })] }), _jsxs("div", { className: 'surf-chart-frame', children: [_jsxs("div", { className: 'surf-chart-key', children: [_jsxs("p", { className: 'surf-chart-axis-label', children: [yLabel, " (", unit, ")"] }), _jsx("ul", { className: 'surf-chart-legend', "aria-label": text.legend(title), children: series.map((s, index) => (_jsx("li", { children: _jsxs("button", { ref: index === 0 ? firstLegend : undefined, type: 'button', className: 'surf-chart-legend-button', "aria-label": text.series(s.label), "aria-pressed": !hidden.has(s.key), onClick: () => toggle(s.key), children: [_jsx(SeriesMark, { type: type, index: index, color: colorFor(index), pattern: `${id}-legend-${index}` }), _jsx("span", { children: s.label })] }) }, s.key))) })] }), hasValues ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: 'surf-chart-plot', onKeyDownCapture: (event) => {
380
+ const chartHeight = height !== undefined && Number.isFinite(height)
381
+ ? Math.max(compact ? 120 : 200, height)
382
+ : compact
383
+ ? 180
384
+ : 280;
385
+ const total = (row) => visible
386
+ .map((s) => row.values[s.key])
387
+ .filter(isNumber)
388
+ .reduce((a, b) => a + b, 0);
389
+ const referenceText = (r) => 'y' in r
390
+ ? `${r.label} ${format(r.y)}`
391
+ : `${r.label} ${formatNumber(r.from) ?? text.missing}–${format(r.to)}`;
392
+ const legendButton = (s, index) => (_jsxs("button", { ref: index === 0 ? firstLegend : undefined, type: 'button', className: 'surf-chart-legend-button', "aria-label": text.series(s.label), "aria-pressed": !hidden.has(s.key), onClick: () => toggle(s.key), children: [_jsx(SeriesMark, { type: type, index: index, color: colorFor(index), pattern: `${id}-legend-${index}` }), _jsx("span", { children: s.label })] }));
393
+ const shown = legendLimit !== undefined && legendLimit < series.length
394
+ ? series.slice(0, Math.max(0, legendLimit))
395
+ : series;
396
+ const folded = series.slice(shown.length);
397
+ const legend = (_jsxs("ul", { className: 'surf-chart-legend', "aria-label": text.legend(title), children: [shown.map((s, index) => (_jsx("li", { children: legendButton(s, index) }, s.key))), folded.length > 0 && (_jsx("li", { children: _jsxs(Popover.Root, { children: [_jsxs(Popover.Trigger, { className: 'surf-chart-legend-button surf-chart-legend-more', "aria-label": text.moreSeries(folded.length), children: ["+", folded.length] }), _jsx(Popover.Content, { size: 'small', children: _jsx("ul", { className: 'surf-chart-legend-list', children: folded.map((s, index) => (_jsx("li", { children: legendButton(s, shown.length + index) }, s.key))) }) })] }) })), references.map((reference, index) => (_jsxs("li", { className: 'surf-chart-legend-reference', children: [_jsx(ReferenceMark, { reference: reference }), _jsx("span", { children: referenceText(reference) })] }, index)))] }));
398
+ return (_jsxs("figure", { ...props, ref: ref, "data-density": density, className: cx('surf-chart', className), "aria-labelledby": `${id}-title`, children: [_jsxs("figcaption", { className: 'surf-chart-caption', children: [_jsx("strong", { id: `${id}-title`, children: title }), _jsx("p", { id: `${id}-summary`, children: description })] }), _jsxs("div", { className: 'surf-chart-frame', children: [_jsxs("div", { className: 'surf-chart-key', children: [_jsxs("p", { className: 'surf-chart-axis-label', children: [yLabel, " (", unit, ")"] }), legendPosition === 'top' && legend] }), hasValues ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: 'surf-chart-plot', onKeyDownCapture: (event) => {
188
399
  if (event.key === 'Escape')
189
400
  setDismissed(true);
190
401
  else if (event.key === 'ArrowLeft' ||
191
402
  event.key === 'ArrowRight')
192
403
  setDismissed(false);
193
- }, onMouseMoveCapture: () => setDismissed(false), onTouchStartCapture: () => setDismissed(false), onFocusCapture: () => setDismissed(false), children: [_jsx(ResponsiveContainer, { width: '100%', height: chartHeight, minWidth: 0, initialDimension: { width: 320, height: chartHeight }, children: _jsxs(ComposedChart, { data: rows, accessibilityLayer: true, role: 'application', "aria-label": title, "aria-describedby": `${id}-summary ${id}-keys`, margin: { top: 16, right: 20, bottom: 8, left: 0 }, children: [_jsx("defs", { children: series.map((s, index) => (_jsx(Pattern, { id: `${id}-pattern-${index}`, index: index, color: colorFor(index) }, s.key))) }), _jsx(CartesianGrid, { vertical: false, stroke: 'var(--surf-line)' }), _jsx(XAxis, { dataKey: 'id', tickFormatter: (value) => {
404
+ }, onMouseMoveCapture: () => setDismissed(false), onTouchStartCapture: () => setDismissed(false), onFocusCapture: () => setDismissed(false), children: [_jsx(ResponsiveContainer, { width: '100%', height: chartHeight, minWidth: 0, initialDimension: { width: 320, height: chartHeight }, children: _jsxs(ComposedChart, { data: plotRows, accessibilityLayer: true, role: 'application', "aria-label": title, "aria-describedby": `${id}-summary ${id}-keys`, margin: { top: 16, right: 20, bottom: 8, left: 0 }, baseValue: logScale ? min : undefined, children: [_jsx("defs", { children: series.map((s, index) => (_jsx(Pattern, { id: `${id}-pattern-${index}`, index: index, color: colorFor(index) }, s.key))) }), _jsx(CartesianGrid, { vertical: false, stroke: 'var(--surf-line)' }), references.map((reference, index) => 'y' in reference ? null : (_jsx(ReferenceArea, { y1: reference.from, y2: reference.to, fill: toneColors[reference.tone ?? 'neutral'], fillOpacity: 0.12, stroke: 'none' }, index))), time ? (_jsx(XAxis, { dataKey: 'time', type: 'number', scale: 'time', domain: time.domain, ticks: time.ticks, tickFormatter: (value) => time.format(Number(value)), tick: fontsReady && {
405
+ fill: 'var(--surf-ink-muted)',
406
+ fontSize: 12,
407
+ }, tickLine: false, axisLine: { stroke: 'var(--surf-line)' }, minTickGap: 24, interval: 'preserveStartEnd' })) : (_jsx(XAxis, { dataKey: 'id', tickFormatter: (value) => {
194
408
  const label = byId.get(String(value))?.label ?? String(value);
195
409
  // minTickGap already drops ticks that would overlap; the cap
196
410
  // only keeps one very long label from crowding the axis.
@@ -200,9 +414,9 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
200
414
  }, tick: fontsReady && {
201
415
  fill: 'var(--surf-ink-muted)',
202
416
  fontSize: 12,
203
- }, tickLine: false, axisLine: { stroke: 'var(--surf-line)' }, minTickGap: 24, interval: 'preserveStartEnd' }), _jsx(YAxis, { width: 56, tickFormatter: (value) => formatValue
417
+ }, tickLine: false, axisLine: { stroke: 'var(--surf-line)' }, minTickGap: 24, interval: 'preserveStartEnd' })), _jsx(YAxis, { width: 56, scale: logScale ? 'log' : 'auto', tickFormatter: (value) => formatValue
204
418
  ? formatValue(Number(value))
205
- : compact.format(Number(value)), tick: { fill: 'var(--surf-ink-muted)', fontSize: 12 }, tickLine: false, axisLine: false, domain: [min, max === min ? max + 1 : max], ticks: ticks }), _jsx(ReferenceLine, { y: 0, stroke: 'var(--surf-ink-muted)' }), host && (_jsx(Tooltip, { portal: host, isAnimationActive: false, active: dismissed ? false : undefined, filterNull: false, cursor: dismissed
419
+ : compactNumber.format(Number(value)), tick: { fill: 'var(--surf-ink-muted)', fontSize: 12 }, tickLine: false, axisLine: false, domain: [min, max === min ? max + 1 : max], ticks: ticks, allowDataOverflow: logScale }), !logScale && (_jsx(ReferenceLine, { y: 0, stroke: 'var(--surf-ink-muted)' })), host && (_jsx(Tooltip, { portal: host, isAnimationActive: false, active: dismissed ? false : undefined, filterNull: false, cursor: dismissed
206
420
  ? false
207
421
  : {
208
422
  stroke: 'var(--surf-control-border)',
@@ -213,29 +427,33 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
213
427
  pointerEvents: 'auto',
214
428
  }, content: (tooltip) => {
215
429
  const row = tooltip.active && !dismissed
216
- ? byId.get(String(tooltip.label))
430
+ ? rowFor(tooltip.label)
217
431
  : undefined;
218
- return (_jsx("div", { className: 'surf-chart-readout', "data-chart-readout": row?.id, children: row ? (_jsxs(_Fragment, { children: [_jsx("strong", { children: row.label }), _jsx("ul", { children: visible.map((s) => (_jsxs("li", { children: [s.label, ": ", format(row.values[s.key])] }, s.key))) })] })) : (_jsx("span", { children: text.inspectHint })) }));
432
+ return (_jsx("div", { className: 'surf-chart-readout', "data-chart-readout": row?.id, children: row ? (_jsxs(_Fragment, { children: [_jsx("strong", { children: row.label }), _jsxs("ul", { children: [visible.map((s) => (_jsxs("li", { children: [s.label, ": ", format(row.values[s.key])] }, s.key))), stacks && (_jsxs("li", { children: [text.total, ": ", format(total(row))] }))] })] })) : (_jsx("span", { children: text.inspectHint })) }));
219
433
  } })), series.map((s, index) => {
220
434
  const common = {
221
435
  name: s.label,
222
- dataKey: (row) => row.values[s.key] ?? null,
436
+ dataKey: (row) => valueOf(row, s.key),
223
437
  hide: hidden.has(s.key),
224
438
  isAnimationActive: false,
225
439
  stroke: colorFor(index),
440
+ stackId: stacks ? 'total' : undefined,
226
441
  };
227
- return type === 'bar' ? (_jsx(Bar, { ...common, fill: `url(#${id}-pattern-${index})`, maxBarSize: 48 }, s.key)) : type === 'area' ? (_jsx(Area, { ...common, type: 'linear', strokeWidth: 2, strokeDasharray: dashes[index % dashes.length], fill: `url(#${id}-pattern-${index})`, fillOpacity: 0.12, connectNulls: false, dot: rows.length > 40
228
- ? false
229
- : (dot) => (_jsx(Marker, { ...dot, color: colorFor(index), index: index })), activeDot: false }, s.key)) : (_jsx(Line, { ...common, type: 'linear', strokeWidth: 2, strokeDasharray: dashes[index % dashes.length], connectNulls: false, dot: rows.length > 40
230
- ? false
231
- : (dot) => (_jsx(Marker, { ...dot, color: colorFor(index), index: index })), activeDot: false }, s.key));
232
- }), _jsx(FloatingValues, { host: floatHost, hidden: dismissed, render: (label) => {
233
- const row = byId.get(label);
442
+ const dot = rows.length > 40
443
+ ? false
444
+ : (dot) => (_jsx(Marker, { cx: dot.cx, cy: dot.cy, color: colorFor(index), index: index }));
445
+ const shape = curve === 'step' ? 'stepAfter' : 'linear';
446
+ return type === 'bar' ? (_jsx(Bar, { ...common, fill: `url(#${id}-pattern-${index})`, maxBarSize: 48 }, s.key)) : type === 'area' ? (_jsx(Area, { ...common, type: shape, strokeWidth: 2, strokeDasharray: dashes[index % dashes.length], fill: `url(#${id}-pattern-${index})`, fillOpacity: stacks ? 0.32 : 0.12, connectNulls: false, dot: dot, activeDot: false }, s.key)) : (_jsx(Line, { ...common, type: shape, strokeWidth: 2, strokeDasharray: dashes[index % dashes.length], connectNulls: false, dot: dot, activeDot: false }, s.key));
447
+ }), references.map((reference, index) => 'y' in reference ? (_jsx(ReferenceLine, { y: reference.y, stroke: toneColors[reference.tone ?? 'neutral'], strokeWidth: 1.5, strokeDasharray: '4 3' }, index)) : null), _jsx(FloatingValues, { host: floatHost, hidden: dismissed, render: (label) => {
448
+ const row = rowFor(label);
234
449
  if (!row)
235
450
  return null;
236
- return (_jsxs(_Fragment, { children: [_jsx("strong", { children: row.label }), _jsx("ul", { children: series.map((s, index) => hidden.has(s.key) ? null : (_jsxs("li", { children: [_jsx(SeriesMark, { type: type, index: index, color: colorFor(index), pattern: `${id}-float-${index}` }), _jsx("span", { children: s.label }), _jsx("span", { className: 'surf-chart-tooltip-value', children: format(row.values[s.key]) })] }, s.key))) })] }));
451
+ return (_jsxs(_Fragment, { children: [_jsx("strong", { children: row.label }), _jsxs("ul", { children: [series.map((s, index) => hidden.has(s.key) ? null : (_jsxs("li", { children: [_jsx(SeriesMark, { type: type, index: index, color: colorFor(index), pattern: `${id}-float-${index}` }), _jsx("span", { children: s.label }), _jsx("span", { className: 'surf-chart-tooltip-value', children: format(row.values[s.key]) })] }, s.key))), stacks && (_jsxs("li", { className: 'surf-chart-tooltip-total', children: [_jsx("span", { children: text.total }), _jsx("span", { className: 'surf-chart-tooltip-value', children: format(total(row)) })] }))] })] }));
237
452
  } })] }) }), _jsx("div", { className: 'surf-chart-float', ref: setFloatHost })] }), _jsx("p", { className: 'surf-chart-axis-label surf-chart-x-label', children: xLabel })] })) : (_jsx("div", { className: 'surf-chart-empty', role: 'status', children: visible.length === 0 && series.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { children: text.noSeries }), _jsx(Button, { variant: 'outline', onClick: () => {
238
453
  setRestoreRequested(true);
239
454
  changeHidden([]);
240
- }, children: text.showAll })] })) : (_jsx("p", { children: text.empty })) }))] }), hasValues && (_jsx("p", { className: 'surf-chart-help', id: `${id}-keys`, children: text.keyboardHelp })), _jsx("div", { className: 'surf-chart-inspector', ref: setHost, role: 'status', "aria-live": 'polite', "aria-atomic": 'true', "aria-label": text.values(title), hidden: !hasValues }), _jsxs("details", { className: 'surf-chart-table', children: [_jsx("summary", { children: text.viewTable }), _jsx(Table.Container, { label: text.table(title), orientation: 'both', scrollChain: 'contain', children: _jsxs(Table.Root, { children: [_jsx(Table.Caption, { children: text.tableCaption(unit) }), _jsx(Table.Head, { children: _jsxs(Table.Row, { children: [_jsx(Table.HeaderCell, { scope: 'col', children: xLabel }), series.map((s) => (_jsxs(Table.HeaderCell, { scope: 'col', children: [s.label, " (", unit, ")"] }, s.key)))] }) }), _jsx(Table.Body, { children: rows.length ? (rows.map((row) => (_jsxs(Table.Row, { children: [_jsx(Table.HeaderCell, { scope: 'row', children: row.label }), series.map((s) => (_jsx(Table.Cell, { children: format(row.values[s.key]) }, s.key)))] }, row.id)))) : (_jsx(Table.Row, { children: _jsx(Table.Cell, { colSpan: series.length + 1, children: text.noRows }) })) })] }) })] })] }));
455
+ }, children: text.showAll })] })) : (_jsx("p", { children: text.empty })) })), legendPosition === 'bottom' && (_jsx("div", { className: 'surf-chart-key', "data-position": 'bottom', children: legend }))] }), hasValues && (_jsx("p", { className: 'surf-chart-help', id: `${id}-keys`, children: text.keyboardHelp })), _jsx("div", { className: 'surf-chart-inspector', ref: setHost, role: 'status', "aria-live": 'polite', "aria-atomic": 'true', "aria-label": text.values(title), hidden: !hasValues }), _jsxs("details", { className: 'surf-chart-table', onToggle: (event) => {
456
+ if (event.currentTarget.open)
457
+ setTableOpened(true);
458
+ }, children: [_jsx("summary", { children: text.viewTable }), tableOpened && (_jsx(Table.Container, { label: text.table(title), orientation: 'both', scrollChain: 'contain', children: _jsxs(Table.Root, { children: [_jsx(Table.Caption, { children: text.tableCaption(unit) }), _jsx(Table.Head, { children: _jsxs(Table.Row, { children: [_jsx(Table.HeaderCell, { scope: 'col', children: xLabel }), series.map((s) => (_jsxs(Table.HeaderCell, { scope: 'col', numeric: true, children: [s.label, " (", unit, ")"] }, s.key)))] }) }), _jsx(Table.Body, { children: rows.length ? (rows.map((row) => (_jsxs(Table.Row, { children: [_jsx(Table.HeaderCell, { scope: 'row', children: row.label }), series.map((s) => (_jsx(Table.Cell, { numeric: true, children: formatNumber(row.values[s.key]) ?? text.missing }, s.key)))] }, row.id)))) : (_jsx(Table.Row, { children: _jsx(Table.Cell, { colSpan: series.length + 1, children: text.noRows }) })) })] }) }))] })] }));
241
459
  };
@@ -0,0 +1,9 @@
1
+ /* Bar list, with the styles of what it renders. */
2
+ @layer surflane.tokens, surflane.themes, surflane.base, surflane.components,
3
+ surflane.sizes;
4
+ @import '@surflane/design-tokens/tokens.css';
5
+ @import '@surflane/css/presentation/base.css';
6
+ @import './rules/base.css';
7
+ @import './rules/sizing.css';
8
+ @import './rules/scroll-area.css';
9
+ @import './rules/bar-list.css';
@@ -5,7 +5,9 @@
5
5
  @import '@surflane/css/presentation/base.css';
6
6
  @import './rules/base.css';
7
7
  @import './rules/sizing.css';
8
+ @import './rules/popup.css';
8
9
  @import './rules/icon.css';
10
+ @import './rules/popover.css';
9
11
  @import './rules/button.css';
10
12
  @import './rules/scroll-area.css';
11
13
  @import '@surflane/css/presentation/table.css';
@@ -0,0 +1,20 @@
1
+ /* Dashboard, with the styles of what it renders. */
2
+ @layer surflane.tokens, surflane.themes, surflane.base, surflane.components,
3
+ surflane.sizes;
4
+ @import '@surflane/design-tokens/tokens.css';
5
+ @import '@surflane/css/presentation/base.css';
6
+ @import './rules/base.css';
7
+ @import './rules/sizing.css';
8
+ @import './rules/popup.css';
9
+ @import './rules/icon.css';
10
+ @import './rules/popover.css';
11
+ @import './rules/button.css';
12
+ @import './rules/spinner.css';
13
+ @import './rules/skeleton.css';
14
+ @import '@surflane/css/foundations/layout.css';
15
+ @import './rules/layout.css';
16
+ @import '@surflane/css/presentation/card.css';
17
+ @import './rules/card.css';
18
+ @import '@surflane/css/presentation/inline-message.css';
19
+ @import './rules/inline-message.css';
20
+ @import './rules/dashboard.css';
@@ -20,4 +20,8 @@
20
20
  .surf-dialog-header {
21
21
  padding-inline-end: var(--surf-ui-card-padding);
22
22
  }
23
+ :where([data-ui-size], [data-ui-size] *).surf-confirm-content
24
+ .surf-dialog-footer {
25
+ padding-top: var(--surf-space-2);
26
+ }
23
27
  }
@@ -211,6 +211,16 @@
211
211
  border-radius: 0;
212
212
  }
213
213
  }
214
+ /* The menu trigger shows only where the sidebar hides, at the header's end. */
215
+ .surf-app-shell-menu-trigger.surf-button {
216
+ display: none;
217
+ }
218
+ @media (max-width: 760px) {
219
+ .surf-app-shell-menu-trigger.surf-button {
220
+ display: inline-flex;
221
+ margin-inline-start: auto;
222
+ }
223
+ }
214
224
  @media (max-width: 760px), (pointer: coarse) {
215
225
  .surf-app-shell-link {
216
226
  min-block-size: var(--surf-control-target-size);
@@ -0,0 +1,101 @@
1
+ /* Bar list, with the styles of what it renders. */
2
+ @layer surflane.tokens, surflane.themes, surflane.base, surflane.components,
3
+ surflane.sizes;
4
+
5
+ @layer surflane.components {
6
+ /* Every row is one height, so a list that scrolls shows whole rows: a name line,
7
+ and a detail line when any row has one. */
8
+ .surf-bar-list {
9
+ --surf-bar-list-row: calc(
10
+ var(--surf-type-body-size) * var(--surf-type-body-leading) +
11
+ var(--surf-space-2)
12
+ );
13
+ min-inline-size: 0;
14
+ }
15
+ .surf-bar-list[data-detail] {
16
+ --surf-bar-list-row: calc(
17
+ var(--surf-type-body-size) * var(--surf-type-body-leading) +
18
+ var(--surf-type-caption-size) * var(--surf-type-caption-leading) +
19
+ var(--surf-space-2)
20
+ );
21
+ }
22
+ .surf-bar-list .surf-scroll-viewport {
23
+ max-block-size: calc(var(--surf-bar-list-rows) * var(--surf-bar-list-row));
24
+ }
25
+ /* Name, bar and value share one line, in columns every row shares, so the bars
26
+ start together and the values line up at the end. */
27
+ .surf-bar-list-items {
28
+ display: grid;
29
+ grid-template-columns: minmax(0, 2fr) minmax(var(--surf-space-12), 3fr) auto;
30
+ column-gap: var(--surf-space-3);
31
+ margin: 0;
32
+ padding: 0;
33
+ list-style: none;
34
+ }
35
+ .surf-bar-list-item {
36
+ display: grid;
37
+ grid-column: 1 / -1;
38
+ grid-template-columns: subgrid;
39
+ align-items: center;
40
+ box-sizing: border-box;
41
+ block-size: var(--surf-bar-list-row);
42
+ padding-block: var(--surf-space-1);
43
+ color: var(--surf-ink-body);
44
+ font: var(--surf-weight-regular)
45
+ var(--surf-type-body-size)/var(--surf-type-body-leading)
46
+ var(--surf-font-body);
47
+ }
48
+ .surf-bar-list-label {
49
+ display: grid;
50
+ min-inline-size: 0;
51
+ }
52
+ .surf-bar-list-name,
53
+ .surf-bar-list-detail {
54
+ overflow: hidden;
55
+ text-overflow: ellipsis;
56
+ white-space: nowrap;
57
+ }
58
+ .surf-bar-list-name {
59
+ color: var(--surf-ink);
60
+ }
61
+ .surf-bar-list-detail {
62
+ color: var(--surf-ink-muted);
63
+ font-size: var(--surf-type-caption-size);
64
+ line-height: var(--surf-type-caption-leading);
65
+ }
66
+ /* The pair Progress and Slider use: the brand mark on the surface plane. */
67
+ .surf-bar-list-bar {
68
+ display: block;
69
+ block-size: var(--surf-space-2);
70
+ border-radius: var(--surf-radius-full);
71
+ background: var(--surf-surface);
72
+ overflow: hidden;
73
+ }
74
+ .surf-bar-list-fill {
75
+ display: block;
76
+ block-size: 100%;
77
+ border-radius: inherit;
78
+ background: var(--surf-brand-mark);
79
+ }
80
+ .surf-bar-list-value {
81
+ color: var(--surf-ink);
82
+ font-variant-numeric: tabular-nums;
83
+ text-align: end;
84
+ white-space: nowrap;
85
+ }
86
+ @media (forced-colors: active) {
87
+ .surf-bar-list-bar {
88
+ background: Canvas;
89
+ outline: 1px solid CanvasText;
90
+ }
91
+ .surf-bar-list-fill {
92
+ background: CanvasText;
93
+ }
94
+ }
95
+ }
96
+ @layer surflane.sizes {
97
+ /* The bar is as thick as Meter's and Progress's at each size. */
98
+ :where([data-ui-size], [data-ui-size] *).surf-bar-list-bar {
99
+ block-size: var(--surf-ui-gauge);
100
+ }
101
+ }