@fusion.dev/helix-core 0.164.0 → 0.166.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.
@@ -15564,6 +15564,14 @@
15564
15564
  "module": "./utils/filter-marks/filter-marks.js"
15565
15565
  }
15566
15566
  },
15567
+ {
15568
+ "kind": "js",
15569
+ "name": "filterKeeps",
15570
+ "declaration": {
15571
+ "name": "filterKeeps",
15572
+ "module": "./utils/filter-marks/filter-marks.js"
15573
+ }
15574
+ },
15567
15575
  {
15568
15576
  "kind": "js",
15569
15577
  "name": "markMatches",
@@ -21908,6 +21916,61 @@
21908
21916
  }
21909
21917
  ]
21910
21918
  },
21919
+ {
21920
+ "kind": "method",
21921
+ "name": "edit",
21922
+ "privacy": "private",
21923
+ "return": {
21924
+ "type": {
21925
+ "text": "boolean"
21926
+ }
21927
+ },
21928
+ "parameters": [
21929
+ {
21930
+ "name": "element",
21931
+ "type": {
21932
+ "text": "HTMLElement"
21933
+ }
21934
+ },
21935
+ {
21936
+ "name": "key",
21937
+ "type": {
21938
+ "text": "string"
21939
+ }
21940
+ }
21941
+ ],
21942
+ "description": "Applies an editing key to the segment, the arrows, Backspace, a digit or\na letter, and says whether it edited anything."
21943
+ },
21944
+ {
21945
+ "kind": "field",
21946
+ "name": "composing",
21947
+ "type": {
21948
+ "text": "[Node, string | null][]"
21949
+ },
21950
+ "privacy": "private",
21951
+ "default": "[]",
21952
+ "description": "What the segment's text was when a composition started to write into it."
21953
+ },
21954
+ {
21955
+ "kind": "field",
21956
+ "name": "handleSegmentBeforeInput",
21957
+ "privacy": "private",
21958
+ "readonly": true,
21959
+ "description": "Text a touch keyboard writes into a segment, which arrives as input and\nnot as a key: an on-screen keyboard sends `Unidentified` for its keys,\nso a digit tapped on a phone is read here, as React Aria's segments read\nit. Every input is canceled, so the segment holds only what the field\ndraws in it."
21960
+ },
21961
+ {
21962
+ "kind": "field",
21963
+ "name": "handleSegmentInput",
21964
+ "privacy": "private",
21965
+ "readonly": true
21966
+ },
21967
+ {
21968
+ "kind": "field",
21969
+ "name": "collapseSelection",
21970
+ "privacy": "private",
21971
+ "readonly": true,
21972
+ "description": "Keeps the caret at the start of the segment with focus, where Chromium\nfires input for a typed key, and where Android's keyboard does not\ncompose over the segment's text. A selection already there, or outside\nthe segment, is left alone."
21973
+ },
21911
21974
  {
21912
21975
  "kind": "field",
21913
21976
  "name": "handleSegmentKeydown",
@@ -27555,6 +27618,37 @@
27555
27618
  }
27556
27619
  ],
27557
27620
  "description": "Marks where `needle` matches in each of `elements`, for `owner`, without\ntouching the elements. An empty needle takes the owner's marks away, which\nis also what an owner leaving the page calls.\n\n**The Custom Highlight API paints a range, and nothing is inserted.** The\nwords are usually the consumer's own light DOM, slotted through as many as\ntwo shadow roots, and wrapping the matched part in a `<mark>` would mean\nrewriting their content: markup they wrote would come back changed. So an\noption holding a link or an icon is marked as readily as one holding a bare\nword. Listbox marks what its filter matched this way, and so every family\nthat filters through one: Select, Combobox, Command and Time Picker.\n\n**The rule that paints it lives in `base.css`**, as\n`::highlight(helix-filter-match)`, rather than in a component's styles. A\nhighlight is styled from the tree its text lives in, and that text lives in\nthe consumer's document.\n\nNothing at all where the API is missing, as on a server, in a test\nenvironment or in an older browser: the text nodes are not walked for an\nanswer nothing can paint."
