@eifi1/ui-kit 0.5.1 → 0.6.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 (128) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +101 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/chip.d.ts +3 -2
  8. package/dist/components/chip.js +14 -1
  9. package/dist/components/chip.js.map +1 -1
  10. package/dist/components/choice-card.d.ts +100 -0
  11. package/dist/components/choice-card.js +170 -0
  12. package/dist/components/choice-card.js.map +1 -0
  13. package/dist/components/combobox-core.d.ts +76 -6
  14. package/dist/components/combobox-core.js +119 -49
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.d.ts +12 -2
  17. package/dist/components/combobox.js +42 -17
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/danger-confirm.d.ts +91 -0
  20. package/dist/components/danger-confirm.js +181 -0
  21. package/dist/components/danger-confirm.js.map +1 -0
  22. package/dist/components/dialog-frame.d.ts +84 -0
  23. package/dist/components/dialog-frame.js +86 -0
  24. package/dist/components/dialog-frame.js.map +1 -0
  25. package/dist/components/disclosure.d.ts +108 -0
  26. package/dist/components/disclosure.js +127 -0
  27. package/dist/components/disclosure.js.map +1 -0
  28. package/dist/components/entity-combobox.d.ts +17 -3
  29. package/dist/components/entity-combobox.js +25 -5
  30. package/dist/components/entity-combobox.js.map +1 -1
  31. package/dist/components/file-button.d.ts +161 -0
  32. package/dist/components/file-button.js +225 -0
  33. package/dist/components/file-button.js.map +1 -0
  34. package/dist/components/file-dropzone.d.ts +72 -23
  35. package/dist/components/file-dropzone.js +219 -94
  36. package/dist/components/file-dropzone.js.map +1 -1
  37. package/dist/components/icon-picker.d.ts +72 -0
  38. package/dist/components/icon-picker.js +104 -0
  39. package/dist/components/icon-picker.js.map +1 -0
  40. package/dist/components/mini-calendar.d.ts +3 -0
  41. package/dist/components/mini-calendar.js +4 -3
  42. package/dist/components/mini-calendar.js.map +1 -1
  43. package/dist/components/modal.d.ts +8 -1
  44. package/dist/components/modal.js +4 -2
  45. package/dist/components/modal.js.map +1 -1
  46. package/dist/components/multi-entity-combobox.d.ts +16 -3
  47. package/dist/components/multi-entity-combobox.js +25 -5
  48. package/dist/components/multi-entity-combobox.js.map +1 -1
  49. package/dist/components/number-field.d.ts +41 -1
  50. package/dist/components/number-field.js +42 -10
  51. package/dist/components/number-field.js.map +1 -1
  52. package/dist/components/number-input.d.ts +35 -2
  53. package/dist/components/number-input.js +35 -4
  54. package/dist/components/number-input.js.map +1 -1
  55. package/dist/components/numpad-sheet.d.ts +7 -0
  56. package/dist/components/search-field.d.ts +16 -0
  57. package/dist/components/search-field.js +29 -7
  58. package/dist/components/search-field.js.map +1 -1
  59. package/dist/components/signature-pad.d.ts +43 -1
  60. package/dist/components/signature-pad.js +74 -2
  61. package/dist/components/signature-pad.js.map +1 -1
  62. package/dist/components/swatch-picker.d.ts +69 -0
  63. package/dist/components/swatch-picker.js +75 -0
  64. package/dist/components/swatch-picker.js.map +1 -0
  65. package/dist/components/tile-radio.d.ts +50 -0
  66. package/dist/components/tile-radio.js +140 -0
  67. package/dist/components/tile-radio.js.map +1 -0
  68. package/dist/components/toggle-group.d.ts +27 -5
  69. package/dist/components/toggle-group.js +22 -15
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/ui.d.ts +150 -12
  72. package/dist/components/ui.js +196 -22
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/i18n/defaults.d.ts +7 -0
  75. package/dist/i18n/defaults.js +13 -2
  76. package/dist/i18n/defaults.js.map +1 -1
  77. package/dist/i18n/kit-labels.d.ts +38 -5
  78. package/dist/i18n/kit-labels.js +12 -4
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +12 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/lib/table-text.d.ts +127 -0
  84. package/dist/lib/table-text.js +82 -0
  85. package/dist/lib/table-text.js.map +1 -0
  86. package/dist/rhf/form.d.ts +79 -0
  87. package/dist/rhf/form.js +143 -0
  88. package/dist/rhf/form.js.map +1 -0
  89. package/dist/rhf.d.ts +4 -0
  90. package/dist/rhf.js +3 -0
  91. package/dist/rhf.js.map +1 -0
  92. package/dist/shell/app-shell.js +3 -1
  93. package/dist/shell/app-shell.js.map +1 -1
  94. package/dist/table-text.d.ts +1 -0
  95. package/dist/table-text.js +3 -0
  96. package/dist/table-text.js.map +1 -0
  97. package/package.json +14 -1
  98. package/src/components/autocomplete.tsx +425 -0
  99. package/src/components/chip.tsx +24 -2
  100. package/src/components/choice-card.tsx +305 -0
  101. package/src/components/combobox-core.tsx +228 -58
  102. package/src/components/combobox.tsx +58 -21
  103. package/src/components/danger-confirm.tsx +286 -0
  104. package/src/components/dialog-frame.tsx +179 -0
  105. package/src/components/disclosure.tsx +259 -0
  106. package/src/components/entity-combobox.tsx +41 -6
  107. package/src/components/file-button.tsx +431 -0
  108. package/src/components/file-dropzone.tsx +323 -117
  109. package/src/components/icon-picker.tsx +181 -0
  110. package/src/components/mini-calendar.tsx +7 -3
  111. package/src/components/modal.tsx +10 -2
  112. package/src/components/multi-entity-combobox.tsx +40 -6
  113. package/src/components/number-field.tsx +86 -10
  114. package/src/components/number-input.tsx +79 -2
  115. package/src/components/search-field.tsx +49 -6
  116. package/src/components/signature-pad.tsx +112 -0
  117. package/src/components/swatch-picker.tsx +141 -0
  118. package/src/components/tile-radio.tsx +228 -0
  119. package/src/components/toggle-group.tsx +54 -18
  120. package/src/components/ui.tsx +400 -24
  121. package/src/i18n/defaults.ts +12 -1
  122. package/src/i18n/kit-labels.tsx +45 -5
  123. package/src/index.ts +19 -0
  124. package/src/lib/table-text.ts +265 -0
  125. package/src/rhf/form.tsx +300 -0
  126. package/src/rhf.ts +9 -0
  127. package/src/shell/app-shell.tsx +3 -1
  128. package/src/table-text.ts +8 -0
