@fusion.dev/helix-core 0.142.1 → 0.143.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 (47) hide show
  1. package/custom-elements.json +1475 -54
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +55 -0
  3. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +72 -16
  4. package/dist/controllers/arrival-controller/arrival-controller.d.ts +12 -0
  5. package/dist/controllers/arrival-controller/arrival-controller.js +10 -0
  6. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +10 -0
  7. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +5 -1
  8. package/dist/controllers/copy-controller/copy-controller.js +5 -0
  9. package/dist/controllers/dismiss-controller/dismiss-controller.js +40 -3
  10. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +79 -1
  11. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +80 -10
  12. package/dist/controllers/popover-controller/popover-controller.d.ts +11 -0
  13. package/dist/controllers/popover-controller/popover-controller.js +6 -0
  14. package/dist/controllers/source-controller/source-controller.d.ts +22 -0
  15. package/dist/controllers/source-controller/source-controller.js +27 -1
  16. package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +16 -0
  17. package/dist/controllers/virtualize-controller/virtualize-controller.js +24 -2
  18. package/dist/events/events.d.ts +102 -4
  19. package/dist/events/events.js +84 -0
  20. package/dist/helix-element/react-bridge.js +6 -4
  21. package/dist/helix-form-element/helix-form-element.d.ts +131 -2
  22. package/dist/helix-form-element/helix-form-element.js +216 -6
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.js +4 -3
  25. package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +8 -0
  26. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +4 -0
  27. package/dist/mixins/mixin-collection/mixin-collection.d.ts +4 -2
  28. package/dist/mixins/mixin-collection/mixin-collection.js +8 -6
  29. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +19 -3
  30. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +39 -0
  31. package/dist/mixins/mixin-field/mixin-field.js +38 -8
  32. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.d.ts +8 -3
  33. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.js +6 -0
  34. package/dist/mixins/mixin-picker-field/mixin-picker-field.d.ts +27 -4
  35. package/dist/mixins/mixin-picker-field/mixin-picker-field.js +83 -1
  36. package/dist/utils/draw-tooltip/draw-tooltip.d.ts +3 -0
  37. package/dist/utils/draw-tooltip/draw-tooltip.js +5 -0
  38. package/dist/utils/read-series/read-series.d.ts +28 -0
  39. package/dist/utils/read-series/read-series.js +31 -4
  40. package/dist/utils/report-runtime/report-runtime.d.ts +10 -0
  41. package/dist/utils/scroll-lock/scroll-lock.d.ts +16 -0
  42. package/dist/utils/scroll-lock/scroll-lock.js +30 -0
  43. package/dist/utils/written-list/written-list.d.ts +11 -2
  44. package/dist/utils/written-list/written-list.js +3 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +4 -4