27621
+ },
27622
+ {
27623
+ "kind": "function",
27624
+ "name": "filterKeeps",
27625
+ "return": {
27626
+ "type": {
27627
+ "text": "boolean"
27628
+ }
27629
+ },
27630
+ "parameters": [
27631
+ {
27632
+ "name": "text",
27633
+ "type": {
27634
+ "text": "string"
27635
+ }
27636
+ },
27637
+ {
27638
+ "name": "filter",
27639
+ "type": {
27640
+ "text": "string"
27641
+ }
27642
+ },
27643
+ {
27644
+ "name": "match",
27645
+ "optional": true,
27646
+ "type": {
27647
+ "text": "((text: string, filter: string) => boolean) | undefined"
27648
+ }
27649
+ }
27650
+ ],
27651
+ "description": "Whether a filter keeps an item: its words hold the filter, in any case, or\n`match` says so where the page gave one. An empty filter keeps everything.\n\nOne answer for every family that filters, so Listbox, Command and a\nfilterable Menu keep the same items for the same words, and a `filterMatch`\nthe page writes once means the same thing in each."
27558
27652
  }
27559
27653
  ],
27560
27654
  "exports": [
@@ -27581,6 +27675,14 @@
27581
27675
  "name": "markMatches",
27582
27676
  "module": "src/utils/filter-marks/filter-marks.ts"
27583
27677
  }
27678
+ },
27679
+ {
27680
+ "kind": "js",
27681
+ "name": "filterKeeps",
27682
+ "declaration": {
27683
+ "name": "filterKeeps",
27684
+ "module": "src/utils/filter-marks/filter-marks.ts"
27685
+ }
27584
27686
  }
27585
27687
  ]
27586
27688
  },
@@ -28842,6 +28944,24 @@
28842
28944
  ],
28843
28945
  "description": "A reading in words: `value` written with `options` where they say another\nstyle, and otherwise `share`, from 0 to 1, as a percentage, whole unless\n`options` say more.\n\nMeter and Progress each wrote this, with the value each holds to its range\nand the share of it that is filled."
28844
28946
  },
28947
+ {
28948
+ "kind": "function",
28949
+ "name": "latinDigits",
28950
+ "return": {
28951
+ "type": {
28952
+ "text": "string"
28953
+ }
28954
+ },
28955
+ "parameters": [
28956
+ {
28957
+ "name": "text",
28958
+ "type": {
28959
+ "text": "string"
28960
+ }
28961
+ }
28962
+ ],
28963
+ "description": "Text with every decimal digit written in ASCII: Arabic-Indic `١٢٣`, Persian\n`۱۲۳` and Devanagari `१२३` read as `123`. A keyboard in one of those\nlocales types its own digits, and `Number` reads only ASCII."
28964
+ },
28845
28965
  {
28846
28966
  "kind": "function",
28847
28967
  "name": "parseNumber",
@@ -28897,6 +29017,14 @@
28897
29017
  "module": "src/utils/number-format/number-format.ts"
28898
29018
  }
28899
29019
  },
29020
+ {
29021
+ "kind": "js",
29022
+ "name": "latinDigits",
29023
+ "declaration": {
29024
+ "name": "latinDigits",
29025
+ "module": "src/utils/number-format/number-format.ts"
29026
+ }
29027
+ },
28900
29028
  {
28901
29029
  "kind": "js",
28902
29030
  "name": "parseNumber",
@@ -33183,7 +33311,7 @@
33183
33311
  "type": {
33184
33312
  "text": "string"
33185
33313
  },
33186
- "default": "'0.164.0'",
33314
+ "default": "'0.166.0'",
33187
33315
  "description": "The version of the package this file ships in.\n\n**Written by `fusion/scripts/version.mjs`** wherever it writes the package's\n`package.json`, and stamped back into the repository beside it after a\nrelease, so the two never disagree; a spec beside this holds them to it.\nNothing else in the running code knows which release it is."
33188
33316
  }
33189
33317
  ],
@@ -33743,6 +33871,6 @@
33743
33871
  ],
33744
33872
  "helix": {
33745
33873
  "package": "@fusion.dev/helix-core",
33746
- "version": "0.164.0"
33874
+ "version": "0.166.0"
33747
33875
  }
33748
33876
  }
@@ -12,6 +12,7 @@ const STATES = [
12
12
  'aria-controls',
13
13
  'aria-invalid',
14
14
  'aria-pressed',
15
+ 'aria-required',
15
16
  ];