@@ -1,11 +1,11 @@
1
1
  import { createContext, useContext, useMemo } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import type { DataTableLabels } from "../components/data-table-labels";
4
- import type { MiniCalendarLabels } from "../components/mini-calendar";
4
+ import type { MiniCalendarLabels, WeekDay } from "../components/mini-calendar";
5
5
  import type { PopoverLabels } from "../components/popover";
6
6
  import type { ChipInputLabels } from "../components/chip";
7
7
  import type { FieldSyncLabels } from "../components/field-sync";
8
- import type { PasswordRevealLabels } from "../components/ui";
8
+ import type { PasswordRevealLabels, TabsLabels } from "../components/ui";
9
9
  import type { WizardLabels } from "../wizard/types";
10
10
  import type { TourLabels } from "../tour/tour";
11
11
  import type { CommandPaletteLabels } from "../search/command-palette";
@@ -16,6 +16,11 @@ import type { SparklineLabels } from "../components/sparkline";
16
16
  import type { StatTileLabels } from "../components/stat-tile";
17
17
  import type { SignaturePadLabels } from "../components/signature-pad";
18
18
  import type { PasswordStrengthLabels } from "../components/password-strength";
19
+ import type { DangerConfirmLabels } from "../components/danger-confirm";
20
+ import type { SwatchPickerLabels } from "../components/swatch-picker";
21
+ import type { IconPickerLabels } from "../components/icon-picker";
22
+ import type { DialogFrameLabels } from "../components/dialog-frame";
23
+ import type { FilePickerLabels } from "../components/file-button";
19
24
 
