@eifi1/ui-kit 0.5.1 → 0.6.1
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.
- package/README.md +50 -1
- package/dist/components/amount-input.d.ts +7 -0
- package/dist/components/autocomplete.d.ts +103 -0
- package/dist/components/autocomplete.js +260 -0
- package/dist/components/autocomplete.js.map +1 -0
- package/dist/components/calculator.d.ts +7 -0
- package/dist/components/checkbox.d.ts +9 -0
- package/dist/components/checkbox.js +7 -2
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/chip.d.ts +3 -2
- package/dist/components/chip.js +14 -1
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +100 -0
- package/dist/components/choice-card.js +170 -0
- package/dist/components/choice-card.js.map +1 -0
- package/dist/components/combobox-core.d.ts +76 -6
- package/dist/components/combobox-core.js +119 -49
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +12 -2
- package/dist/components/combobox.js +42 -17
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +91 -0
- package/dist/components/danger-confirm.js +181 -0
- package/dist/components/danger-confirm.js.map +1 -0
- package/dist/components/dialog-frame.d.ts +84 -0
- package/dist/components/dialog-frame.js +86 -0
- package/dist/components/dialog-frame.js.map +1 -0
- package/dist/components/disclosure.d.ts +108 -0
- package/dist/components/disclosure.js +127 -0
- package/dist/components/disclosure.js.map +1 -0
- package/dist/components/entity-combobox.d.ts +17 -3
- package/dist/components/entity-combobox.js +25 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +161 -0
- package/dist/components/file-button.js +243 -0
- package/dist/components/file-button.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +72 -23
- package/dist/components/file-dropzone.js +219 -94
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/icon-picker.d.ts +72 -0
- package/dist/components/icon-picker.js +104 -0
- package/dist/components/icon-picker.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +3 -0
- package/dist/components/mini-calendar.js +4 -3
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +8 -1
- package/dist/components/modal.js +4 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +16 -3
- package/dist/components/multi-entity-combobox.js +25 -5
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +41 -1
- package/dist/components/number-field.js +42 -10
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +35 -2
- package/dist/components/number-input.js +35 -4
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +7 -0
- package/dist/components/search-field.d.ts +16 -0
- package/dist/components/search-field.js +29 -7
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/signature-pad.d.ts +43 -1
- package/dist/components/signature-pad.js +74 -2
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +69 -0
- package/dist/components/swatch-picker.js +75 -0
- package/dist/components/swatch-picker.js.map +1 -0
- package/dist/components/switch.d.ts +9 -0
- package/dist/components/switch.js +7 -2
- package/dist/components/switch.js.map +1 -1
- package/dist/components/tile-radio.d.ts +50 -0
- package/dist/components/tile-radio.js +140 -0
- package/dist/components/tile-radio.js.map +1 -0
- package/dist/components/toggle-group.d.ts +27 -5
- package/dist/components/toggle-group.js +22 -15
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +150 -12
- package/dist/components/ui.js +196 -22
- package/dist/components/ui.js.map +1 -1
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +13 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +38 -5
- package/dist/i18n/kit-labels.js +12 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/index.d.ts +16 -7
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/table-text.d.ts +127 -0
- package/dist/lib/table-text.js +82 -0
- package/dist/lib/table-text.js.map +1 -0
- package/dist/rhf/form.d.ts +79 -0
- package/dist/rhf/form.js +143 -0
- package/dist/rhf/form.js.map +1 -0
- package/dist/rhf.d.ts +4 -0
- package/dist/rhf.js +3 -0
- package/dist/rhf.js.map +1 -0
- package/dist/shell/app-shell.js +3 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/table-text.d.ts +1 -0
- package/dist/table-text.js +3 -0
- package/dist/table-text.js.map +1 -0
- package/package.json +14 -1
- package/src/components/autocomplete.tsx +429 -0
- package/src/components/checkbox.tsx +16 -0
- package/src/components/chip.tsx +24 -2
- package/src/components/choice-card.tsx +305 -0
- package/src/components/combobox-core.tsx +228 -58
- package/src/components/combobox.tsx +58 -21
- package/src/components/danger-confirm.tsx +286 -0
- package/src/components/dialog-frame.tsx +179 -0
- package/src/components/disclosure.tsx +259 -0
- package/src/components/entity-combobox.tsx +41 -6
- package/src/components/file-button.tsx +458 -0
- package/src/components/file-dropzone.tsx +323 -117
- package/src/components/icon-picker.tsx +181 -0
- package/src/components/mini-calendar.tsx +7 -3
- package/src/components/modal.tsx +10 -2
- package/src/components/multi-entity-combobox.tsx +40 -6
- package/src/components/number-field.tsx +86 -10
- package/src/components/number-input.tsx +79 -2
- package/src/components/search-field.tsx +49 -6
- package/src/components/signature-pad.tsx +112 -0
- package/src/components/swatch-picker.tsx +141 -0
- package/src/components/switch.tsx +16 -0
- package/src/components/tile-radio.tsx +228 -0
- package/src/components/toggle-group.tsx +54 -18
- package/src/components/ui.tsx +400 -24
- package/src/i18n/defaults.ts +12 -1
- package/src/i18n/kit-labels.tsx +45 -5
- package/src/index.ts +19 -0
- package/src/lib/table-text.ts +265 -0
- package/src/rhf/form.tsx +300 -0
- package/src/rhf.ts +9 -0
- package/src/shell/app-shell.tsx +3 -1
- package/src/table-text.ts +8 -0
package/src/i18n/kit-labels.tsx
CHANGED
|
@@ -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(",", ".");
|