@fusion.dev/helix-core 0.177.0 → 0.177.2

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.
@@ -12298,13 +12298,13 @@
12298
12298
  {
12299
12299
  "kind": "method",
12300
12300
  "name": "runPendingUpdate",
12301
- "privacy": "private",
12301
+ "privacy": "protected",
12302
12302
  "return": {
12303
12303
  "type": {
12304
12304
  "text": "void"
12305
12305
  }
12306
12306
  },
12307
- "description": "Runs an update that is on its way, so the verdict read next is the one\nfor what the control holds now.\n\n**A control reports its verdict as it updates, and an update waits for a\nmicrotask**, so a script that wrote `value` and asked at once was answered\nabout the value before: measured on Time Input, `value = '18:00'` past a\n`max` of 17:00 and `checkValidity()` returned true until `updateComplete`.\nA native control answers about the value it was just given, so the\nquestions a script can ask run the pending update first. Lit's\n`performUpdate` is the documented way to run one synchronously."
12307
+ "description": "Runs an update that is on its way, so the verdict read next is the one\nfor what the control holds now.\n\n**A control reports its verdict as it updates, and an update waits for a\nmicrotask**, so a script that wrote `value` and asked at once was answered\nabout the value before: measured on Time Input, `value = '18:00'` past a\n`max` of 17:00 and `checkValidity()` returned true until `updateComplete`.\nA native control answers about the value it was just given, so the\nquestions a script can ask run the pending update first. Lit's\n`performUpdate` is the documented way to run one synchronously.\n\nProtected for a control that submits on its own, which has to hand the\nform the verdict for what it holds now: Pin Input's `autosubmit`."
12308
12308
  },
12309
12309
  {
12310
12310
  "kind": "field",
@@ -12415,6 +12415,24 @@
12415
12415
  "privacy": "private",
12416
12416
  "description": "Stops listening for the labels in this control's tree."
12417
12417
  },
12418
+ {
12419
+ "kind": "method",
12420
+ "name": "formAssociatedCallback",
12421
+ "return": {
12422
+ "type": {
12423
+ "text": "void"
12424
+ }
12425
+ },
12426
+ "parameters": [
12427
+ {
12428
+ "name": "form",
12429
+ "type": {
12430
+ "text": "HTMLFormElement | null"
12431
+ }
12432
+ }
12433
+ ],
12434
+ "description": "Called by the browser when the control's form changes: watches the new\nform's `checkValidity()`, as connecting does for the form it starts in."
12435
+ },
12418
12436
  {
12419
12437
  "kind": "field",
12420
12438
  "name": "form",
@@ -21044,6 +21062,17 @@
21044
21062
  "privacy": "protected",
21045
21063
  "readonly": true,
21046
21064
  "description": "A press that focuses the field opens the picker.\n\nA reader who presses into this control is choosing, and making them find\nthe trigger afterwards is a second action for something they have already\nasked for. Typing still works with it up, because the picker follows the\nfield rather than the other way round. Focus by Tab or by `focus()` opens\nnothing: see the mixin's note.\n\n**Only where focus arrived from outside this element**, which is the whole\nof the difficulty: picking shuts the panel and puts the caret back in the\nfield, so an unguarded version reopens the picker the reader just\nanswered. `relatedTarget` is where focus came from, so a hand-off from the\npicker or the trigger is left alone and only a genuine arrival opens\nanything. `returning` covers the hand-off `relatedTarget` cannot see, and\n`MixinPickerField` says why.\n\nOn the input rather than on the field, too. Focus does not bubble, so a\nlistener there covers the text and not the trigger, which is what keeps\npressing the trigger from opening the panel on focus and then shutting it\nagain on the click."
21065
+ },
21066
+ {
21067
+ "kind": "method",
21068
+ "name": "selectOnArrival",
21069
+ "privacy": "private",
21070
+ "return": {
21071
+ "type": {
21072
+ "text": "void"
21073
+ }
21074
+ },
21075
+ "description": "Selects the text a reader tabbed into, once the field shows it for\nediting, as Tab into a native `<input>` holding text selects all of it,\nfrom either side. Not when this element hands the caret back itself.\n\n**After the update**, because focus is what turns editing on, and the\ntext the field then writes put the caret at its end. Measured in\nChromium before this: Tab into a Time Input holding `14:30` left the\nselection at 5 to 5, and typing `9am` gave `14:309am`. A press keeps\nits caret where the pointer put it."
21047
21076
  }
21048
21077
  ],