@@ -16,6 +16,19 @@ export const MixinField = (Base) => {
16
16
  this.controlId = 'control';
17
17
  this.labelId = 'label';
18
18
  this.namedBy = null;
19
+ this.filledSlots = new Set();
20
+ this.noteSlotted = (event) => {
21
+ const slot = event.target;
22
+ if (slot.name !== 'label' && slot.name !== 'hint') {
23
+ return;
24
+ }
25
+ const filled = slot.assignedNodes().length > 0;
26
+ if (filled !== this.filledSlots.has(slot.name)) {
27
+ const next = new Set(this.filledSlots);
28
+ next[filled ? 'add' : 'delete'](slot.name);
29
+ this.filledSlots = next;
30
+ }
31
+ };
19
32
  this.feedback = '';
20
33
  this.hint = '';
21
34
  this.label = '';
@@ -24,7 +37,15 @@ export const MixinField = (Base) => {
24
37
  this.shownFeedback = '';
25
38
  }
26
39
  get labeled() {
27
- return this.label !== '' || Boolean(this.getAttribute('aria-label'));
40
+ return (this.label !== '' ||
41
+ Boolean(this.getAttribute('aria-label')) ||
42
+ this.filledSlots.has('label'));
43
+ }
44
+ bareSlot(name) {
45
+ return name === 'label' ? html `<slot name="label"></slot>` : html `<slot name="hint"></slot>`;
46
+ }
47
+ get validityFeedback() {
48
+ return this.validates ? this.feedback : '';
28
49
  }
29
50
  get validates() {
30
51
  return this.constructor.formAssociated === true;
@@ -42,15 +63,15 @@ export const MixinField = (Base) => {
42
63
  if (this.resolvedFeedback !== '') {
43
64
  return FEEDBACK_ID;
44
65
  }
45
- return this.hint === '' ? undefined : HINT_ID;
66
+ return this.hint === '' && !this.filledSlots.has('hint') ? undefined : HINT_ID;
46
67
  }
47
68
  get invalidity() {
48
69
  return this.resolvedFeedback === '' ? undefined : 'true';
49
70
  }
50
71
  renderGroupLabel() {
51
72
  const hidden = this.label === '' ? this.getAttribute('aria-label') : null;
52
- if (this.label === '' && !hidden) {
53
- return nothing;
73
+ if (this.label === '' && !hidden && !this.filledSlots.has('label')) {
74
+ return this.bareSlot('label');
54
75
  }
55
76
  return html `<span part="label" id=${this.labelId} ?data-hidden=${hidden !== null}
56
77
  ><slot name="label">${this.label || hidden}</slot></span
@@ -58,8 +79,8 @@ export const MixinField = (Base) => {
58
79
  }
59
80
  renderLabel() {
60
81
  const hidden = this.label === '' ? this.getAttribute('aria-label') : null;
61
- if (this.label === '' && !hidden) {
62
- return nothing;
82
+ if (this.label === '' && !hidden && !this.filledSlots.has('label')) {
83
+ return this.bareSlot('label');
63
84
  }
64
85
  return html `<label
65
86
  part="label"
@@ -70,10 +91,15 @@ export const MixinField = (Base) => {
70
91
  >`;
71
92
  }
72
93
  renderHint() {
73
- if (this.hint === '' || this.resolvedFeedback !== '') {
94
+ if (this.resolvedFeedback !== '') {
74
95
  return nothing;
75
96
  }
76
- return html `<p part="hint" id=${HINT_ID}><slot name="hint">${this.hint}</slot></p>`;
97
+ if (this.hint === '' && !this.filledSlots.has('hint')) {
98
+ return this.bareSlot('hint');
99
+ }
100
+ return html `<p part="hint" id=${HINT_ID}
101
+ ><slot name="hint">${this.hint}</slot></p
102
+ >`;
77
103
  }
78
104
  renderFeedback() {
79
105
  const message = this.resolvedFeedback;
@@ -133,11 +159,15 @@ export const MixinField = (Base) => {
133
159
  };
134
160
  this.addEventListener('invalid', touch);
135
161
  this.addEventListener('change', touch);
162
+ this.renderRoot.addEventListener('slotchange', this.noteSlotted);
136
163
  }
137
164
  }
138
165
  __decorate([
139
166
  property({ attribute: 'aria-label' })
140
167
  ], FieldElement.prototype, "namedBy", void 0);
168
+ __decorate([
169
+ state()
170
+ ], FieldElement.prototype, "filledSlots", void 0);
141
171
  __decorate([
142
172
  property()
143
173
  ], FieldElement.prototype, "feedback", void 0);
@@ -30,6 +30,11 @@ export declare class MixinPickerEditingInterface {
30
30
  * returns `false`.
31
31
  */
32
32
  requestValue(next: string): boolean;
33
+ /**
34
+ * Requests the panel open or closed. While `readOnly` is set, it does not
35
+ * open, and it returns `false`.
36
+ */
37
+ requestOpen(next: boolean): boolean;
33
38
  }
34
39
  /**
35
40
  * Grafts the typing half of a picker field: it opens on arrival, holds the
@@ -43,9 +48,9 @@ export declare class MixinPickerEditingInterface {
43
48
  * know when the caret is in the field, which is `editing`; and a reader who
44
49
  * reaches such a field is choosing, so arriving opens the picker. A color has
45
50
  * neither: `#ff0000` is what is typed and what is shown, and a reader who has
46
- * the hex pastes it and never opens anything. `readOnly` is the third thing
47
- * Color Input does not have, and a mixin cannot give it one without changing
48
- * its API.
51
+ * the hex pastes it and never opens anything. `readOnly` comes along here for
52
+ * the two that take this mixin; Color Input declares its own, the same
53
+ * property refusing the same two requests.
49
54
  */
50
55
  export declare const MixinPickerEditing: <T extends Constructor<EditingBase>>(Base: T) => Constructor<MixinPickerEditingInterface> & T;
51
56
  export {};
@@ -32,6 +32,12 @@ export const MixinPickerEditing = (Base) => {
32
32
  }
33
33
  return super.requestValue(next);
34
34
  }
35
+ requestOpen(next) {
36
+ if (next && this.readOnly) {
37
+ return false;
38
+ }
39
+ return super.requestOpen(next);
40
+ }
35
41
  }
36
42
  __decorate([
37
43
  property({ attribute: 'readonly', reflect: true, type: Boolean })
@@ -45,7 +45,22 @@ export declare class MixinPickerFieldInterface {
45
45
  protected get display(): string;
46
46
  /** Puts the caret in the field, as clicking its label does. */
47
47
  focus(options?: FocusOptions): void;
48
- /** Escape shuts the panel without moving the value. */
48
+ /**
49
+ * The text control's own `change`, which a reader's edit ends with, said
50
+ * under the host's name.
51
+ */
52
+ protected readonly handleCommit: (event: Event) => void;
53
+ /**
54
+ * The trigger's press: opens the panel with focus in the picker, or closes
55
+ * it.
56
+ */
57
+ protected readonly handleTriggerClick: () => void;
58
+ /** Enter in the text submits the form, as it does in a native text field. */
59
+ protected readonly handleKeypress: (event: KeyboardEvent) => void;
60
+ /**
61
+ * Escape shuts the panel without moving the value, Tab out of the text shuts
62
+ * a panel the reader is passing, and ArrowDown in the text moves into it.
63
+ */
49
64
  protected readonly handleKeydown: (event: KeyboardEvent) => void;
50
65
  /** The overlay, placed against the field rather than the trigger. */
51
66
  protected readonly panel: PopoverController;
@@ -56,6 +71,14 @@ export declare class MixinPickerFieldInterface {
56
71
  * Nothing, unless a family fits what its panel holds to the room it has.
57
72
  */
58
73
  protected placed(room: number | undefined): void;
74
+ /**
75
+ * Takes a value the reader picked in the panel, and says it is settled.
76
+ *
77
+ * Returns whether it was taken. A family whose typing announces `input`
78
+ * alone sends `change` from here for a pick, since a pick is a finished
79
+ * choice.
80
+ */
81
+ protected requestPicked(next: string): boolean;
59
82
  /** Puts the field back where the answer differs from what was typed. */
60
83
  protected restore(): void;
61
84
  /** Puts the caret back in the field without the field reopening the panel. */
@@ -103,9 +126,9 @@ export declare class MixinPickerFieldInterface {
103
126
  * applies it.
104
127
  *
105
128
  * **Opening on focus, `readOnly` and the idle rendering are not here.** They
106
- * are `MixinPickerEditing`'s, because Color Input has none of the three: a hex
107
- * is pasted and read at a glance, so its field neither renders nor opens on
108
- * arrival, and giving it `readOnly` would be a property its API does not have.
129
+ * are `MixinPickerEditing`'s, because Color Input takes neither of the first
130
+ * and last: a hex is pasted and read at a glance, so its field neither renders
131
+ * nor opens on arrival. Color Input declares its own `readOnly`.
109
132
  *
110
133
  * **Three things the two typed families share stay with them.** `renderTrigger`
111
134
  * differs only by its icon and would still be the wrong thing to lift: a part a
@@ -29,7 +29,63 @@ export const MixinPickerField = (Base) => {
29
29
  event.stopPropagation();
30
30
  this.requestOpen(false);
31
31
  this.returnFocus();
32
+ return;
32
33
  }
34
+ const fromText = event.composedPath()[0] === this.control;
35
+ if (event.key === 'Tab' && this.open && fromText) {
36
+ this.requestOpen(false);
37
+ return;
38
+ }
39
+ if (event.key === 'ArrowDown' && fromText && !this.isDisabled) {
40
+ event.preventDefault();
41
+ if (this.open) {
42
+ this.partNamed('picker')?.focus();
43
+ }
44
+ else {
45
+ this.fromTrigger = true;
46
+ if (!this.requestOpen(true)) {
47
+ this.fromTrigger = false;
48
+ }
49
+ }
50
+ }
51
+ };
52
+ this.handleSurfaceKeydown = (event) => {
53
+ const surface = this.partNamed('surface');
54
+ if (event.key !== 'Escape' ||
55
+ !this.open ||
56
+ event.defaultPrevented ||
57
+ !surface ||
58
+ !event.composedPath().includes(surface)) {
59
+ return;
60
+ }
61
+ event.stopPropagation();
62
+ event.preventDefault();
63
+ this.requestOpen(false);
64
+ this.returnFocus();
65
+ };
66
+ this.handleFocusOut = (event) => {
67
+ const to = event.relatedTarget;
68
+ if (!this.open || !(to instanceof Node)) {
69
+ return;
70
+ }
71
+ if (this.contains(to) || this.renderRoot?.contains?.(to)) {
72
+ return;
73
+ }
74
+ this.requestOpen(false);
75
+ };
76
+ this.handleCommit = (event) => {
77
+ event.stopPropagation();
78
+ this.announceChange();
79
+ };
80
+ this.fromTrigger = false;
81
+ this.handleKeypress = (event) => {
82
+ if (event.key === 'Enter' && !event.defaultPrevented) {
83
+ this.submitImplicitly();
84
+ }
85
+ };
86
+ this.handleTriggerClick = () => {
87
+ this.fromTrigger = !this.open;
88
+ this.toggleOpen();
33
89
  };
34
90
  this.returning = false;
35
91
  }
@@ -78,9 +134,29 @@ export const MixinPickerField = (Base) => {
78
134
  }
79
135
  this.refreshFeedback();
80
136
  }
137
+ get blocksImplicitSubmission() {
138
+ return this.control !== null;
139
+ }
140
+ requestPicked(next) {
141
+ const taken = this.requestValue(next);
142
+ if (taken && this.announcesInput) {
143
+ this.announceChange();
144
+ }
145
+ return taken;
146
+ }
147
+ connectedCallback() {
148
+ super.connectedCallback();
149
+ this.addEventListener('keydown', this.handleSurfaceKeydown);
150
+ this.addEventListener('focusout', this.handleFocusOut);
151
+ }
152
+ disconnectedCallback() {
153
+ super.disconnectedCallback();
154
+ this.removeEventListener('keydown', this.handleSurfaceKeydown);
155
+ this.removeEventListener('focusout', this.handleFocusOut);
156
+ }
81
157
  returnFocus() {
82
158
  this.returning = true;
83
- this.control?.focus();
159
+ this.anchor?.focus();
84
160
  this.returning = false;
85
161
  }
86
162
  focus(options) {
@@ -92,6 +168,12 @@ export const MixinPickerField = (Base) => {
92
168
  }
93
169
  updated(changed) {
94
170
  super.updated(changed);
171
+ if (changed.has('open')) {
172
+ if (this.open && this.fromTrigger) {
173
+ this.partNamed('picker')?.focus();
174
+ }
175
+ this.fromTrigger = false;
176
+ }
95
177
  this.sync();
96
178
  }
97
179
  }
@@ -89,5 +89,8 @@ export interface TooltipAt {
89
89
  * **Hidden from assistive technology, on purpose.** The table is the accessible
90
90
  * representation and the drawing is `aria-hidden`; a reader on a keyboard gets
91
91
  * the table, which they can reach, rather than a tooltip which they cannot.
92
+ * The mark and the bubble carry `aria-hidden` themselves, so they stay hidden
93
+ * in a chart whose plot also holds something a reader should hear, such as
94
+ * the middle of a donut.
92
95
  */
93
96
  export declare const drawTooltip: (rows: Row[], at: number, tag: StaticValue, where?: TooltipAt, slotOf?: (cell: number) => number | undefined) => TemplateResult | typeof nothing;
@@ -3,6 +3,7 @@ import { styleMap } from 'lit/directives/style-map.js';
3
3
  import { html } from 'lit/static-html.js';
4
4
  import { seriesIndex } from '../../mixins/mixin-chart-source/mixin-chart-source.js';
5
5
  export const CHART_PLOT = 'helix-chart-plot';
6
+ const CHART_TOOLTIP = 'helix-chart-tooltip';
6
7
  export const drawTooltip = (rows, at, tag, where, slotOf) => {
7
8
  const row = rows[at];
8
9
  if (!row || rows.length === 0) {
@@ -15,6 +16,8 @@ export const drawTooltip = (rows, at, tag, where, slotOf) => {
15
16
  y: '0%',
16
17
  };
17
18
  return html `<div
19
+ aria-describedby=${CHART_TOOLTIP}
20
+ aria-hidden="true"
18
21
  id="helix-chart-mark"
19
22
  part="mark"
20
23
  style=${styleMap(place.fixed
@@ -22,12 +25,14 @@ export const drawTooltip = (rows, at, tag, where, slotOf) => {
22
25
  : { insetBlockStart: place.y, insetInlineStart: place.x })}
23
26
  ></div>
24
27
  <${tag}
28
+ aria-hidden="true"
25
29
  dir=${place.dir ?? nothing}
26
30
  exportparts="arrow: tooltip-arrow, arrow-bottom: tooltip-arrow-bottom,
27
31
  arrow-left: tooltip-arrow-left, arrow-right: tooltip-arrow-right,
28
32
  arrow-top: tooltip-arrow-top, tooltip, tooltip-bottom, tooltip-left, tooltip-right,
29
33
  tooltip-top"
30
34
  for="helix-chart-mark"
35
+ id=${CHART_TOOLTIP}
31
36
  open
32
37
  part="tooltip-anchor"
33
38
  placement=${place.placement}
@@ -1,5 +1,10 @@
1
1
  /** One point in a series: what it is called, what it is worth, and when. */
2
2
  export interface SeriesPoint {
3
+ /**
4
+ * The row the reading is in, counting from zero, which is its place among
5
+ * the rows `readRows` reads and so the category it is drawn at.
6
+ */
7
+ at: number;
3
8
  /**
4
9
  * The row's own label, taken from its header cell.
5
10
  */
@@ -50,8 +55,31 @@ export interface Series {
50
55
  * the second reads `12abc` as 12: a value that is nearly a number is a mistake
51
56
  * worth dropping rather than rounding off, and a dropped point is visible where
52
57
  * a silently truncated one is not.
58
+ *
59
+ * **A dropped point keeps its place.** Each point carries `at`, the row it was
60
+ * read from, and a chart draws it at that row's category. Counted by position
61
+ * in `points`, a blank in March slid April's reading under March's label, and a
62
+ * blank in the first column took a category off the axis for every series.
63
+ * `byRow` lays a series out one slot per row with the blanks left empty.
53
64
  */
54
65
  export declare const readSeries: (table: Element) => Series[];
66
+ /**
67
+ * A series laid out one slot per row, with nothing in a slot whose cell was
68
+ * blank or did not parse.
69
+ *
70
+ * This is the shape a chart drawing categories wants: slot `i` is the reading at
71
+ * the `i`th row, which is the label under it and the row the tooltip reads.
72
+ */
73
+ export declare const byRow: (series: Series | undefined, rows: number) => (SeriesPoint | undefined)[];
74
+ /**
75
+ * A series cut into its unbroken runs, where a run ends at a row with no
76
+ * reading.
77
+ *
78
+ * A line drawn through a gap claims a value in the gap. Drawn as runs, the
79
+ * line stops at the last reading before a blank and starts again at the first
80
+ * one after it.
81
+ */
82
+ export declare const unbroken: (series: Series) => SeriesPoint[][];
55
83
  /** One cell of a row, as a reader sees it and as a chart measures it. */
56
84
  export interface RowCell {
57
85
  /**
@@ -6,13 +6,13 @@ const isGrid = (element) => element.getAttribute('role') === 'table' ||
6
6
  /^helix-(headless-)?table$/i.test(element.tagName);
7
7
  const readGrid = (grid) => {
8
8
  const columns = [...grid.querySelectorAll(COLUMNS)];
9
- const rows = [...grid.querySelectorAll(ROWS)];
9
+ const rows = gridRows(grid);
10
10
  const names = columns.slice(1).map((cell) => cell.textContent?.trim() ?? '');
11
11
  const cellsOf = (row) => [...row.querySelectorAll(CELLS)];
12
12
  const count = Math.max(0, ...rows.map((row) => cellsOf(row).length - 1));
13
13
  return Array.from({ length: count }, (_, column) => ({
14
14
  name: names[column] ?? grid.getAttribute('label') ?? '',
15
- points: rows.flatMap((row) => {
15
+ points: rows.flatMap((row, at) => {
16
16
  const cells = cellsOf(row);
17
17
  const head = cells[0];
18
18
  const cell = cells[column + 1];
@@ -26,6 +26,7 @@ const readGrid = (grid) => {
26
26
  return Number.isFinite(value) && raw.trim() !== ''
27
27
  ? [
28
28
  {
29
+ at,
29
30
  label: head?.textContent?.trim() ?? '',
30
31
  value,
31
32
  ...(tone ? { tone } : {}),
@@ -36,6 +37,7 @@ const readGrid = (grid) => {
36
37
  }),
37
38
  }));
38
39
  };
40
+ const gridRows = (grid) => [...grid.querySelectorAll(ROWS)].filter((row) => row.querySelector(CELLS) !== null);
39
41
  const plotted = (series) => series.filter((one) => one.points.length > 0);
40
42
  export const readSeries = (table) => {
41
43
  if (isGrid(table)) {
@@ -49,7 +51,7 @@ export const readSeries = (table) => {
49
51
  const columns = Math.max(0, ...rows.map((row) => row.cells.length - 1));
50
52
  return plotted(Array.from({ length: columns }, (_, column) => ({
51
53
  name: names[column] ?? element.caption?.textContent?.trim() ?? '',
52
- points: rows.flatMap((row) => {
54
+ points: rows.flatMap((row, at) => {
53
55
  const head = row.cells[0];
54
56
  const cell = row.cells[column + 1];
55
57
  if (!cell) {
@@ -62,6 +64,7 @@ export const readSeries = (table) => {
62
64
  return Number.isFinite(value) && raw.trim() !== ''
63
65
  ? [
64
66
  {
67
+ at,
65
68
  label: head?.textContent?.trim() ?? '',
66
69
  value,
67
70
  ...(tone ? { tone } : {}),
@@ -72,6 +75,30 @@ export const readSeries = (table) => {
72
75
  }),
73
76
  })));
74
77
  };
78
+ export const byRow = (series, rows) => {
79
+ const slots = Array.from({ length: rows }, () => undefined);
80
+ for (const point of series?.points ?? []) {
81
+ if (point.at >= 0 && point.at < rows) {
82
+ slots[point.at] = point;
83
+ }
84
+ }
85
+ return slots;
86
+ };
87
+ export const unbroken = (series) => {
88
+ const runs = [];
89
+ let previous;
90
+ for (const point of series.points) {
91
+ const run = runs.at(-1);
92
+ if (run && previous && point.at === previous.at + 1) {
93
+ run.push(point);
94
+ }
95
+ else {
96
+ runs.push([point]);
97
+ }
98
+ previous = point;
99
+ }
100
+ return runs;
101
+ };
75
102
  export const readRows = (table) => {
76
103
  const cell = (name, element) => {
77
104
  if (!element) {
@@ -100,7 +127,7 @@ export const readRows = (table) => {
100
127
  const names = [...table.querySelectorAll(COLUMNS)]
101
128
  .slice(1)
102
129
  .map((one) => one.textContent?.trim() ?? '');
103
- return [...table.querySelectorAll(ROWS)].map((one) => {
130
+ return gridRows(table).map((one) => {
104
131
  const cells = [...one.querySelectorAll(CELLS)];
105
132
  return row(cells[0], cells.slice(1), names);
106
133
  });
@@ -26,6 +26,8 @@ export type RuntimeKind =
26
26
  | 'max-past-fold'
27
27
  /** A rating's `min` is no score between 0 and its `max`. */
28
28
  | 'min-out-of-range'
29
+ /** A Stat was given one of Trend's directions, `up`, `down` or `neutral`, which lay nothing out. */
30
+ | 'misread-direction'
29
31
  /** A React handler prop names an event the element never dispatches itself. */
30
32
  | 'misplaced-handler'
31
33
  /** The three.js driver could not load its layout, so the notes stay where they started. */
@@ -34,6 +36,8 @@ export type RuntimeKind =
34
36
  | 'missing-row-key'
35
37
  /** A stencil names parts the shape does not have. */
36
38
  | 'missing-stencil-parts'
39
+ /** A Skip Link's `href` names no element in its tree, so a press moves focus nowhere. */
40
+ | 'missing-target'
37
41
  /** A tooltip's `for` names no element in its own tree. */
38
42
  | 'missing-trigger'
39
43
  /** An area chart was handed a negative number, which it draws as none. */
@@ -42,6 +46,8 @@ export type RuntimeKind =
42
46
  | 'not-a-graph'
43
47
  /** The browser refused picture in picture. */
44
48
  | 'picture-in-picture-refused'
49
+ /** A Grid's `min` is no length it can write, so the grid lays out by its column count. */
50
+ | 'refused-length'
45
51
  /** A React handler prop is a removed callback, which never ran from JSX. */
46
52
  | 'removed-callback'
47
53
  /** A React handler prop uses Solid's `on:`, which React reads as another event's name. */
@@ -56,8 +62,12 @@ export type RuntimeKind =
56
62
  | 'two-default-operators'
57
63
  /** A React handler prop names an event nothing dispatches. */
58
64
  | 'unknown-event'
65
+ /** An Icon's `name` is in no icon set it can read, so it draws nothing. */
66
+ | 'unknown-icon'
59
67
  /** A tooltip's trigger does not name it in `aria-describedby`, so it is not read out. */
60
68
  | 'unlinked-trigger'
69
+ /** A Text rendered as a label wraps or names a control, which its shadow root cannot reach. */
70
+ | 'unreachable-label'
61
71
  /** A virtualized list's box has no height of its own, so its window never settles. */
62
72
  | 'unsettled-window'
63
73
  /** A slotted trigger is a plain element, which cannot be told it opens something. */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Stops the page behind a modal surface from scrolling.
3
+ *
4
+ * `showModal()` makes the page inert and leaves it scrollable, so a wheel over
5
+ * the backdrop moves the page under the dialog. `overflow: hidden` on the root
6
+ * stops that, and `scrollbar-gutter: stable` keeps the space a classic
7
+ * scrollbar took, so the page does not shift sideways when it goes. What the
8
+ * root carried before is put back when the last holder lets go.
9
+ *
10
+ * Dialog, Drawer and Lightbox hold it, and Toast reads who does.
11
+ */
12
+ export declare const lockScroll: (holder: Element) => void;
13
+ /** Lets go of the page, and gives it back once nothing else holds it. */
14
+ export declare const unlockScroll: (holder: Element) => void;
15
+ /** The surfaces holding the page still, in the order they took it. */
16
+ export declare const scrollHolders: () => Element[];
@@ -0,0 +1,30 @@
1
+ const SCROLL_LOCK = Symbol.for('fusion.helix.scroll-lock');
2
+ const record = () => globalThis[SCROLL_LOCK];
3
+ export const lockScroll = (holder) => {
4
+ const globals = globalThis;
5
+ const root = document.documentElement;
6
+ globals[SCROLL_LOCK] ??= { gutter: '', holders: new Set(), overflow: '' };
7
+ const lock = globals[SCROLL_LOCK];
8
+ if (lock.holders.has(holder)) {
9
+ return;
10
+ }
11
+ if (lock.holders.size === 0) {
12
+ lock.overflow = root.style.overflow;
13
+ lock.gutter = root.style.scrollbarGutter;
14
+ if (window.innerWidth > root.clientWidth) {
15
+ root.style.scrollbarGutter = 'stable';
16
+ }
17
+ root.style.overflow = 'hidden';
18
+ }
19
+ lock.holders.add(holder);
20
+ };
21
+ export const unlockScroll = (holder) => {
22
+ const lock = record();
23
+ if (!lock?.holders.delete(holder) || lock.holders.size > 0) {
24
+ return;
25
+ }
26
+ const root = document.documentElement;
27
+ root.style.overflow = lock.overflow;
28
+ root.style.scrollbarGutter = lock.gutter;
29
+ };
30
+ export const scrollHolders = () => [...(record()?.holders ?? [])];
@@ -1,3 +1,12 @@
1
+ /**
2
+ * What a list property may hold: what an attribute writes, or an array a
3
+ * framework assigned to the property.
4
+ *
5
+ * **An array as well, because React 19 sets the property.**
6
+ * `values={[1240, 1318]}` arrives as an array, and splitting it as a string
7
+ * threw inside the render and left the old drawing on the screen.
8
+ */
9
+ export type WrittenList = string | readonly (number | string)[] | null | undefined;
1
10
  /**
2
11
  * A list a consumer wrote into an attribute, split the way they write one.
3
12
  *
@@ -20,7 +29,7 @@
20
29
  * `for` take whitespace and only whitespace, and accepting a comma there would
21
30
  * resolve an id nobody can write.
22
31
  */
23
- export declare const writtenList: (source: string | undefined) => string[];
32
+ export declare const writtenList: (source: WrittenList) => string[];
24
33
  /**
25
34
  * The same list read as numbers, with everything that is not one dropped.
26
35
  *
@@ -33,4 +42,4 @@ export declare const writtenList: (source: string | undefined) => string[];
33
42
  * answer, because a number that makes sense here is a bound, a reading and a
34
43
  * playback rate, and only the caller knows that a rate of zero is not a rate.
35
44
  */
36
- export declare const writtenNumbers: (source: string | undefined) => number[];
45
+ export declare const writtenNumbers: (source: WrittenList) => number[];
@@ -1,4 +1,6 @@
1
- export const writtenList = (source) => (source ?? '').split(/[\s,]+/).filter((one) => one !== '');
1
+ export const writtenList = (source) => (Array.isArray(source) ? source.join(' ') : String(source ?? ''))
2
+ .split(/[\s,]+/)
3
+ .filter((one) => one !== '');
2
4
  export const writtenNumbers = (source) => writtenList(source)
3
5
  .map(Number)
4
6
  .filter((one) => Number.isFinite(one));
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * release, so the two never disagree; a spec beside this holds them to it.
7
7
  * Nothing else in the running code knows which release it is.
8
8
  */
9
- export declare const VERSION = "0.142.1";
9
+ export declare const VERSION = "0.143.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.142.1';
1
+ export const VERSION = '0.143.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-core",
3
3
  "description": "Helix element primitives: base classes, mixins, decorators, utilities.",
4
- "version": "0.142.1",
4
+ "version": "0.143.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -34,11 +34,11 @@
34
34
  "bugs": "https://github.com/project-fusion/fusion/issues",
35
35
  "dependencies": {
36
36
  "@floating-ui/dom": "^1.8.0",
37
- "@fusion.dev/helix-icons": "0.142.1"
37
+ "@fusion.dev/helix-icons": "0.143.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@fusion.dev/helix-cem": "0.142.1",
41
- "@fusion.dev/helix-testing": "0.142.1",
40
+ "@fusion.dev/helix-cem": "0.143.0",
41
+ "@fusion.dev/helix-testing": "0.143.0",
42
42
  "@lit-labs/ssr": "^4.1.0",
43
43
  "lit": "^3.2.1"
44
44
  },