@fusion.dev/helix-core 0.163.0 → 0.165.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.
@@ -19665,6 +19665,25 @@
19665
19665
  "privacy": "protected",
19666
19666
  "description": "Where focus lands when a neighbor that held it is removed: the dismiss control.",
19667
19667
  "readonly": true
19668
+ },
19669
+ {
19670
+ "kind": "field",
19671
+ "name": "present",
19672
+ "type": {
19673
+ "text": "boolean"
19674
+ },
19675
+ "privacy": "protected",
19676
+ "readonly": true
19677
+ },
19678
+ {
19679
+ "kind": "method",
19680
+ "name": "handFocusOn",
19681
+ "privacy": "protected",
19682
+ "return": {
19683
+ "type": {
19684
+ "text": "void"
19685
+ }
19686
+ }
19668
19687
  }
19669
19688
  ],
19670
19689
  "helix": {}
@@ -19729,6 +19748,27 @@
19729
19748
  "description": "Where focus lands when a neighbor that held it is removed: this\nelement's dismiss control.\n\nA seam for a member whose focus lives elsewhere: a tag in a Tag Group is\na row the arrows move between, so a removed row hands focus to the next\nrow rather than to its remove button.",
19730
19749
  "readonly": true
19731
19750
  },
19751
+ {
19752
+ "kind": "field",
19753
+ "name": "present",
19754
+ "type": {
19755
+ "text": "boolean"
19756
+ },
19757
+ "privacy": "protected",
19758
+ "description": "Whether the element is still on the page for a reader, which is what\nthe focus hand-off waits on: connected, for a surface the page removes.\nA surface that hides itself says it is gone once hidden, and calls\n`handFocusOn` then.",
19759
+ "readonly": true
19760
+ },
19761
+ {
19762
+ "kind": "method",
19763
+ "name": "handFocusOn",
19764
+ "privacy": "protected",
19765
+ "return": {
19766
+ "type": {
19767
+ "text": "void"
19768
+ }
19769
+ },
19770
+ "description": "Hands focus on now, where a dismissal is waiting for this element to\ngo: for a surface that hides itself, once it has."
19771
+ },
19732
19772
  {
19733
19773
  "kind": "method",
19734
19774
  "name": "dismiss",
@@ -21868,6 +21908,61 @@
21868
21908
  }
21869
21909
  ]
21870
21910
  },
21911
+ {
21912
+ "kind": "method",
21913
+ "name": "edit",
21914
+ "privacy": "private",
21915
+ "return": {
21916
+ "type": {
21917
+ "text": "boolean"
21918
+ }
21919
+ },
21920
+ "parameters": [
21921
+ {
21922
+ "name": "element",
21923
+ "type": {
21924
+ "text": "HTMLElement"
21925
+ }
21926
+ },
21927
+ {
21928
+ "name": "key",
21929
+ "type": {
21930
+ "text": "string"
21931
+ }
21932
+ }
21933
+ ],
21934
+ "description": "Applies an editing key to the segment, the arrows, Backspace, a digit or\na letter, and says whether it edited anything."
21935
+ },
21936
+ {
21937
+ "kind": "field",
21938
+ "name": "composing",
21939
+ "type": {
21940
+ "text": "[Node, string | null][]"
21941
+ },
21942
+ "privacy": "private",
21943
+ "default": "[]",
21944
+ "description": "What the segment's text was when a composition started to write into it."
21945
+ },
21946
+ {
21947
+ "kind": "field",
21948
+ "name": "handleSegmentBeforeInput",
21949
+ "privacy": "private",
21950
+ "readonly": true,
21951
+ "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."
21952
+ },
21953
+ {
21954
+ "kind": "field",
21955
+ "name": "handleSegmentInput",
21956
+ "privacy": "private",
21957
+ "readonly": true
21958
+ },
21959
+ {
21960
+ "kind": "field",
21961
+ "name": "collapseSelection",
21962
+ "privacy": "private",
21963
+ "readonly": true,
21964
+ "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."
21965
+ },
21871
21966
  {
21872
21967
  "kind": "field",
21873
21968
  "name": "handleSegmentKeydown",
@@ -28802,6 +28897,24 @@
28802
28897
  ],
28803
28898
  "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."
28804
28899
  },