21049
21078
  "attributes": [
@@ -21361,7 +21390,7 @@
21361
21390
  "kind": "field",
21362
21391
  "name": "handleTriggerClick",
21363
21392
  "type": {
21364
- "text": "() => void"
21393
+ "text": "(event?: Event) => void"
21365
21394
  },
21366
21395
  "privacy": "protected",
21367
21396
  "readonly": true,
@@ -21619,6 +21648,17 @@
21619
21648
  "readonly": true,
21620
21649
  "description": "Escape and Tab pressed inside the panel, which `handleKeydown` never\nhears.\n\nA family binds `handleKeydown` on the field, and the surface is the\nfield's sibling, so a key pressed in the grid or the list went past it to\nthe document, where the dismissal closed the panel and left focus on a\ncell that was no longer shown. Measured: focus fell to the body. Here the\nkey is taken on the host, and focus goes back to the text.\n\n**Tab stays in the panel**, which is the APG date picker dialog and\nReact Aria's popover: the panel is a dialog, so Tab from its last control\ngoes to its first and Shift+Tab from its first goes to its last. Tab from\nthe grid left the dialog for the page behind it while the dialog stayed\nopen. Escape, a pick, or a press outside leaves it."
21621
21650
  },
21651
+ {
21652
+ "kind": "method",
21653
+ "name": "dismiss",
21654
+ "privacy": "private",
21655
+ "return": {
21656
+ "type": {
21657
+ "text": "void"
21658
+ }
21659
+ },
21660
+ "description": "Escape's close, which hands focus back to the control that opened the\npanel: the trigger after its press, which is the APG date picker\ndialog's rule, and the field otherwise.\n\n**Here rather than in each family.** Color Input had the rule alone, and\nDate Input and Time Input, on the same code, put focus in the text after\nthe trigger's press: one control before where the reader had been."
21661
+ },
21622
21662
  {
21623
21663
  "kind": "field",
21624
21664
  "name": "handleFocusOut",
@@ -21643,6 +21683,16 @@
21643
21683
  "default": "false",
21644
21684
  "description": "Whether the trigger opened the panel, so focus moves into the picker."
21645
21685
  },
21686
+ {
21687
+ "kind": "field",
21688
+ "name": "pressedOpen",
21689
+ "type": {
21690
+ "text": "boolean"
21691
+ },
21692
+ "privacy": "private",
21693
+ "default": "false",
21694
+ "description": "Whether the trigger's press opened the panel that is open now, so Escape returns there."
21695
+ },
21646
21696
  {
21647
21697
  "kind": "field",
21648
21698
  "name": "handleKeypress",
@@ -33929,7 +33979,7 @@
33929
33979
  "type": {
33930
33980
  "text": "string"
33931
33981
  },
33932
- "default": "'0.177.0'",
33982
+ "default": "'0.177.2'",
33933
33983
  "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."
33934
33984
  }
33935
33985
  ],
@@ -34565,6 +34615,6 @@
34565
34615
  ],
34566
34616
  "helix": {
34567
34617
  "package": "@fusion.dev/helix-core",
34568
- "version": "0.177.0"
34618
+ "version": "0.177.2"
34569
34619
  }
34570
34620
  }
