@fusion.dev/helix-core 0.0.0-dev
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/LICENSE +21 -0
- package/custom-elements.json +8005 -0
- package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +67 -0
- package/dist/controllers/aria-forward-controller/aria-forward-controller.js +146 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +79 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +140 -0
- package/dist/controllers/command-controller/command-controller.d.ts +83 -0
- package/dist/controllers/command-controller/command-controller.js +93 -0
- package/dist/controllers/copy-controller/copy-controller.d.ts +58 -0
- package/dist/controllers/copy-controller/copy-controller.js +128 -0
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.d.ts +76 -0
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +67 -0
- package/dist/controllers/dismiss-controller/dismiss-controller.d.ts +58 -0
- package/dist/controllers/dismiss-controller/dismiss-controller.js +165 -0
- package/dist/controllers/drilldown-controller/drilldown-controller.d.ts +127 -0
- package/dist/controllers/drilldown-controller/drilldown-controller.js +171 -0
- package/dist/controllers/floating-controller/floating-controller.d.ts +123 -0
- package/dist/controllers/floating-controller/floating-controller.js +227 -0
- package/dist/controllers/focus-trap-controller/focus-trap-controller.d.ts +26 -0
- package/dist/controllers/focus-trap-controller/focus-trap-controller.js +93 -0
- package/dist/controllers/indicator-controller/indicator-controller.d.ts +97 -0
- package/dist/controllers/indicator-controller/indicator-controller.js +164 -0
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +113 -0
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +117 -0
- package/dist/controllers/popover-controller/popover-controller.d.ts +132 -0
- package/dist/controllers/popover-controller/popover-controller.js +150 -0
- package/dist/controllers/source-controller/source-controller.d.ts +55 -0
- package/dist/controllers/source-controller/source-controller.js +94 -0
- package/dist/controllers/token-color-controller/token-color-controller.d.ts +63 -0
- package/dist/controllers/token-color-controller/token-color-controller.js +105 -0
- package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +127 -0
- package/dist/controllers/virtualize-controller/virtualize-controller.js +216 -0
- package/dist/events/element-events.d.ts +4 -0
- package/dist/events/element-events.js +34 -0
- package/dist/events/events.d.ts +743 -0
- package/dist/events/events.js +799 -0
- package/dist/helix-element/helix-element.d.ts +201 -0
- package/dist/helix-element/helix-element.js +695 -0
- package/dist/helix-form-element/helix-form-element.d.ts +152 -0
- package/dist/helix-form-element/helix-form-element.js +194 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +54 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +137 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +140 -0
- package/dist/mixins/mixin-collection/index.d.ts +1 -0
- package/dist/mixins/mixin-collection/index.js +1 -0
- package/dist/mixins/mixin-collection/mixin-collection.d.ts +58 -0
- package/dist/mixins/mixin-collection/mixin-collection.js +154 -0
- package/dist/mixins/mixin-controller-active/mixin-controller-active.d.ts +53 -0
- package/dist/mixins/mixin-controller-active/mixin-controller-active.js +155 -0
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.d.ts +54 -0
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +157 -0
- package/dist/mixins/mixin-controller-open/mixin-controller-open.d.ts +49 -0
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +176 -0
- package/dist/mixins/mixin-controller-value/mixin-controller-value.d.ts +73 -0
- package/dist/mixins/mixin-controller-value/mixin-controller-value.js +173 -0
- package/dist/mixins/mixin-dismissible/mixin-dismissible.d.ts +41 -0
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +79 -0
- package/dist/mixins/mixin-field/mixin-field.d.ts +145 -0
- package/dist/mixins/mixin-field/mixin-field.js +423 -0
- package/dist/utils/anchor-element/anchor-element.d.ts +23 -0
- package/dist/utils/anchor-element/anchor-element.js +39 -0
- package/dist/utils/capture-pointer/capture-pointer.d.ts +22 -0
- package/dist/utils/capture-pointer/capture-pointer.js +30 -0
- package/dist/utils/closest-instance/closest-instance.d.ts +25 -0
- package/dist/utils/closest-instance/closest-instance.js +34 -0
- package/dist/utils/create-sheet/create-sheet.d.ts +16 -0
- package/dist/utils/create-sheet/create-sheet.js +16 -0
- package/dist/utils/custom-element/custom-element.d.ts +26 -0
- package/dist/utils/custom-element/custom-element.js +114 -0
- package/dist/utils/draw-icon/draw-icon.d.ts +38 -0
- package/dist/utils/draw-icon/draw-icon.js +39 -0
- package/dist/utils/draw-tooltip/draw-tooltip.d.ts +56 -0
- package/dist/utils/draw-tooltip/draw-tooltip.js +89 -0
- package/dist/utils/elapsed-time/elapsed-time.d.ts +10 -0
- package/dist/utils/elapsed-time/elapsed-time.js +117 -0
- package/dist/utils/focusable-elements/focusable-elements.d.ts +40 -0
- package/dist/utils/focusable-elements/focusable-elements.js +88 -0
- package/dist/utils/format-numbers/format-numbers.d.ts +1 -0
- package/dist/utils/format-numbers/format-numbers.js +29 -0
- package/dist/utils/host-aria/host-aria.d.ts +38 -0
- package/dist/utils/host-aria/host-aria.js +41 -0
- package/dist/utils/inherit-disabled/inherit-disabled.d.ts +33 -0
- package/dist/utils/inherit-disabled/inherit-disabled.js +55 -0
- package/dist/utils/line-path/line-path.d.ts +23 -0
- package/dist/utils/line-path/line-path.js +107 -0
- package/dist/utils/on-unless-false/on-unless-false.d.ts +28 -0
- package/dist/utils/on-unless-false/on-unless-false.js +32 -0
- package/dist/utils/read-series/read-series.d.ts +78 -0
- package/dist/utils/read-series/read-series.js +187 -0
- package/dist/utils/relative-time/relative-time.d.ts +24 -0
- package/dist/utils/relative-time/relative-time.js +84 -0
- package/dist/utils/request-change/request-change.d.ts +48 -0
- package/dist/utils/request-change/request-change.js +7 -0
- package/dist/utils/resolve-active-item/resolve-active-item.d.ts +28 -0
- package/dist/utils/resolve-active-item/resolve-active-item.js +21 -0
- package/dist/utils/restore-value/restore-value.d.ts +32 -0
- package/dist/utils/restore-value/restore-value.js +84 -0
- package/dist/utils/scale/scale.d.ts +23 -0
- package/dist/utils/scale/scale.js +32 -0
- package/dist/utils/scale/ticks.d.ts +27 -0
- package/dist/utils/scale/ticks.js +52 -0
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.d.ts +10 -0
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +98 -0
- package/dist/utils/slot-filled/slot-filled.d.ts +26 -0
- package/dist/utils/slot-filled/slot-filled.js +26 -0
- package/dist/utils/tell-disclosure/tell-disclosure.d.ts +51 -0
- package/dist/utils/tell-disclosure/tell-disclosure.js +76 -0
- package/dist/utils/ticker/ticker.d.ts +8 -0
- package/dist/utils/ticker/ticker.js +55 -0
- package/dist/utils/tone-marks/tone-marks.d.ts +52 -0
- package/dist/utils/tone-marks/tone-marks.js +36 -0
- package/dist/utils/value-as/value-as.d.ts +64 -0
- package/dist/utils/value-as/value-as.js +105 -0
- package/package.json +58 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** One point in a series: what it is called, what it is worth, and when. */
|
|
2
|
+
export interface SeriesPoint {
|
|
3
|
+
/**
|
|
4
|
+
* The row's own label, taken from its header cell.
|
|
5
|
+
*/
|
|
6
|
+
label: string;
|
|
7
|
+
/**
|
|
8
|
+
* The number the cell holds.
|
|
9
|
+
*/
|
|
10
|
+
value: number;
|
|
11
|
+
/**
|
|
12
|
+
* The machine-readable instant, if the row header carries a `<time>`.
|
|
13
|
+
*/
|
|
14
|
+
when?: string;
|
|
15
|
+
}
|
|
16
|
+
/** One column of a table, read as a series. */
|
|
17
|
+
export interface Series {
|
|
18
|
+
/**
|
|
19
|
+
* The column's name, from the header row, or the table's caption where the
|
|
20
|
+
* table has one column and therefore one series to name.
|
|
21
|
+
*/
|
|
22
|
+
name: string;
|
|
23
|
+
/**
|
|
24
|
+
* The rows, in the order the table gives them.
|
|
25
|
+
*/
|
|
26
|
+
points: SeriesPoint[];
|
|
27
|
+
}
|
|
28
|
+
export declare const readSeries: (table: Element) => Series[];
|
|
29
|
+
/** One cell of a row, as a reader sees it and as a chart measures it. */
|
|
30
|
+
export interface RowCell {
|
|
31
|
+
/**
|
|
32
|
+
* The column's name, from the header.
|
|
33
|
+
*/
|
|
34
|
+
name: string;
|
|
35
|
+
/**
|
|
36
|
+
* The cell's own text, which is what a reader sees.
|
|
37
|
+
*/
|
|
38
|
+
text: string;
|
|
39
|
+
/**
|
|
40
|
+
* The number behind it, where there is one.
|
|
41
|
+
*/
|
|
42
|
+
value?: number;
|
|
43
|
+
}
|
|
44
|
+
/** One row of a table, headed and with every cell in it. */
|
|
45
|
+
export interface Row {
|
|
46
|
+
/**
|
|
47
|
+
* Every cell in the row, in order, numeric or not.
|
|
48
|
+
*/
|
|
49
|
+
cells: RowCell[];
|
|
50
|
+
/**
|
|
51
|
+
* The row's own label, from its header cell.
|
|
52
|
+
*/
|
|
53
|
+
label: string;
|
|
54
|
+
/**
|
|
55
|
+
* The machine-readable instant, if the row header carries a `<time>`.
|
|
56
|
+
*/
|
|
57
|
+
when?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Reads a table as rows rather than as columns.
|
|
61
|
+
*
|
|
62
|
+
* **Everything `readSeries` throws away on purpose, a tooltip needs.** That
|
|
63
|
+
* function answers "what should I draw", so it keeps the parsed number and
|
|
64
|
+
* discards the cell's text, and it drops a column whose cells are not numbers
|
|
65
|
+
* at all. A chart drawing `128000` and a tooltip saying `128000` would be the
|
|
66
|
+
* chart overruling the table, which already decided that number reads `$128k`;
|
|
67
|
+
* and a column of notes beside the figures is exactly the extra a reader wants
|
|
68
|
+
* when their pointer is resting on a reading.
|
|
69
|
+
*
|
|
70
|
+
* So this keeps both, and keeps the columns nothing plotted. A chart of two
|
|
71
|
+
* series over a table of four columns still has four columns to show.
|
|
72
|
+
*
|
|
73
|
+
* The traversal is the same shape as `readSeries` and deliberately separate.
|
|
74
|
+
* Folding them into one pass returning both would make every caller who wants
|
|
75
|
+
* one build the other, and the two are asked for at different moments: series
|
|
76
|
+
* on render, rows only where a pointer has stopped.
|
|
77
|
+
*/
|
|
78
|
+
export declare const readRows: (table: Element) => Row[];
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a table as series, one per data column.
|
|
3
|
+
*
|
|
4
|
+
* **The table is the chart's accessible representation, not a fallback for
|
|
5
|
+
* one.** Every chart here takes its numbers from real markup rather than from a
|
|
6
|
+
* property, so the page is readable with no script at all, sortable by anything
|
|
7
|
+
* that sorts tables, and correct in a screen reader without a parallel
|
|
8
|
+
* description that has to be kept in step by hand. A `values` array would be
|
|
9
|
+
* easier to pass and would make the accessible version the thing nobody
|
|
10
|
+
* maintains.
|
|
11
|
+
*
|
|
12
|
+
* The shape is the ordinary one: a header row naming the series, a first column
|
|
13
|
+
* of row headers, and numbers in the rest. A single-column table has one series
|
|
14
|
+
* and takes its name from the caption, since a header row of one is usually
|
|
15
|
+
* absent.
|
|
16
|
+
*
|
|
17
|
+
* `data-value` wins over the cell's text, because `$1.2k` is what a reader
|
|
18
|
+
* should see and is not a number. `Number()` rather than `parseFloat`, because
|
|
19
|
+
* the second reads `12abc` as 12: a value that is nearly a number is a mistake
|
|
20
|
+
* worth dropping rather than rounding off, and a dropped point is visible where
|
|
21
|
+
* a silently truncated one is not.
|
|
22
|
+
*/
|
|
23
|
+
/* A grid's own rows and cells, by role and by tag.
|
|
24
|
+
*
|
|
25
|
+
* **Both, because `helix-table` sets its roles through `ElementInternals`.**
|
|
26
|
+
* That writes no attribute at all, which is the whole point of it: the
|
|
27
|
+
* semantics reach the accessibility tree and never the DOM. So a selector of
|
|
28
|
+
* `[role="row"]` matches a hand-rolled grid and matches nothing at all in this
|
|
29
|
+
* library's own table, and a selector of the tag matches ours and nothing
|
|
30
|
+
* anybody else builds. Neither alone is the answer. */
|
|
31
|
+
const ROWS = 'helix-table-row, helix-headless-table-row, [role="row"]';
|
|
32
|
+
const CELLS = 'helix-table-cell, helix-headless-table-cell, [role="cell"], [role="gridcell"], [role="rowheader"]';
|
|
33
|
+
const COLUMNS = 'helix-table-column, helix-headless-table-column, [role="columnheader"]';
|
|
34
|
+
/** Whether an element is a grid rather than a document table. */
|
|
35
|
+
const isGrid = (element) => element.getAttribute('role') === 'table' ||
|
|
36
|
+
element.getAttribute('role') === 'grid' ||
|
|
37
|
+
/^helix-(headless-)?table$/i.test(element.tagName);
|
|
38
|
+
/**
|
|
39
|
+
* Reads a grid built from roles rather than from table markup.
|
|
40
|
+
*
|
|
41
|
+
* The shape is the same question asked of different elements: a first row of
|
|
42
|
+
* column headers naming the series, a first cell per row labeling it, and
|
|
43
|
+
* numbers in the rest. `helix-table` puts its columns beside its rows rather
|
|
44
|
+
* than inside a header row, so the columns are looked for in the whole grid and
|
|
45
|
+
* the rows are read separately.
|
|
46
|
+
*
|
|
47
|
+
* **A paginated grid reads as the page it is showing.** That is worth knowing
|
|
48
|
+
* before pointing a chart at one: a table element holds every row it has, and a
|
|
49
|
+
* grid may be holding twenty of two thousand. The chart is not wrong, the
|
|
50
|
+
* question was.
|
|
51
|
+
*/
|
|
52
|
+
const readGrid = (grid) => {
|
|
53
|
+
const columns = [...grid.querySelectorAll(COLUMNS)];
|
|
54
|
+
const rows = [...grid.querySelectorAll(ROWS)];
|
|
55
|
+
const names = columns.slice(1).map((cell) => cell.textContent?.trim() ?? '');
|
|
56
|
+
const cellsOf = (row) => [...row.querySelectorAll(CELLS)];
|
|
57
|
+
const count = Math.max(0, ...rows.map((row) => cellsOf(row).length - 1));
|
|
58
|
+
return Array.from({ length: count }, (_, column) => ({
|
|
59
|
+
name: names[column] ?? grid.getAttribute('label') ?? '',
|
|
60
|
+
points: rows.flatMap((row) => {
|
|
61
|
+
const cells = cellsOf(row);
|
|
62
|
+
const head = cells[0];
|
|
63
|
+
const cell = cells[column + 1];
|
|
64
|
+
if (!cell) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const raw = cell.dataset?.value ?? cell.textContent ?? '';
|
|
68
|
+
const value = Number(raw.trim());
|
|
69
|
+
const when = head?.querySelector('time')?.getAttribute('datetime');
|
|
70
|
+
const tone = cell.dataset?.tone;
|
|
71
|
+
return Number.isFinite(value) && raw.trim() !== ''
|
|
72
|
+
? [
|
|
73
|
+
{
|
|
74
|
+
label: head?.textContent?.trim() ?? '',
|
|
75
|
+
value,
|
|
76
|
+
...(tone ? { tone } : {}),
|
|
77
|
+
...(when ? { when } : {}),
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
: [];
|
|
81
|
+
}),
|
|
82
|
+
}));
|
|
83
|
+
};
|
|
84
|
+
/* A column with no numbers in it is not a series.
|
|
85
|
+
*
|
|
86
|
+
* Both readers build one entry per data column and then drop the rows whose
|
|
87
|
+
* cells will not parse, so a column of notes came back as a series with a name
|
|
88
|
+
* and no points. Nothing drew it, which is why it went unseen, and `Legend`
|
|
89
|
+
* named it anyway: a swatch in the palette against a column no chart plotted,
|
|
90
|
+
* and every series after it shifted a color.
|
|
91
|
+
*
|
|
92
|
+
* Found the moment tables grew columns a tooltip wanted and a drawing did not,
|
|
93
|
+
* which is the arrangement `readRows` exists for. */
|
|
94
|
+
const plotted = (series) => series.filter((one) => one.points.length > 0);
|
|
95
|
+
export const readSeries = (table) => {
|
|
96
|
+
if (isGrid(table)) {
|
|
97
|
+
return plotted(readGrid(table));
|
|
98
|
+
}
|
|
99
|
+
const element = table;
|
|
100
|
+
const names = [...(element.tHead?.rows[0]?.cells ?? [])]
|
|
101
|
+
.slice(1)
|
|
102
|
+
.map((cell) => cell.textContent?.trim() ?? '');
|
|
103
|
+
const rows = [...(element.tBodies[0]?.rows ?? [])];
|
|
104
|
+
const columns = Math.max(0, ...rows.map((row) => row.cells.length - 1));
|
|
105
|
+
return plotted(Array.from({ length: columns }, (_, column) => ({
|
|
106
|
+
name: names[column] ?? element.caption?.textContent?.trim() ?? '',
|
|
107
|
+
points: rows.flatMap((row) => {
|
|
108
|
+
const head = row.cells[0];
|
|
109
|
+
const cell = row.cells[column + 1];
|
|
110
|
+
if (!cell) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const raw = cell.dataset.value ?? cell.textContent ?? '';
|
|
114
|
+
const value = Number(raw.trim());
|
|
115
|
+
const when = head?.querySelector('time')?.getAttribute('datetime');
|
|
116
|
+
const tone = cell.dataset.tone;
|
|
117
|
+
return Number.isFinite(value) && raw.trim() !== ''
|
|
118
|
+
? [
|
|
119
|
+
{
|
|
120
|
+
label: head?.textContent?.trim() ?? '',
|
|
121
|
+
value,
|
|
122
|
+
...(tone ? { tone } : {}),
|
|
123
|
+
...(when ? { when } : {}),
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
: [];
|
|
127
|
+
}),
|
|
128
|
+
})));
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Reads a table as rows rather than as columns.
|
|
132
|
+
*
|
|
133
|
+
* **Everything `readSeries` throws away on purpose, a tooltip needs.** That
|
|
134
|
+
* function answers "what should I draw", so it keeps the parsed number and
|
|
135
|
+
* discards the cell's text, and it drops a column whose cells are not numbers
|
|
136
|
+
* at all. A chart drawing `128000` and a tooltip saying `128000` would be the
|
|
137
|
+
* chart overruling the table, which already decided that number reads `$128k`;
|
|
138
|
+
* and a column of notes beside the figures is exactly the extra a reader wants
|
|
139
|
+
* when their pointer is resting on a reading.
|
|
140
|
+
*
|
|
141
|
+
* So this keeps both, and keeps the columns nothing plotted. A chart of two
|
|
142
|
+
* series over a table of four columns still has four columns to show.
|
|
143
|
+
*
|
|
144
|
+
* The traversal is the same shape as `readSeries` and deliberately separate.
|
|
145
|
+
* Folding them into one pass returning both would make every caller who wants
|
|
146
|
+
* one build the other, and the two are asked for at different moments: series
|
|
147
|
+
* on render, rows only where a pointer has stopped.
|
|
148
|
+
*/
|
|
149
|
+
export const readRows = (table) => {
|
|
150
|
+
const cell = (name, element) => {
|
|
151
|
+
if (!element) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
const text = element.textContent?.trim() ?? '';
|
|
155
|
+
const raw = element.dataset?.value ?? text;
|
|
156
|
+
const value = Number(raw.trim());
|
|
157
|
+
return {
|
|
158
|
+
name,
|
|
159
|
+
text,
|
|
160
|
+
...(Number.isFinite(value) && raw.trim() !== '' ? { value } : {}),
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
const row = (head, rest, names) => {
|
|
164
|
+
const when = head?.querySelector('time')?.getAttribute('datetime');
|
|
165
|
+
return {
|
|
166
|
+
cells: rest
|
|
167
|
+
.map((one, index) => cell(names[index] ?? '', one))
|
|
168
|
+
.filter((one) => one !== undefined),
|
|
169
|
+
label: head?.textContent?.trim() ?? '',
|
|
170
|
+
...(when ? { when } : {}),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
if (isGrid(table)) {
|
|
174
|
+
const names = [...table.querySelectorAll(COLUMNS)]
|
|
175
|
+
.slice(1)
|
|
176
|
+
.map((one) => one.textContent?.trim() ?? '');
|
|
177
|
+
return [...table.querySelectorAll(ROWS)].map((one) => {
|
|
178
|
+
const cells = [...one.querySelectorAll(CELLS)];
|
|
179
|
+
return row(cells[0], cells.slice(1), names);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const element = table;
|
|
183
|
+
const names = [...(element.tHead?.rows[0]?.cells ?? [])]
|
|
184
|
+
.slice(1)
|
|
185
|
+
.map((one) => one.textContent?.trim() ?? '');
|
|
186
|
+
return [...(element.tBodies[0]?.rows ?? [])].map((one) => row(one.cells[0], [...one.cells].slice(1), names));
|
|
187
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** What one timestamp reads as, and when that stops being true. */
|
|
2
|
+
export interface Relative {
|
|
3
|
+
/**
|
|
4
|
+
* Milliseconds until `text` would be different, or `Infinity` where it never
|
|
5
|
+
* will be within a session worth planning for.
|
|
6
|
+
*
|
|
7
|
+
* Floored at a second so a caller cannot be handed a zero and schedule a busy
|
|
8
|
+
* loop, and capped at an hour: an item three years old changes text in about
|
|
9
|
+
* a year, and a timer set for a year is a timer that is really "never" while
|
|
10
|
+
* looking like a number.
|
|
11
|
+
*/
|
|
12
|
+
next: number;
|
|
13
|
+
/** The reading, in the caller's locale. */
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A formatter for one locale, kept.
|
|
18
|
+
*
|
|
19
|
+
* `now` is a parameter rather than read from the clock, which is what makes this
|
|
20
|
+
* testable at all: a function that reads `Date.now()` internally can only be
|
|
21
|
+
* tested by waiting or by replacing a global, and both produce specs that fail
|
|
22
|
+
* on a slow machine.
|
|
23
|
+
*/
|
|
24
|
+
export declare const relativeTime: (locale: string) => ((from: Date, now: Date) => Relative);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A timestamp read the way a person reads one, and the moment it stops being true.
|
|
3
|
+
*
|
|
4
|
+
* **The second half is the point.** "2 minutes ago" is correct for sixty seconds
|
|
5
|
+
* and wrong for the rest of the session, and a feed a user leaves open is
|
|
6
|
+
* exactly where that shows: every item drifts, quietly, and the panel ends up
|
|
7
|
+
* describing when the page loaded rather than when anything happened. So this
|
|
8
|
+
* returns the text and the milliseconds until that text changes, which is what
|
|
9
|
+
* lets a caller schedule one update instead of polling and lets a page hold one
|
|
10
|
+
* timer instead of one per item.
|
|
11
|
+
*
|
|
12
|
+
* `Intl.RelativeTimeFormat` with `numeric: 'auto'` is what produces "yesterday"
|
|
13
|
+
* rather than "1 day ago", which is what a reader would say. Cached per locale
|
|
14
|
+
* for the reason `formatNumbers` is: the constructor is expensive, the call is
|
|
15
|
+
* cheap, and a feed of fifty items formats fifty times per update.
|
|
16
|
+
*/
|
|
17
|
+
const held = new Map();
|
|
18
|
+
/** How long each unit lasts, in milliseconds, largest last. */
|
|
19
|
+
const UNITS = [
|
|
20
|
+
{ ms: 1000, unit: 'second' },
|
|
21
|
+
{ ms: 60_000, unit: 'minute' },
|
|
22
|
+
{ ms: 3_600_000, unit: 'hour' },
|
|
23
|
+
{ ms: 86_400_000, unit: 'day' },
|
|
24
|
+
{ ms: 604_800_000, unit: 'week' },
|
|
25
|
+
{ ms: 2_629_800_000, unit: 'month' },
|
|
26
|
+
{ ms: 31_557_600_000, unit: 'year' },
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* A formatter for one locale, kept.
|
|
30
|
+
*
|
|
31
|
+
* `now` is a parameter rather than read from the clock, which is what makes this
|
|
32
|
+
* testable at all: a function that reads `Date.now()` internally can only be
|
|
33
|
+
* tested by waiting or by replacing a global, and both produce specs that fail
|
|
34
|
+
* on a slow machine.
|
|
35
|
+
*/
|
|
36
|
+
export const relativeTime = (locale) => {
|
|
37
|
+
let format = held.get(locale);
|
|
38
|
+
if (!format) {
|
|
39
|
+
format = new Intl.RelativeTimeFormat(locale || undefined, { numeric: 'auto' });
|
|
40
|
+
held.set(locale, format);
|
|
41
|
+
}
|
|
42
|
+
const found = format;
|
|
43
|
+
return (from, now) => {
|
|
44
|
+
/* Signed from the timestamp's point of view, not the reader's:
|
|
45
|
+
`Intl.RelativeTimeFormat` takes a negative count for the past, so
|
|
46
|
+
`format(-2, 'minute')` is "2 minutes ago" and `format(2, 'minute')` is "in
|
|
47
|
+
2 minutes". Computed as `now - from` the sign is backwards and every past
|
|
48
|
+
item reads as the future, which is the shape of it that looks like a
|
|
49
|
+
working feed until you read one. */
|
|
50
|
+
const elapsed = from.getTime() - now.getTime();
|
|
51
|
+
if (!Number.isFinite(elapsed)) {
|
|
52
|
+
return { next: Number.POSITIVE_INFINITY, text: '' };
|
|
53
|
+
}
|
|
54
|
+
/* Under a minute is "now" rather than a count of seconds. A feed that
|
|
55
|
+
reports 3, then 4, then 5 seconds ago is a feed that moves under the
|
|
56
|
+
reader while they are trying to read it, and the precision is worth
|
|
57
|
+
nothing: nobody acts differently at seven seconds than at four. */
|
|
58
|
+
const size = Math.abs(elapsed);
|
|
59
|
+
if (size < 60_000) {
|
|
60
|
+
/* Floored, which `Relative.next` has always said this is and this branch
|
|
61
|
+
alone was not: at 59,999 milliseconds the remainder is one, so a caller
|
|
62
|
+
is re-armed inside the callback that fired it. Nothing had noticed
|
|
63
|
+
because both readers go through the shared ticker, whose own
|
|
64
|
+
`Math.max(1000, ...)` was absorbing it; a caller scheduling for itself
|
|
65
|
+
would have had the busy loop the type promised it would not. */
|
|
66
|
+
return { next: Math.max(1000, 60_000 - size), text: found.format(0, 'second') };
|
|
67
|
+
}
|
|
68
|
+
let chosen = UNITS[1];
|
|
69
|
+
for (const unit of UNITS) {
|
|
70
|
+
if (size >= unit.ms) {
|
|
71
|
+
chosen = unit;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const count = Math.trunc(elapsed / chosen.ms);
|
|
75
|
+
/* Time until the count ticks over, which is the remainder of the current
|
|
76
|
+
unit rather than the whole of one: an item read at 90 seconds is "1 minute
|
|
77
|
+
ago" for another 30 seconds, not another 60. */
|
|
78
|
+
const remaining = chosen.ms - (size % chosen.ms);
|
|
79
|
+
return {
|
|
80
|
+
next: Math.max(1000, Math.min(remaining, 3_600_000)),
|
|
81
|
+
text: found.format(count, chosen.unit),
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Announces a change, then applies it unless a listener refused.
|
|
3
|
+
*
|
|
4
|
+
* One path for every stateful change in the library. The four controller
|
|
5
|
+
* mixins call it, and so does any component whose state is not one of theirs,
|
|
6
|
+
* so a fault here is a fault in every stateful component at once.
|
|
7
|
+
*
|
|
8
|
+
* **The order is the whole point.** The event goes first and carries the state
|
|
9
|
+
* being *proposed*, so a listener calling `preventDefault()` stops it before
|
|
10
|
+
* anything moves. A component that mutated itself and then announced it would
|
|
11
|
+
* leave a consumer reacting to a decision already made.
|
|
12
|
+
*
|
|
13
|
+
* **Control is an action, not a mode.** This used to decide whether to apply by
|
|
14
|
+
* asking if the consumer had declared ownership, which meant an element that
|
|
15
|
+
* had been handed a `value` applied nothing and waited to be told. That is
|
|
16
|
+
* gone: the element always owns its state and always applies it, and a consumer
|
|
17
|
+
* takes over for one interaction by refusing here and assigning their own. See
|
|
18
|
+
* 294 for the argument, which is that a mode forks the event vocabulary by prop
|
|
19
|
+
* presence, has no ergonomic spelling in HTML, and is invisible enough to need
|
|
20
|
+
* an audit to make it audible.
|
|
21
|
+
*
|
|
22
|
+
* **The audit went with it, and that is not a loss.** It existed to warn that
|
|
23
|
+
* `value="Ada"` looks like the native seed and was not one. It is one now, so
|
|
24
|
+
* the trap it detected cannot happen and the machinery to detect it, two
|
|
25
|
+
* `WeakMap`s and a warning judged on the second request, has nothing to do.
|
|
26
|
+
*
|
|
27
|
+
* Returns whether the change was applied, so a caller that needs to know (a key
|
|
28
|
+
* handler deciding whether to also move focus) can ask.
|
|
29
|
+
*
|
|
30
|
+
* **There is one channel, and it is the event.** `onCheckedChange`,
|
|
31
|
+
* `onOpenChange`, `onValueChange` and `onActiveChange` were a second one,
|
|
32
|
+
* invoked from here once the state had been applied. They were removed on
|
|
33
|
+
* 19 Aug 2026, for working in fewer places than the thing they duplicated:
|
|
34
|
+
* React takes any `on[A-Z]` prop into its own branch and calls
|
|
35
|
+
* `addEventListener('CheckedChange', …)` with it, an event nothing dispatches,
|
|
36
|
+
* so no consumer writing JSX could reach one.
|
|
37
|
+
*/
|
|
38
|
+
export interface ChangeRequestOptions<T> {
|
|
39
|
+
/** Applies the change. Called only if no listener refused it. */
|
|
40
|
+
apply: (next: T) => void;
|
|
41
|
+
/** Builds the cancelable event announcing the change. */
|
|
42
|
+
event: (next: T) => Event;
|
|
43
|
+
/** The element announcing the change. */
|
|
44
|
+
host: EventTarget;
|
|
45
|
+
/** The state being requested. */
|
|
46
|
+
next: T;
|
|
47
|
+
}
|
|
48
|
+
export declare const requestChange: <T>({ apply, event, host, next, }: ChangeRequestOptions<T>) => boolean;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The least a thing must be for this to pick one of them. */
|
|
2
|
+
export interface ActivatableItem {
|
|
3
|
+
/** Whether it can be the current one. A disabled item is skipped. */
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
/** What identifies it, and what the value is compared against. */
|
|
6
|
+
value: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Which item a value names, or the first that could stand in for it.
|
|
10
|
+
*
|
|
11
|
+
* `items.find(match) ?? items[0]` was written by hand in four components and it
|
|
12
|
+
* is not the obvious line it looks like. It is what makes a set whose `value`
|
|
13
|
+
* names nothing still show something, which is every set before its consumer
|
|
14
|
+
* has chosen and every set whose current item has just been removed.
|
|
15
|
+
*
|
|
16
|
+
* The fallback skips disabled items and the match does not. A consumer who
|
|
17
|
+
* names a disabled item meant it, and quietly moving to another would be the
|
|
18
|
+
* component overruling them; but falling back *onto* a disabled item is the
|
|
19
|
+
* component choosing, and it should not choose something out of play. Every
|
|
20
|
+
* hand-written copy took `items[0]` either way, so a set opening on a disabled
|
|
21
|
+
* first item showed it as current.
|
|
22
|
+
*
|
|
23
|
+
* A plain function rather than only a method on `MixinControllerActive`,
|
|
24
|
+
* because the components that need it are split across two mixins: a set whose
|
|
25
|
+
* value is submitted takes `MixinControllerValue` and cannot also take the
|
|
26
|
+
* active one without two properties called `value`.
|
|
27
|
+
*/
|
|
28
|
+
export declare const resolveActiveItem: <Item extends ActivatableItem>(value: string, items: Item[]) => Item | undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which item a value names, or the first that could stand in for it.
|
|
3
|
+
*
|
|
4
|
+
* `items.find(match) ?? items[0]` was written by hand in four components and it
|
|
5
|
+
* is not the obvious line it looks like. It is what makes a set whose `value`
|
|
6
|
+
* names nothing still show something, which is every set before its consumer
|
|
7
|
+
* has chosen and every set whose current item has just been removed.
|
|
8
|
+
*
|
|
9
|
+
* The fallback skips disabled items and the match does not. A consumer who
|
|
10
|
+
* names a disabled item meant it, and quietly moving to another would be the
|
|
11
|
+
* component overruling them; but falling back *onto* a disabled item is the
|
|
12
|
+
* component choosing, and it should not choose something out of play. Every
|
|
13
|
+
* hand-written copy took `items[0]` either way, so a set opening on a disabled
|
|
14
|
+
* first item showed it as current.
|
|
15
|
+
*
|
|
16
|
+
* A plain function rather than only a method on `MixinControllerActive`,
|
|
17
|
+
* because the components that need it are split across two mixins: a set whose
|
|
18
|
+
* value is submitted takes `MixinControllerValue` and cannot also take the
|
|
19
|
+
* active one without two properties called `value`.
|
|
20
|
+
*/
|
|
21
|
+
export const resolveActiveItem = (value, items) => items.find((item) => item.value === value) ?? items.find((item) => !item.disabled);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** A control whose text the element may have to put back. */
|
|
2
|
+
type TextControl = HTMLInputElement | HTMLTextAreaElement;
|
|
3
|
+
/**
|
|
4
|
+
* Holds the caret across a write the element does not make itself.
|
|
5
|
+
*
|
|
6
|
+
* **Lit's `.value` binding moves the caret to the end, and only a controlled
|
|
7
|
+
* field notices.** Measured in Chrome with real typing: place the caret after
|
|
8
|
+
* `Ada` in `Ada Lovelace` and press one key, and an uncontrolled field reports
|
|
9
|
+
* `AdaX Lovelace` with the caret at 4, while a controlled one reports the same
|
|
10
|
+
* text with the caret at 13. Editing the middle of a controlled field was
|
|
11
|
+
* impossible: every keystroke threw the caret to the end.
|
|
12
|
+
*
|
|
13
|
+
* The asymmetry is the interesting part. Uncontrolled, `handleInput` runs
|
|
14
|
+
* `restoreValue` after the state has moved, so the control already holds the
|
|
15
|
+
* new text and the call returns early; when Lit's binding then assigns the same
|
|
16
|
+
* string, Chrome leaves the selection alone. Controlled, the control has been
|
|
17
|
+
* put *back* to the old text while the consumer decides, so Lit assigns a
|
|
18
|
+
* genuinely different string and the platform resets the selection, as it does
|
|
19
|
+
* for any assignment to `value`.
|
|
20
|
+
*
|
|
21
|
+
* So the selection is taken before the update and put back after it, mapped by
|
|
22
|
+
* however much the text grew or shrank, which is the same arithmetic
|
|
23
|
+
* `restoreValue` does in one step. Returned as a closure rather than a pair of
|
|
24
|
+
* fields so the two halves cannot be called out of order.
|
|
25
|
+
*
|
|
26
|
+
* The binding itself has to stay: it is what Lit SSR serializes, and a field
|
|
27
|
+
* that filled in at hydration would flash empty for every reader on a slow
|
|
28
|
+
* connection.
|
|
29
|
+
*/
|
|
30
|
+
export declare const holdCaret: (control: TextControl) => (() => void);
|
|
31
|
+
export declare const restoreValue: (control: TextControl, next: string) => void;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Puts a value back into a text control without moving the reader's caret.
|
|
3
|
+
*
|
|
4
|
+
* A controlled input has to restore what the element holds whenever the native
|
|
5
|
+
* control has moved and the answer has not followed, which is a refusal or a
|
|
6
|
+
* consumer who owns the value and has not answered yet. Assigning `value` is
|
|
7
|
+
* how that is done, and per spec assigning a *different* string moves the text
|
|
8
|
+
* entry cursor to the end. Measured: caret at index three in `abcdef`, assign
|
|
9
|
+
* `abcXdef`, caret at seven. Assigning the same string leaves it alone, which
|
|
10
|
+
* is why this was invisible until a consumer was slow.
|
|
11
|
+
*
|
|
12
|
+
* So a React consumer holding the value in state, which commits after the
|
|
13
|
+
* event, put the caret at the end of the field after every single keystroke.
|
|
14
|
+
* Typing in the middle of a word was impossible, and the class JSDoc claimed
|
|
15
|
+
* the caret was the platform's business.
|
|
16
|
+
*
|
|
17
|
+
* The offset is adjusted by the length difference rather than clamped, because
|
|
18
|
+
* the common case is exactly one character wide: the reader typed, the element
|
|
19
|
+
* restored the shorter previous string, and the caret belongs one place back.
|
|
20
|
+
* Clamping alone would leave it one to the right of where the reader was, which
|
|
21
|
+
* is the same bug at a smaller size.
|
|
22
|
+
*
|
|
23
|
+
* Nothing is restored when the value already matches, since the assignment
|
|
24
|
+
* itself is what moves the caret and there is nothing to assign.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Holds the caret across a write the element does not make itself.
|
|
28
|
+
*
|
|
29
|
+
* **Lit's `.value` binding moves the caret to the end, and only a controlled
|
|
30
|
+
* field notices.** Measured in Chrome with real typing: place the caret after
|
|
31
|
+
* `Ada` in `Ada Lovelace` and press one key, and an uncontrolled field reports
|
|
32
|
+
* `AdaX Lovelace` with the caret at 4, while a controlled one reports the same
|
|
33
|
+
* text with the caret at 13. Editing the middle of a controlled field was
|
|
34
|
+
* impossible: every keystroke threw the caret to the end.
|
|
35
|
+
*
|
|
36
|
+
* The asymmetry is the interesting part. Uncontrolled, `handleInput` runs
|
|
37
|
+
* `restoreValue` after the state has moved, so the control already holds the
|
|
38
|
+
* new text and the call returns early; when Lit's binding then assigns the same
|
|
39
|
+
* string, Chrome leaves the selection alone. Controlled, the control has been
|
|
40
|
+
* put *back* to the old text while the consumer decides, so Lit assigns a
|
|
41
|
+
* genuinely different string and the platform resets the selection, as it does
|
|
42
|
+
* for any assignment to `value`.
|
|
43
|
+
*
|
|
44
|
+
* So the selection is taken before the update and put back after it, mapped by
|
|
45
|
+
* however much the text grew or shrank, which is the same arithmetic
|
|
46
|
+
* `restoreValue` does in one step. Returned as a closure rather than a pair of
|
|
47
|
+
* fields so the two halves cannot be called out of order.
|
|
48
|
+
*
|
|
49
|
+
* The binding itself has to stay: it is what Lit SSR serializes, and a field
|
|
50
|
+
* that filled in at hydration would flash empty for every reader on a slow
|
|
51
|
+
* connection.
|
|
52
|
+
*/
|
|
53
|
+
export const holdCaret = (control) => {
|
|
54
|
+
const { selectionDirection, selectionEnd, selectionStart } = control;
|
|
55
|
+
const held = control.value.length;
|
|
56
|
+
return () => {
|
|
57
|
+
/* Null on a control with no selection, `<input type="range">` among them,
|
|
58
|
+
where `setSelectionRange` throws rather than declining. */
|
|
59
|
+
if (selectionStart === null || selectionEnd === null) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const delta = control.value.length - held;
|
|
63
|
+
const at = (point) => Math.max(0, Math.min(control.value.length, point + delta));
|
|
64
|
+
control.setSelectionRange(at(selectionStart), at(selectionEnd), selectionDirection ?? undefined);
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
export const restoreValue = (control, next) => {
|
|
68
|
+
if (control.value === next) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const { selectionDirection, selectionEnd, selectionStart } = control;
|
|
72
|
+
const delta = next.length - control.value.length;
|
|
73
|
+
control.value = next;
|
|
74
|
+
/* A control with no selection is done here, and this is a guard rather than a
|
|
75
|
+
tidiness: `<input type="range">` reports `selectionStart` as null and
|
|
76
|
+
*throws* `InvalidStateError` from `setSelectionRange`. Measured. The slider
|
|
77
|
+
restores through this same util, which is what makes the check load-bearing
|
|
78
|
+
rather than defensive. */
|
|
79
|
+
if (selectionStart === null || selectionEnd === null) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const at = (point) => Math.max(0, Math.min(next.length, point + delta));
|
|
83
|
+
control.setSelectionRange(at(selectionStart), at(selectionEnd), selectionDirection ?? undefined);
|
|
84
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A mapping from one numeric interval onto another. */
|
|
2
|
+
export type Scale = (value: number) => number;
|
|
3
|
+
/** The smallest and largest of a set of numbers, as a scale's domain. */
|
|
4
|
+
export declare const extent: (values: readonly number[]) => [number, number];
|
|
5
|
+
/**
|
|
6
|
+
* Maps a domain onto a range, linearly.
|
|
7
|
+
*
|
|
8
|
+
* A pure function rather than a controller, which is the distinction the rules
|
|
9
|
+
* draw: a controller reacts to a host's lifecycle, and this is arithmetic on
|
|
10
|
+
* four numbers. It holds nothing, subscribes to nothing, and can be called from
|
|
11
|
+
* `render` without anything being wired up first.
|
|
12
|
+
*
|
|
13
|
+
* The range is given rather than assumed, so an inverted one is ordinary: SVG
|
|
14
|
+
* counts down from the top, so a chart passes `[height, 0]` and gets a larger
|
|
15
|
+
* value drawn higher without anything here knowing why.
|
|
16
|
+
*
|
|
17
|
+
* **A flat domain maps to the middle of the range, and that is the case worth
|
|
18
|
+
* naming.** Every value being equal makes the domain zero wide, and the obvious
|
|
19
|
+
* arithmetic divides by it: seven identical readings drew a line at `NaN`, which
|
|
20
|
+
* an `<svg>` renders as no line at all rather than as an error. A flat series is
|
|
21
|
+
* a real thing to plot and it should come out as a flat line halfway up.
|
|
22
|
+
*/
|
|
23
|
+
export declare const linear: (domain: readonly [number, number], range: readonly [number, number]) => Scale;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The smallest and largest of a set of numbers, as a scale's domain. */
|
|
2
|
+
export const extent = (values) => [
|
|
3
|
+
Math.min(...values),
|
|
4
|
+
Math.max(...values),
|
|
5
|
+
];
|
|
6
|
+
/**
|
|
7
|
+
* Maps a domain onto a range, linearly.
|
|
8
|
+
*
|
|
9
|
+
* A pure function rather than a controller, which is the distinction the rules
|
|
10
|
+
* draw: a controller reacts to a host's lifecycle, and this is arithmetic on
|
|
11
|
+
* four numbers. It holds nothing, subscribes to nothing, and can be called from
|
|
12
|
+
* `render` without anything being wired up first.
|
|
13
|
+
*
|
|
14
|
+
* The range is given rather than assumed, so an inverted one is ordinary: SVG
|
|
15
|
+
* counts down from the top, so a chart passes `[height, 0]` and gets a larger
|
|
16
|
+
* value drawn higher without anything here knowing why.
|
|
17
|
+
*
|
|
18
|
+
* **A flat domain maps to the middle of the range, and that is the case worth
|
|
19
|
+
* naming.** Every value being equal makes the domain zero wide, and the obvious
|
|
20
|
+
* arithmetic divides by it: seven identical readings drew a line at `NaN`, which
|
|
21
|
+
* an `<svg>` renders as no line at all rather than as an error. A flat series is
|
|
22
|
+
* a real thing to plot and it should come out as a flat line halfway up.
|
|
23
|
+
*/
|
|
24
|
+
export const linear = (domain, range) => {
|
|
25
|
+
const [from, to] = domain;
|
|
26
|
+
const [start, end] = range;
|
|
27
|
+
const span = to - from;
|
|
28
|
+
if (span === 0) {
|
|
29
|
+
return () => (start + end) / 2;
|
|
30
|
+
}
|
|
31
|
+
return (value) => start + ((value - from) / span) * (end - start);
|
|
32
|
+
};
|