20
25
  /**
21
26
  * EVERY string the kit renders, as one typed tree — and an optional provider that
@@ -81,7 +86,7 @@ export interface DatePickerLabels {
81
86
  }
82
87
 
83
88
  /** The whole combobox family: `Combobox`, `EntityCombobox`,
84
- * `MultiEntityCombobox`, `InlineEntityCombobox`. */
89
+ * `MultiEntityCombobox`, `InlineEntityCombobox`, `Autocomplete`. */
85
90
  export interface ComboboxLabels {
86
91
  search: string;
87
92
  noResults: string;
@@ -91,6 +96,12 @@ export interface ComboboxLabels {
91
96
  create: (query: string) => string;
92
97
  /** Trigger summary once more than one value is picked. */
93
98
  selectedCount: (count: number) => string;
99
+ /** An async lookup (`loadOptions`) failed. */
100
+ loadError: string;
101
+ /** Announced (live region) when the list settles on `count` > 0 rows. */
102
+ resultCount: (count: number) => string;
103
+ /** The query is shorter than the `minChars` a lookup needs. */
104
+ minChars: (count: number) => string;
94
105
  }
95
106
 
96
107
  export interface MultiSelectLabels {
@@ -177,6 +188,7 @@ export interface UiKitLabels {
177
188
  chipInput: ChipInputLabels;
178
189
  fieldSync: FieldSyncLabels;
179
190
  passwordReveal: PasswordRevealLabels;
191
+ tabs: TabsLabels;
180
192
  appShell: AppShellLabels;
181
193
  pageContents: PageContentsLabels;
182
194
  topBar: TopBarLabels;
@@ -191,6 +203,11 @@ export interface UiKitLabels {
191
203
  statTile: StatTileLabels;
192
204
  signaturePad: SignaturePadLabels;
193
205
  passwordStrength: PasswordStrengthLabels;
206
+ dangerConfirm: DangerConfirmLabels;
207
+ swatchPicker: SwatchPickerLabels;
208
+ iconPicker: IconPickerLabels;
209
+ dialogFrame: DialogFrameLabels;
210
+ filePicker: FilePickerLabels;
194
211
  }
195
212
 
196
213
  /** Any subset of the tree, one level deep — each namespace may be partial, and a
@@ -230,6 +247,10 @@ export const DEFAULT_COMBOBOX_LABELS: ComboboxLabels = {
230
247
  loading: "Loading…",
231
248
  create: (query) => `Create “${query}”`,
232
249
  selectedCount: (count) => `${count} selected`,
250
+ loadError: "Couldn’t load results",
251
+ resultCount: (count) => (count === 1 ? "1 result" : `${count} results`),
252
+ minChars: (count) =>
253
+ count === 1 ? "Type at least 1 character" : `Type at least ${count} characters`,
233
254
  };
234
255
 
235
256
  export const DEFAULT_MULTI_SELECT_LABELS: MultiSelectLabels = {
@@ -307,6 +328,7 @@ export function formatFileSize(bytes: number, locale?: string): string {
307
328
  interface KitI18n {
308
329
  labels?: UiKitLabelOverrides;
309
330
  locale?: string;
331
+ weekStartsOn?: WeekDay;
310
332
  }
311
333
 
312
334
  const KitI18nContext = createContext<KitI18n>({});
@@ -320,6 +342,16 @@ export interface UiKitProviderProps {
320
342
  * its own. Without a provider, and without a prop, those use the runtime default.
321
343
  */
322
344
  locale?: string;
345
+ /**
346
+ * The first day of the week (0 = Sunday, 1 = Monday …) for every calendar below —
347
+ * `MiniCalendar` and the pickers built on it — that is not handed a `weekStartsOn`
348
+ * of its own. Without it the week start follows `locale`'s week info.
349
+ *
350
+ * Separate from `locale` because the two are separate decisions: a German-built
351
+ * app running in English (`locale="en"`) would otherwise start its weeks on Sunday,
352
+ * and switching it to `en-GB` to get Monday changes every date and number format.
353
+ */
354
+ weekStartsOn?: WeekDay;
323
355
  children: ReactNode;
324
356
  }
325
357
 
@@ -331,14 +363,15 @@ export interface UiKitProviderProps {
331
363
  * it names, so a page can re-label one table's `dataTable.table` without restating
332
364
  * the language.
333
365
  */
334
- export function UiKitProvider({ labels, locale, children }: UiKitProviderProps) {
366
+ export function UiKitProvider({ labels, locale, weekStartsOn, children }: UiKitProviderProps) {
335
367
  const outer = useContext(KitI18nContext);
336
368
  const value = useMemo<KitI18n>(
337
369
  () => ({
338
370
  locale: locale ?? outer.locale,
371
+ weekStartsOn: weekStartsOn ?? outer.weekStartsOn,
339
372
  labels: mergeOverrides(outer.labels, labels),
340
373
  }),
341
- [outer, labels, locale],
374
+ [outer, labels, locale, weekStartsOn],
342
375
  );
343
376
  return <KitI18nContext.Provider value={value}>{children}</KitI18nContext.Provider>;
344
377
  }
@@ -404,6 +437,13 @@ export function useKitLocale(prop?: string): string | undefined {
404
437
  return prop ?? fromProvider;
405
438
  }
406
439
 
440
+ /** The week start the nearest `<UiKitProvider weekStartsOn>` pins, else `undefined`
441
+ * (the caller then asks the locale). A component's own prop goes first:
442
+ * `prop ?? useKitWeekStart()`. */
443
+ export function useKitWeekStart(): WeekDay | undefined {
444
+ return useContext(KitI18nContext).weekStartsOn;
445
+ }
446
+
407
447
  /** {@link DEFAULT_FILE_LABELS}, but formatting in the provider's locale. */
408
448
  export function useKitFileLabels(prop?: Partial<FileLabels>): FileLabels {
409
449
  const locale = useKitLocale();
package/src/index.ts CHANGED
@@ -80,6 +80,25 @@ export * from "./components/stat-tile";
80
80
  export * from "./components/signature-pad";
81
81
  export * from "./components/password-strength";
82
82
  export * from "./components/page-contents";
83
+ export * from "./components/disclosure";
84
+ export * from "./components/dialog-frame";
85
+ export * from "./components/danger-confirm";
86
+ export * from "./components/swatch-picker";
87
+ export * from "./components/icon-picker";
88
+ export * from "./components/choice-card";
89
+ export * from "./components/autocomplete";
90
+ // Named, not `export *`: file-button.tsx also holds the screening helpers the
91
+ // dropzone shares, which are internal.
92
+ export { FileButton, useFilePicker, matchesAccept, DEFAULT_FILE_PICKER_LABELS } from "./components/file-button";
93
+ export type {
94
+ FileButtonProps,
95
+ UseFilePickerOptions,
96
+ UseFilePickerReturn,
97
+ FilePickerLabels,
98
+ FileRejection,
99
+ FileRejectionReason,
100
+ FileScreenOptions,
101
+ } from "./components/file-button";
83
102
  export * from "./components/treemap";
84
103
  export * from "./components/series-chart";
85
104
  export * from "./components/chart-zoom";
@@ -0,0 +1,265 @@
1
+ /**
2
+ * One lexer for "paste the table out of a spreadsheet", and the three rules for the
3
+ * comma. `@eifi1/ui-kit/table-text` — pure functions over strings, no React.
4
+ *
5
+ * Moved here from Lenkbank (`shared/lib/parse-table.ts`), where three screens took a
6
+ * table off somebody's clipboard — a measured-table grid, the text view beside it, and
7
+ * a page that reads a recorder's setpoint/actual files — and each had grown its own
8
+ * reader. They disagreed, and not in the harmless way: a whitespace-separated German
9
+ * export was read as six columns of nonsense by two of them and as nothing at all by
10
+ * the third, with no message either way. The lexing is one problem (lines, a
11
+ * separator, fields, a header, numbers) and it is solved once here, so the next app
12
+ * that imports a CSV does not write the comma rules wrong a second time.
13
+ *
14
+ * **What is genuinely different between callers is where the decision about the
15
+ * comma is taken**, and that is a parameter rather than a constant. A comma is the
16
+ * decimal mark in every German export and the column separator in every English CSV,
17
+ * and no single rule serves both without regressing one of them. So the rules are
18
+ * named instead of one being picked:
19
+ *
20
+ * * **`"whole-text"`** — the *file* door. A recorder writes one file in one
21
+ * convention, so the separator and the decimal mark are decided once, from the
22
+ * whole text, and stated back to the reader (`decimalComma`) where they can see
23
+ * what was assumed. Deciding once is what lets one damaged line be *counted*
24
+ * rather than re-guessed into a different convention than the lines around it.
25
+ * * **`"per-line"`** — the *paste* door. What arrives on a clipboard is a few rows
26
+ * out of a spreadsheet, sometimes one row, sometimes a line somebody typed. There
27
+ * is no file to be consistent with, and a line must not change meaning because of
28
+ * another line somewhere else in the box — so each line is read on its own
29
+ * evidence.
30
+ * * **unconditional** — one cell (`cellNumber` / `isCellNumber`). A single cell has
31
+ * no columns in it, so a comma in one cannot be separating anything and is always
32
+ * a decimal mark. That is not a table rule at all, which is why it is a pair of
33
+ * one-liners beside the others rather than an option on them.
34
+ *
35
+ * What this deliberately does not do:
36
+ *
37
+ * * **Thousands separators.** `1.234,5` and `1,234.5` are both a number with two
38
+ * marks in it, and telling the grouping mark from the decimal mark needs a rule
39
+ * about digit runs that would then have to be right for a three-digit integer
40
+ * too. A field carrying one is reported as a line that could not be read, with
41
+ * its line number, which is something a reader can act on. Guessing is not.
42
+ * * **Empty cells.** An empty field is dropped rather than held open as a hole, so a
43
+ * row with a dropped channel in the middle of it shifts left. Keeping the hole
44
+ * would mean reading `1;2;3;` — a trailing separator, which every spreadsheet
45
+ * writes — as a four-column row. It is a known gap, not an oversight.
46
+ * * **Quoted fields.** No `"a;b"` escaping. These are numbers; the only text in them
47
+ * is the header line, and a header field with a separator in it costs a column
48
+ * name, not a measurement.
49
+ */
50
+
51
+ /** A digit, a comma, a digit — a comma that can only be a decimal mark or a
52
+ * grouping mark, never a separator with a number on each side of it. `1, 2`
53
+ * deliberately does not match: a comma followed by a space is how a human
54
+ * writes a list, and reading it as a decimal mark would turn a hand-typed
55
+ * `1, 2, 3` into one number. */
56
+ const DECIMAL_COMMA = /\d,\d/;
57
+
58
+ /** What separates the columns of one line. A regular expression only for the
59
+ * whitespace case, because a run of spaces is one separator. */
60
+ type Separator = string | RegExp;
61
+
62
+ /** Where the decision about the comma is taken. See the module docstring: the
63
+ * file door decides once for the whole text, the paste door decides per line. */
64
+ export type DecimalRule = "per-line" | "whole-text";
65
+
66
+ export interface ParseTableOptions {
67
+ decimal: DecimalRule;
68
+ /**
69
+ * How many columns the caller already knows the table has. A grid with fixed
70
+ * columns knows its width before a number arrives: anything past it is sliced
71
+ * off — an export with a trailing timestamp column is not an error worth
72
+ * refusing — and a line with fewer is reported. Omitted where the width is the
73
+ * file's own answer.
74
+ */
75
+ columns?: number;
76
+ /**
77
+ * How many leading lines that are not rows of numbers may be a header.
78
+ * Default 1, the name row. {@link parseRows} passes 2, because a bench export
79
+ * writes the units on a second line under the names.
80
+ */
81
+ headerLines?: number;
82
+ }
83
+
84
+ export interface ParsedTable {
85
+ rows: number[][];
86
+ /** The first header line's fields — the column *names*. A units line under it
87
+ * is absorbed and not returned: what a column is measured in is the caller's to
88
+ * say (its column definitions), and a second list of strings here would be a
89
+ * second answer to that. */
90
+ header: string[] | null;
91
+ /** Whether a comma was read as a decimal mark. Under `"per-line"` this is true
92
+ * if any line was read that way, and it exists for the reader: a guess
93
+ * nobody can see is a guess nobody can correct. */
94
+ decimalComma: boolean;
95
+ /** Line numbers — 1-based, counted in the text as it was handed over, blank
96
+ * lines included — that held something which is not a row of numbers. A list
97
+ * rather than a count, because one door names the first one and the other
98
+ * counts them. */
99
+ skipped: number[];
100
+ }
101
+
102
+ /**
103
+ * The columns of one line, as text, with the decimal mark normalised to a point.
104
+ *
105
+ * Text rather than numbers because a grid holds cells as strings: a pasted
106
+ * field that is not a number belongs in the cell it landed in, marked, where the
107
+ * person who pasted it can see which column went wrong. Dropping it here would
108
+ * shift every column after it and lose the evidence.
109
+ */
110
+ export function splitRow(line: string): string[] {
111
+ const separator = separatorOf(line);
112
+ const decimalComma = separator !== ",";
113
+ return fieldsOf(line, separator).map((field) => (decimalComma ? point(field) : field));
114
+ }
115
+
116
+ /**
117
+ * A pasted table, or the lines of it that are not one.
118
+ *
119
+ * The lexer both doors are built on. It reads lines, drops blanks and `#`
120
+ * banners, decides what separates the columns, takes the header off the top and
121
+ * converts what is left.
122
+ */
123
+ export function parseTable(text: string, options: ParseTableOptions): ParsedTable {
124
+ const headerLimit = options.headerLines ?? 1;
125
+ const lines = contentLines(text);
126
+ if (!lines.length) return { rows: [], header: null, decimalComma: false, skipped: [] };
127
+ const fixed = options.decimal === "whole-text" ? wholeTextRule(lines) : null;
128
+
129
+ let header: string[] | null = null;
130
+ let headerCount = 0;
131
+ const rows: number[][] = [];
132
+ const skipped: number[] = [];
133
+ let sawDecimalComma = false;
134
+
135
+ for (const { line, number } of lines) {
136
+ const separator = fixed ? fixed.separator : separatorOf(line);
137
+ const decimalComma = fixed ? fixed.decimalComma : separator !== ",";
138
+ const fields = fieldsOf(line, separator);
139
+ const numbers = fields.map((field) => Number(decimalComma ? point(field) : field));
140
+ const readable = fields.length > 0 && numbers.every(Number.isFinite);
141
+
142
+ if (!readable) {
143
+ // A header is only a header at the top, and only before any data: a stray
144
+ // word further down is a broken row, and dropping it silently would lose a
145
+ // measurement without saying so.
146
+ if (rows.length === 0 && headerCount < headerLimit) {
147
+ header ??= fields;
148
+ headerCount += 1;
149
+ continue;
150
+ }
151
+ skipped.push(number);
152
+ continue;
153
+ }
154
+ if (options.columns !== undefined && fields.length < options.columns) {
155
+ skipped.push(number);
156
+ continue;
157
+ }
158
+ if (decimalComma && DECIMAL_COMMA.test(line)) sawDecimalComma = true;
159
+ rows.push(options.columns === undefined ? numbers : numbers.slice(0, options.columns));
160
+ }
161
+
162
+ return {
163
+ rows,
164
+ header,
165
+ decimalComma: fixed ? fixed.decimalComma : sawDecimalComma,
166
+ skipped,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * The paste door: a fixed number of columns, and the line that stopped it.
172
+ *
173
+ * Read per line, with up to two header lines (names, then units). It answers with a
174
+ * line number rather than with what it managed to read, because half a pasted
175
+ * sweep saved as a measurement is worse than a refused paste — and because a
176
+ * table that comes back **empty** is the one answer a reader cannot act on: it
177
+ * looks exactly like a paste that did not happen. So text that has content and
178
+ * produced no row at all is an error naming its first unreadable line, never
179
+ * `{ rows: [] }`.
180
+ */
181
+ export function parseRows(text: string, columns: number): { rows: number[][] } | { error: number } {
182
+ const lines = contentLines(text);
183
+ if (!lines.length) return { rows: [] };
184
+ const parsed = parseTable(text, { decimal: "per-line", columns, headerLines: 2 });
185
+ if (parsed.skipped.length) return { error: parsed.skipped[0] };
186
+ if (!parsed.rows.length) return { error: lines[0].number };
187
+ return { rows: parsed.rows };
188
+ }
189
+
190
+ /** One cell as a number. Unconditional: a cell has no columns in it, so a comma
191
+ * in one is a decimal mark and nothing else. */
192
+ export const cellNumber = (cell: string) => Number(cell.replace(",", "."));
193
+
194
+ /** Whether one cell holds a number yet. An empty cell is not one — it is a hole
195
+ * in the measurement, which is a different thing from a zero. */
196
+ export const isCellNumber = (cell: string) =>
197
+ cell.trim() !== "" && !Number.isNaN(cellNumber(cell));
198
+
199
+ /**
200
+ * The one separator and the one decimal mark a whole file is read with.
201
+ *
202
+ * The separator is taken from the line most likely to be data — the second,
203
+ * where there is one, because the first is usually the names.
204
+ *
205
+ * A comma between two digits is then a decimal mark whenever something else is
206
+ * already separating the columns: there is nothing else it could be. The rule
207
+ * this replaces also demanded that no decimal POINT appear anywhere in the file,
208
+ * which read a semicolon-separated export with one column written by a different
209
+ * tool — an entirely ordinary thing for a bench to produce — as a table with no
210
+ * rows in it, and said nothing.
211
+ */
212
+ function wholeTextRule(lines: { line: string }[]): { separator: Separator; decimalComma: boolean } {
213
+ const separator = separatorOf(lines[lines.length > 1 ? 1 : 0].line);
214
+ const body = lines.map((entry) => entry.line).join("\n");
215
+ return { separator, decimalComma: separator !== "," && DECIMAL_COMMA.test(body) };
216
+ }
217
+
218
+ /** Lines with something on them, numbered as they stand in the original text so
219
+ * a message can point at one. `#` banners are a recorder's, not a measurement. */
220
+ function contentLines(text: string): { line: string; number: number }[] {
221
+ return text
222
+ .split(/\r?\n/)
223
+ .map((line, index) => ({ line: line.trim(), number: index + 1 }))
224
+ .filter(({ line }) => line !== "" && !line.startsWith("#"));
225
+ }
226
+
227
+ /**
228
+ * What separates this line's columns.
229
+ *
230
+ * In order of trust, not of count: a semicolon-separated line with decimal
231
+ * commas has more commas than semicolons, and a tab-separated one may have both.
232
+ * Whichever of tab and semicolon appears wins outright, because neither is ever
233
+ * anything but a separator.
234
+ *
235
+ * The comma is the one that has to be argued about. A line that has whitespace
236
+ * between its fields *and* a comma between two digits has already said what
237
+ * separates it — so the comma is a decimal mark, and the whitespace separates.
238
+ * That is a German export printed or dumped from MATLAB (`0,5 1,25 2,75`), and
239
+ * reading its commas as separators produced six columns from three: each number
240
+ * split into a pair of small integers, accepted as a row, and saved as a
241
+ * measurement with nothing on screen to say so. A comma with a space after it is
242
+ * not that shape (`DECIMAL_COMMA` does not match `1, 2`), so a hand-typed list
243
+ * still separates on the comma.
244
+ */
245
+ function separatorOf(line: string): Separator {
246
+ if (line.includes("\t")) return "\t";
247
+ if (line.includes(";")) return ";";
248
+ if (line.includes(",")) return /\s/.test(line) && DECIMAL_COMMA.test(line) ? /\s+/ : ",";
249
+ return /\s+/;
250
+ }
251
+
252
+ /** One line into trimmed, non-empty fields, as they were written. The header is
253
+ * returned out of these, so a column called `Weg,X` keeps its name even where
254
+ * the numbers under it are being read with a decimal comma. */
255
+ function fieldsOf(line: string, separator: Separator): string[] {
256
+ return line
257
+ .split(separator)
258
+ .map((field) => field.trim())
259
+ .filter((field) => field !== "");
260
+ }
261
+
262
+ /** The decimal mark this language of `Number` understands. One comma, because a
263
+ * field with two of them is a grouped number and this module does not read
264
+ * those — it reports the line instead. */
265
+ const point = (field: string) => field.replace(",", ".");