@@ -228,8 +228,11 @@ export declare class HelixFormElement extends HelixElement {
228
228
  * A native control answers about the value it was just given, so the
229
229
  * questions a script can ask run the pending update first. Lit's
230
230
  * `performUpdate` is the documented way to run one synchronously.
231
+ *
232
+ * Protected for a control that submits on its own, which has to hand the
233
+ * form the verdict for what it holds now: Pin Input's `autosubmit`.
231
234
  */
232
- private runPendingUpdate;
235
+ protected runPendingUpdate(): void;
233
236
  /** Whether the control is checked when its form is submitted. */
234
237
  get willValidate(): boolean;
235
238
  /**
@@ -246,8 +249,10 @@ export declare class HelixFormElement extends HelixElement {
246
249
  * required `<input>` checked from a script fires `invalid` and does not match
247
250
  * `:user-invalid`, measured in Chromium. Every layer that takes `invalid` as
248
251
  * a reader's attempt asks this first, so a script that checks an untouched
249
- * field does not mark it wrong. A submission, `reportValidity()`, and the
250
- * reader's own edits still do.
252
+ * field does not mark it wrong. The same holds for `form.checkValidity()`,
253
+ * which fires `invalid` on every control in the form and marks no native
254
+ * one. A submission, `reportValidity()`, and the reader's own edits still
255
+ * mark it.
251
256
  */
252
257
  protected get checkingValidity(): boolean;
253
258
  /**
@@ -290,6 +295,11 @@ export declare class HelixFormElement extends HelixElement {
290
295
  private unwatchLabels;
291
296
  connectedCallback(): void;
292
297
  disconnectedCallback(): void;
298
+ /**
299
+ * Called by the browser when the control's form changes: watches the new
300
+ * form's `checkValidity()`, as connecting does for the form it starts in.
301
+ */
302
+ formAssociatedCallback(form: HTMLFormElement | null): void;
293
303
  /**
294
304
  * @internal Whether this control is a field that counts against implicit
295
305
  * submission: a single line of text, as `<input type="text">` is. Read by
@@ -41,6 +41,24 @@ const firstTabStop = (node) => {
41
41
  }
42
42
  return null;
43
43
  };
44
+ let formChecks = 0;
45
+ const watchedForms = new WeakSet();
46
+ const watchFormChecks = (form) => {
47
+ if (!form || watchedForms.has(form)) {
48
+ return;
49
+ }
50
+ watchedForms.add(form);
51
+ const check = form.checkValidity;
52
+ form.checkValidity = function checkValidity() {
53
+ formChecks += 1;
54
+ try {
55
+ return check.call(this);
56
+ }
57
+ finally {
58
+ formChecks -= 1;
59
+ }
60
+ };
61
+ };
44
62
  export class HelixFormElement extends HelixElement {
45
63
  static { this.formAssociated = true; }
46
64
  get isDisabled() {
@@ -172,7 +190,7 @@ export class HelixFormElement extends HelixElement {
172
190
  }
173
191
  }
174
192
  get checkingValidity() {
175
- return this.checkInProgress;
193
+ return this.checkInProgress || formChecks > 0;
176
194
  }
177
195
  reportValidity() {
178
196
  this.runPendingUpdate();
@@ -198,6 +216,7 @@ export class HelixFormElement extends HelixElement {
198
216
  }
199
217
  connectedCallback() {
200
218
  super.connectedCallback();
219
+ watchFormChecks(this.internals.form);
201
220
  this.addEventListener('click', this.focusFromLabel);
202
221
  this.addEventListener('invalid', this.refreshAnchor);
203
222
  this.unwatchLabels = watchLabels(this, () => this.requestUpdate());
@@ -209,6 +228,9 @@ export class HelixFormElement extends HelixElement {
209
228
  this.unwatchLabels?.();
210
229
  this.unwatchLabels = undefined;
211
230
  }
231
+ formAssociatedCallback(form) {
232
+ watchFormChecks(form);
233
+ }
212
234
  get blocksImplicitSubmission() {
213
235
  return false;
214
236
  }
@@ -55,10 +55,11 @@ export const MixinPickerEditing = (Base) => {
55
55
  const pressed = this.pressing;
56
56
  this.pressing = false;
57
57
  const from = event.relatedTarget;
58
- if (!pressed || this.open || this.isDisabled || this.returning) {
59
- return;
58
+ const inside = from instanceof Node && Boolean(this.renderRoot?.contains?.(from));
59
+ if (!pressed && !this.returning) {
60
+ this.selectOnArrival();
60
61
  }
61
- if (from instanceof Node && this.renderRoot?.contains?.(from)) {
62
+ if (!pressed || this.open || this.isDisabled || this.returning || inside) {
62
63
  return;
63
64
  }
64
65
  this.requestOpen(true);
@@ -90,6 +91,15 @@ export const MixinPickerEditing = (Base) => {
90
91
  this.removeEventListener('click', this.handlePress);
91
92
  this.removeEventListener('keydown', this.handleEditKey);
92
93
  }
94
+ selectOnArrival() {
95
+ void this.updateComplete.then(() => {
96
+ const control = this.control;
97
+ const root = this.renderRoot;
98
+ if (control && root?.activeElement === control) {
99
+ control.select();
100
+ }
101
+ });
102
+ }
93
103
  }
94
104
  __decorate([
95
105
  property({ attribute: 'readonly', reflect: true, type: Boolean })
@@ -56,7 +56,7 @@ export declare class MixinPickerFieldInterface {
56
56
  * The trigger's press: opens the panel with focus in the picker, or closes
57
57
  * it.
58
58
  */
59
- protected readonly handleTriggerClick: () => void;
59
+ protected readonly handleTriggerClick: (event?: Event) => void;
60
60
  /** Enter in the text submits the form, as it does in a native text field. */
61
61
  protected readonly handleKeypress: (event: KeyboardEvent) => void;
62
62
  /**
@@ -30,8 +30,7 @@ export const MixinPickerField = (Base) => {
30
30
  this.handleKeydown = (event) => {
31
31
  if (event.key === 'Escape' && this.open) {
32
32
  event.stopPropagation();
33
- this.requestOpen(false);
34
- this.returnFocus();
33
+ this.dismiss();
35
34
  return;
36
35
  }
37
36
  const fromText = event.composedPath()[0] === this.control;
@@ -73,8 +72,7 @@ export const MixinPickerField = (Base) => {
73
72
  }
74
73
  event.stopPropagation();
75
74
  event.preventDefault();
76
- this.requestOpen(false);
77
- this.returnFocus();
75
+ this.dismiss();
78
76
  };
79
77
  this.handleFocusOut = (event) => {
80
78
  const to = event.relatedTarget;
@@ -91,14 +89,17 @@ export const MixinPickerField = (Base) => {
91
89
  this.announceChange();
92
90
  };
93
91
  this.fromTrigger = false;
92
+ this.pressedOpen = false;
94
93
  this.handleKeypress = (event) => {
95
94
  if (event.key === 'Enter' && !event.defaultPrevented) {
96
95
  submitImplicitly(this.form);
97
96
  }
98
97
  };
99
- this.handleTriggerClick = () => {
100
- this.fromTrigger = !this.open;
98
+ this.handleTriggerClick = (event) => {
99
+ const opening = !this.open;
100
+ this.fromTrigger = opening;
101
101
  this.toggleOpen();
102
+ this.pressedOpen = event !== undefined && opening && this.open;
102
103
  };
103
104
  this.returning = false;
104
105
  }
@@ -147,6 +148,18 @@ export const MixinPickerField = (Base) => {
147
148
  }
148
149
  this.refreshFeedback();
149
150
  }
151
+ dismiss() {
152
+ const trigger = this.pressedOpen ? this.partNamed('trigger') : null;
153
+ this.pressedOpen = false;
154
+ this.requestOpen(false);
155
+ if (trigger) {
156
+ this.returning = true;
157
+ trigger.focus();
158
+ this.returning = false;
159
+ return;
160
+ }
161
+ this.returnFocus();
162
+ }
150
163
  get blocksImplicitSubmission() {
151
164
  return this.control !== null;
152
165
  }
@@ -186,6 +199,9 @@ export const MixinPickerField = (Base) => {
186
199
  this.partNamed('picker')?.focus();
187
200
  }
188
201
  this.fromTrigger = false;
202
+ if (!this.open) {
203
+ this.pressedOpen = false;
204
+ }
189
205
  }
190
206
  this.sync();
191
207
  }
@@ -32,8 +32,9 @@ export const parseNumber = (text, locale = '', options) => {
32
32
  const parts = format.formatToParts(-12345.6);
33
33
  const one = format.formatToParts(1);
34
34
  const of = (type, from = parts) => from.filter((part) => part.type === type).map((part) => part.value);
35
- const group = of('group')[0] ?? ',';
36
- const decimal = of('decimal')[0] ?? '.';
35
+ const plain = formatter(locale, undefined).formatToParts(-12345.6);
36
+ const group = of('group')[0] ?? of('group', plain)[0] ?? ',';
37
+ const decimal = of('decimal')[0] ?? of('decimal', plain)[0] ?? '.';
37
38
  const named = (from) => [
38
39
  ...of('currency', from),
39
40
  ...of('percentSign', from),
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.177.0";
9
+ export declare const VERSION = "0.177.2";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.177.0';
1
+ export const VERSION = '0.177.2';
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.177.0",
4
+ "version": "0.177.2",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -68,11 +68,11 @@
68
68
  "bugs": "https://github.com/project-fusion/fusion/issues",
69
69
  "dependencies": {
70
70
  "@floating-ui/dom": "^1.8.0",
71
- "@fusion.dev/helix-icons": "0.177.0"
71
+ "@fusion.dev/helix-icons": "0.177.2"
72
72
  },
73
73
  "devDependencies": {
74
- "@fusion.dev/helix-cem": "0.177.0",
75
- "@fusion.dev/helix-testing": "0.177.0",
74
+ "@fusion.dev/helix-cem": "0.177.2",
75
+ "@fusion.dev/helix-testing": "0.177.2",
76
76
  "@lit-labs/ssr": "^4.1.0",
77
77
  "lit": "^3.2.1"
78
78
  },