16
17
  export class AriaForwardController {
17
18
  get label() {
package/dist/index.d.ts CHANGED
@@ -83,7 +83,7 @@ export { elapsedTime } from './utils/elapsed-time/elapsed-time.js';
83
83
  export { type ElementByIdOptions, elementById, searchableById, } from './utils/element-by-id/element-by-id.js';
84
84
  export { exportParts } from './utils/export-parts/export-parts.js';
85
85
  export { fillTemplate, labelWith } from './utils/fill-template/fill-template.js';
86
- export { FILTER_MARK, markMatches, rangeOf } from './utils/filter-marks/filter-marks.js';
86
+ export { FILTER_MARK, filterKeeps, markMatches, rangeOf, } from './utils/filter-marks/filter-marks.js';
87
87
  export { focusTarget } from './utils/focus-target/focus-target.js';
88
88
  export { deepActiveElement, deepContains, focusableElements, PRESSABLE, pressedOwnControl, } from './utils/focusable-elements/focusable-elements.js';
89
89
  export { afterLinkFollowed, followLink, type LinkModifiers, type LinkTarget, } from './utils/follow-link/follow-link.js';
package/dist/index.js CHANGED
@@ -83,7 +83,7 @@ export { elapsedTime } from './utils/elapsed-time/elapsed-time.js';
83
83
  export { elementById, searchableById, } from './utils/element-by-id/element-by-id.js';
84
84
  export { exportParts } from './utils/export-parts/export-parts.js';
85
85
  export { fillTemplate, labelWith } from './utils/fill-template/fill-template.js';
86
- export { FILTER_MARK, markMatches, rangeOf } from './utils/filter-marks/filter-marks.js';
86
+ export { FILTER_MARK, filterKeeps, markMatches, rangeOf, } from './utils/filter-marks/filter-marks.js';
87
87
  export { focusTarget } from './utils/focus-target/focus-target.js';
88
88
  export { deepActiveElement, deepContains, focusableElements, PRESSABLE, pressedOwnControl, } from './utils/focusable-elements/focusable-elements.js';
89
89
  export { afterLinkFollowed, followLink, } from './utils/follow-link/follow-link.js';
@@ -8,6 +8,7 @@ import { html, nothing } from 'lit';
8
8
  import { property, state } from 'lit/decorators.js';
9
9
  import { isPartial, isTwelveHourLayout, segmentKey, segmentLayout, segmentName, segmentRange, segmentsOf, segmentsValue, segmentText, segmentValueText, shownValue, } from '../../utils/date-segments/date-segments.js';
10
10
  import { inlineStep } from '../../utils/inline-step/inline-step.js';
11
+ import { latinDigits } from '../../utils/number-format/number-format.js';
11
12
  import { appleTouch } from '../../utils/platform/platform.js';
12
13
  import { verdictFlags } from '../../utils/verdict-flags/verdict-flags.js';
13
14
  import { formatWords, words } from '../../words/words.js';
@@ -27,11 +28,59 @@ export const MixinSegmentedField = (Base) => {
27
28
  this.touchApple = appleTouch(typeof navigator === 'undefined' ? undefined : navigator);
28
29
  this.entered = undefined;
29
30
  this.opener = undefined;
31
+ this.composing = [];
32
+ this.handleSegmentBeforeInput = (event) => {
33
+ const element = event.currentTarget;
34
+ if (event.inputType === 'insertCompositionText') {
35
+ this.composing = [...element.childNodes].map((node) => [node, node.nodeValue]);
36
+ return;
37
+ }
38
+ event.preventDefault();
39
+ if (event.inputType === 'deleteContentBackward' ||
40
+ event.inputType === 'deleteContentForward') {
41
+ this.typed = '';
42
+ this.edit(element, 'Backspace');
43
+ return;
44
+ }
45
+ for (const character of latinDigits(event.data ?? '')) {
46
+ this.edit(element, character);
47
+ }
48
+ };
49
+ this.handleSegmentInput = (event) => {
50
+ const element = event.currentTarget;
51
+ const { data, inputType } = event;
52
+ if (inputType !== 'insertCompositionText') {
53
+ return;
54
+ }
55
+ const kept = new Set(this.composing.map(([node]) => node));
56
+ for (const node of [...element.childNodes]) {
57
+ if (!kept.has(node)) {
58
+ node.remove();
59
+ }
60
+ }
61
+ for (const [node, text] of this.composing) {
62
+ node.nodeValue = text;
63
+ }
64
+ this.composing = [];
65
+ if (element.dataset.segment === 'dayPeriod' && data) {
66
+ this.edit(element, data.slice(-1));
67
+ }
68
+ };
69
+ this.collapseSelection = () => {
70
+ const root = this.renderRoot;
71
+ const active = this.segmentElements.find((one) => one === root?.activeElement);
72
+ const selection = document.getSelection();
73
+ const at = selection?.anchorNode;
74
+ if (active &&
75
+ selection &&
76
+ at &&
77
+ active.contains(at) &&
78
+ !(at === active && selection.isCollapsed && selection.anchorOffset === 0)) {
79
+ selection.collapse(active, 0);
80
+ }
81
+ };
30
82
  this.handleSegmentKeydown = (event) => {
31
83
  const element = event.currentTarget;
32
- const kind = element.dataset.segment;
33
- const row = Number(element.dataset.row ?? 0);
34
- const twelve = isTwelveHourLayout(this.layout);
35
84
  if (event.key === 'ArrowDown' && event.altKey) {
36
85
  event.preventDefault();
37
86
  this.opener = element;
@@ -56,36 +105,23 @@ export const MixinSegmentedField = (Base) => {
56
105
  this.typed = '';
57
106
  return;
58
107
  }
59
- if (this.readOnly || this.isDisabled) {
60
- return;
61
- }
62
- const edit = segmentKey(event.key, {
63
- kind,
64
- locale: this.segmentLocale,
65
- segments: this.parts[row] ?? {},
66
- system: this.segmentSystem,
67
- twelve,
68
- typed: this.typed,
69
- });
70
- if (edit) {
108
+ if (this.edit(element, latinDigits(event.key))) {
71
109
  event.preventDefault();
72
- this.typed = edit.typed;
73
- this.take(row, edit.segments);
74
- if (edit.advance) {
75
- this.moveFocus(element, 1);
76
- }
77
110
  }
78
111
  };
79
112
  this.handleSegmentsFocusIn = (event) => {
80
113
  this.typed = '';
114
+ document.getSelection()?.collapse(event.target, 0);
81
115
  if (!this.segmentElements.includes(event.relatedTarget)) {
82
116
  this.entered = this.value;
117
+ document.addEventListener('selectionchange', this.collapseSelection);
83
118
  }
84
119
  };
85
120
  this.handleSegmentsFocusOut = (event) => {
86
121
  if (this.segmentElements.includes(event.relatedTarget)) {
87
122
  return;
88
123
  }
124
+ document.removeEventListener('selectionchange', this.collapseSelection);
89
125
  if (this.entered !== undefined && this.entered !== this.value) {
90
126
  this.announceChange();
91
127
  }
@@ -174,6 +210,29 @@ export const MixinSegmentedField = (Base) => {
174
210
  const all = this.segmentElements;
175
211
  all[all.indexOf(from) + by]?.focus();
176
212
  }
213
+ edit(element, key) {
214
+ if (this.readOnly || this.isDisabled) {
215
+ return false;
216
+ }
217
+ const row = Number(element.dataset.row ?? 0);
218
+ const edit = segmentKey(key, {
219
+ kind: element.dataset.segment,
220
+ locale: this.segmentLocale,
221
+ segments: this.parts[row] ?? {},
222
+ system: this.segmentSystem,
223
+ twelve: isTwelveHourLayout(this.layout),
224
+ typed: this.typed,
225
+ });
226
+ if (!edit) {
227
+ return false;
228
+ }
229
+ this.typed = edit.typed;
230
+ this.take(row, edit.segments);
231
+ if (edit.advance) {
232
+ this.moveFocus(element, 1);
233
+ }
234
+ return true;
235
+ }
177
236
  willUpdate(changed) {
178
237
  super.willUpdate(changed);
179
238
  const own = this.own;
@@ -196,6 +255,7 @@ export const MixinSegmentedField = (Base) => {
196
255
  disconnectedCallback() {
197
256
  super.disconnectedCallback();
198
257
  this.removeEventListener('click', this.handleLabelPress);
258
+ document.removeEventListener('selectionchange', this.collapseSelection);
199
259
  }
200
260
  get blocksImplicitSubmission() {
201
261
  return this.segmentsShown || super.blocksImplicitSubmission;
@@ -260,7 +320,10 @@ export const MixinSegmentedField = (Base) => {
260
320
  data-segment=${kind}
261
321
  role=${spin ? 'spinbutton' : 'textbox'}
262
322
  tabindex=${this.isDisabled ? '-1' : '0'}
263
- inputmode=${kind === 'dayPeriod' ? nothing : 'numeric'}
323
+ contenteditable=${editable ? 'true' : nothing}
324
+ spellcheck=${editable ? 'false' : nothing}
325
+ autocorrect=${editable ? 'off' : nothing}
326
+ inputmode=${kind === 'dayPeriod' || !editable ? nothing : 'numeric'}
264
327
  enterkeyhint=${editable ? 'next' : nothing}
265
328
  aria-label=${named === id ? segmentName(kind, locale) : `${segmentName(kind, locale)},`}
266
329
  aria-labelledby=${named === id ? nothing : named}
@@ -273,6 +336,8 @@ export const MixinSegmentedField = (Base) => {
273
336
  aria-required=${this.required ? 'true' : nothing}
274
337
  aria-readonly=${this.readOnly ? 'true' : nothing}
275
338
  @keydown=${this.handleSegmentKeydown}
339
+ @beforeinput=${this.handleSegmentBeforeInput}
340
+ @input=${this.handleSegmentInput}
276
341
  >${text}</span
277
342
  >`;
278
343
  })}`;
@@ -34,3 +34,12 @@ export declare const rangeOf: (root: Element, needle: string) => Range[];
34
34
  * answer nothing can paint.
35
35
  */
36
36
  export declare const markMatches: (owner: Element, elements: Iterable<Element>, needle: string) => void;
37
+ /**
38
+ * Whether a filter keeps an item: its words hold the filter, in any case, or
39
+ * `match` says so where the page gave one. An empty filter keeps everything.
40
+ *
41
+ * One answer for every family that filters, so Listbox, Command and a
42
+ * filterable Menu keep the same items for the same words, and a `filterMatch`
43
+ * the page writes once means the same thing in each.
44
+ */
45
+ export declare const filterKeeps: (text: string, filter: string, match?: ((text: string, filter: string) => boolean) | undefined) => boolean;
@@ -39,3 +39,4 @@ export const markMatches = (owner, elements, needle) => {
39
39
  registry.set(FILTER_MARK, new Highlight(...all));
40
40
  }
41
41
  };
42
+ export const filterKeeps = (text, filter, match) => filter === '' || (match ? match(text, filter) : text.toLowerCase().includes(filter.toLowerCase()));
@@ -25,6 +25,12 @@ export declare const formatPercent: (share: number, locale?: string) => string;
25
25
  * and the share of it that is filled.
26
26
  */
27
27
  export declare const readingText: (value: number, share: number, locale?: string, options?: Intl.NumberFormatOptions) => string;
28
+ /**
29
+ * Text with every decimal digit written in ASCII: Arabic-Indic `١٢٣`, Persian
30
+ * `۱۲۳` and Devanagari `१२३` read as `123`. A keyboard in one of those
31
+ * locales types its own digits, and `Number` reads only ASCII.
32
+ */
33
+ export declare const latinDigits: (text: string) => string;
28
34
  /**
29
35
  * Reads a number written in a locale with the same options it is shown with,
30
36
  * or `null` where the text is no number.
@@ -14,6 +14,14 @@ export const formatPercent = (share, locale = '') => formatNumber(share, locale,
14
14
  export const readingText = (value, share, locale = '', options) => options && options.style !== 'percent'
15
15
  ? formatNumber(value, locale, options)
16
16
  : formatNumber(share, locale, options ?? WHOLE_PERCENT);
17
+ export const latinDigits = (text) => text.replace(/\p{Nd}/gu, (digit) => {
18
+ const point = digit.codePointAt(0) ?? 0;
19
+ let zero = point;
20
+ while (/\p{Nd}/u.test(String.fromCodePoint(zero - 1))) {
21
+ zero -= 1;
22
+ }
23
+ return String((point - zero) % 10);
24
+ });
17
25
  const grouped = (text, separator, decimal) => {
18
26
  const whole = (text.split(decimal)[0] ?? '').replace(/^[+-]/u, '');
19
27
  return new RegExp(`^\\d{1,3}(${literal(separator)}\\d{3})+$`, 'u').test(whole);
@@ -36,7 +44,7 @@ export const parseNumber = (text, locale = '', options) => {
36
44
  .filter(Boolean)
37
45
  .sort((a, b) => b.length - a.length);
38
46
  const percent = options?.style === 'percent' && /[%\u066a\u2030]/u.test(text);
39
- let read = text.replace(/[\s\u00a0\u202f]/gu, '');
47
+ let read = latinDigits(text).replace(/[\s\u00a0\u202f\u061c\u200e\u200f]/gu, '');
40
48
  for (const one of dropped) {
41
49
  read = read.replace(new RegExp(literal(one), 'gu'), '');
42
50
  }
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.164.0";
9
+ export declare const VERSION = "0.166.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.164.0';
1
+ export const VERSION = '0.166.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.164.0",
4
+ "version": "0.166.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -56,11 +56,11 @@
56
56
  "bugs": "https://github.com/project-fusion/fusion/issues",
57
57
  "dependencies": {
58
58
  "@floating-ui/dom": "^1.8.0",
59
- "@fusion.dev/helix-icons": "0.164.0"
59
+ "@fusion.dev/helix-icons": "0.166.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@fusion.dev/helix-cem": "0.164.0",
63
- "@fusion.dev/helix-testing": "0.164.0",
62
+ "@fusion.dev/helix-cem": "0.166.0",
63
+ "@fusion.dev/helix-testing": "0.166.0",
64
64
  "@lit-labs/ssr": "^4.1.0",
65
65
  "lit": "^3.2.1"
66
66
  },