28900
+ {
28901
+ "kind": "function",
28902
+ "name": "latinDigits",
28903
+ "return": {
28904
+ "type": {
28905
+ "text": "string"
28906
+ }
28907
+ },
28908
+ "parameters": [
28909
+ {
28910
+ "name": "text",
28911
+ "type": {
28912
+ "text": "string"
28913
+ }
28914
+ }
28915
+ ],
28916
+ "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."
28917
+ },
28805
28918
  {
28806
28919
  "kind": "function",
28807
28920
  "name": "parseNumber",
@@ -28857,6 +28970,14 @@
28857
28970
  "module": "src/utils/number-format/number-format.ts"
28858
28971
  }
28859
28972
  },
28973
+ {
28974
+ "kind": "js",
28975
+ "name": "latinDigits",
28976
+ "declaration": {
28977
+ "name": "latinDigits",
28978
+ "module": "src/utils/number-format/number-format.ts"
28979
+ }
28980
+ },
28860
28981
  {
28861
28982
  "kind": "js",
28862
28983
  "name": "parseNumber",
@@ -33143,7 +33264,7 @@
33143
33264
  "type": {
33144
33265
  "text": "string"
33145
33266
  },
33146
- "default": "'0.163.0'",
33267
+ "default": "'0.165.0'",
33147
33268
  "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."
33148
33269
  }
33149
33270
  ],
@@ -33703,6 +33824,6 @@
33703
33824
  ],
33704
33825
  "helix": {
33705
33826
  "package": "@fusion.dev/helix-core",
33706
- "version": "0.163.0"
33827
+ "version": "0.165.0"
33707
33828
  }
33708
33829
  }
@@ -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() {
@@ -15,6 +15,8 @@ export declare class DismissibleElement {
15
15
  protected dismissEvent(): HelixDismissEvent;
16
16
  /** Where focus lands when a neighbor that held it is removed: the dismiss control. */
17
17
  protected get returnFocusTo(): HTMLElement | null;
18
+ protected get present(): boolean;
19
+ protected handFocusOn(): void;
18
20
  }
19
21
  /**
20
22
  * Grafts `dismissible`, its label, and the event that says so.
@@ -29,6 +29,16 @@ export const MixinDismissible = (Base) => {
29
29
  get returnFocusTo() {
30
30
  return this.shadowRoot?.querySelector('[part~="dismiss"]') ?? null;
31
31
  }
32
+ get present() {
33
+ return this.isConnected;
34
+ }
35
+ handFocusOn() {
36
+ const settle = this.handOff;
37
+ if (settle) {
38
+ this.forgetFocus?.();
39
+ requestAnimationFrame(() => settle(true));
40
+ }
41
+ }
32
42
  dismiss() {
33
43
  const inside = deepContains(this, deepActiveElement());
34
44
  const neighbors = inside ? this.neighbors() : [];
@@ -70,7 +80,8 @@ export const MixinDismissible = (Base) => {
70
80
  this.forgetFocus?.();
71
81
  const settle = (late) => {
72
82
  const active = document.activeElement;
73
- if (this.isConnected || (active !== null && active !== document.body)) {
83
+ const dropped = active === null || active === document.body || deepContains(this, active);
84
+ if (this.present || !dropped) {
74
85
  return;
75
86
  }
76
87
  for (const neighbor of neighbors) {
@@ -101,11 +112,7 @@ export const MixinDismissible = (Base) => {
101
112
  }
102
113
  disconnectedCallback() {
103
114
  super.disconnectedCallback();
104
- const settle = this.handOff;
105
- if (settle) {
106
- this.forgetFocus?.();
107
- requestAnimationFrame(() => settle(true));
108
- }
115
+ this.handFocusOn();
109
116
  }
110
117
  }
111
118
  __decorate([
@@ -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
  })}`;
@@ -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.163.0";
9
+ export declare const VERSION = "0.165.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.163.0';
1
+ export const VERSION = '0.165.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.163.0",
4
+ "version": "0.165.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.163.0"
59
+ "@fusion.dev/helix-icons": "0.165.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@fusion.dev/helix-cem": "0.163.0",
63
- "@fusion.dev/helix-testing": "0.163.0",
62
+ "@fusion.dev/helix-cem": "0.165.0",
63
+ "@fusion.dev/helix-testing": "0.165.0",
64
64
  "@lit-labs/ssr": "^4.1.0",
65
65
  "lit": "^3.2.1"
66
66
  },