@fusion.dev/helix-headless 0.164.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.
@@ -8,7 +8,7 @@ declare const HelixHeadlessNumberInput_base: (new (...args: any[]) => import("@f
8
8
  * A separate family from `Input`'s `type="number"`. It renders two buttons,
9
9
  * binds four keys `Input` does not, and handles clamping and stepping.
10
10
  *
11
- * The field is `type="text"` with `inputmode="decimal"`. Tested in a real
11
+ * The field is `type="text"` with an `inputmode` for a number. Tested in a real
12
12
  * browser, `type="number"` fails in three ways:
13
13
  * - Typing `12e` gives a `.value` of `""`, so the element cannot see what you
14
14
  * typed. A `type="text"` field keeps `"12e"`.
@@ -16,7 +16,11 @@ declare const HelixHeadlessNumberInput_base: (new (...args: any[]) => import("@f
16
16
  * to 4, when you only meant to scroll the page.
17
17
  * - `selectionStart` is `null`, so the caret cannot be managed.
18
18
  *
19
- * `inputmode="decimal"` keeps the numeric keypad on phones.
19
+ * `inputmode` keeps a numeric keypad on phones, chosen as React Aria's
20
+ * NumberField chooses it: a keypad with a minus key where the number can be
21
+ * negative, and one with a decimal separator where the format shows a
22
+ * fraction. An iPhone has no keypad with a minus key, so a field that can be
23
+ * negative takes the full keyboard there.
20
24
  *
21
25
  * `format-options` shows the number with `Intl.NumberFormat` in `locale`, as a
22
26
  * currency, a percent or a unit, while the field is not being edited. Focus
@@ -85,6 +89,8 @@ declare const HelixHeadlessNumberInput_base: (new (...args: any[]) => import("@f
85
89
  * @csspart increment-mark - The icon on the step up button.
86
90
  * @csspart label - The label above the field. It is also the grip you drag when `scrub` is set.
87
91
  *
92
+ * @cssstate at-max - The number is at `max`, so it cannot step up, as React Aria's NumberField says with `canIncrement`.
93
+ * @cssstate at-min - The number is at `min`, so it cannot step down.
88
94
  * @cssstate invalid - The control has an error to show, from the browser's validation or from your `feedback`.
89
95
  * @cssstate scrubbing - The label is being dragged to change the value, under `scrub`, as Zag's scrubber says while it is held.
90
96
  */
@@ -209,12 +215,13 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
209
215
  * invalid, as it is in a native number field.
210
216
  *
211
217
  * Unset, a step moves by one, or by one percent under a percent format, and
212
- * a typed number is rounded to the smallest amount `format-options` shows
213
- * rather than to whole steps, as React Aria's NumberField does: a field
214
- * showing dollars keeps the cents typed into it. Without `format-options`
215
- * the grid is whole numbers, as in a native number field.
218
+ * a typed number is rounded to the smallest amount the format shows, as
219
+ * React Aria's NumberField does: a field showing dollars keeps the cents
220
+ * typed into it, and one with no `format-options` keeps three decimal
221
+ * places. `any` puts the number on no grid, as it does in a native number
222
+ * field: a step moves by one, and any number in range is valid.
216
223
  */
217
- step?: number;
224
+ step?: number | 'any';
218
225
  /**
219
226
  * The message the browser shows when the number is off the step grid, with
220
227
  * `{below}` and `{above}` filled in with the nearest valid values, written in
@@ -251,16 +258,30 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
251
258
  * has no navigator and renders the spinbutton.
252
259
  */
253
260
  private readonly touchApple;
261
+ /** The phone the field is on, read once on the client, for the keypad it offers. */
262
+ private readonly phone;
263
+ /**
264
+ * The keypad a phone shows, as React Aria's NumberField chooses it.
265
+ * `numeric` on Android has a minus key and a decimal separator, and
266
+ * `decimal` has no minus key. On an iPhone neither has a minus key, so a
267
+ * number that can be negative takes the full keyboard. Measured before
268
+ * this: `decimal` with `min="-10"`, so a phone could not type the minus.
269
+ */
270
+ private get keypad();
254
271
  private get control();
255
272
  /** The value as a number, or `null` where the field holds nothing usable. */
256
273
  private get numeric();
257
274
  /** The grain last read off the format, with the locale and options it was read for. */
258
275
  private grainOf?;
276
+ /** The step as a number, or zero where it is unset or `any`. */
277
+ private get stepSize();
259
278
  /** How far one press moves: `step`, or the stride the format implies. */
260
279
  private get stride();
261
280
  /**
262
- * The grid a typed number is rounded to: `step` where it is set, and
263
- * otherwise the smallest amount the format shows, or whole numbers.
281
+ * The grid a typed number is rounded to: `step` where it is set, nothing
282
+ * where it is `any`, and otherwise the smallest amount the format shows.
283
+ * Measured before this: with no step and no format, `2,75` typed in German
284
+ * became 3 on blur.
264
285
  */
265
286
  private get grid();
266
287
  /**
@@ -323,6 +344,8 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
323
344
  * `handleStepperClick`.
324
345
  */
325
346
  private readonly handleStepperDown;
347
+ /** Keeps a pressed stepper from taking focus from the field. */
348
+ private readonly handleStepperMouseDown;
326
349
  /**
327
350
  * Ends a hold. The click that follows a release arrives in the same task,
328
351
  * so the press is forgotten after it.
@@ -12,6 +12,20 @@ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { decimals, formatGrain, strideOf } from '../grain.js';
13
13
  const SCRUB_PITCH = 4;
14
14
  const SCRUB_FINE = 16;
15
+ const PLAIN_FORMAT = {};
16
+ const stepConverter = {
17
+ fromAttribute: (text) => {
18
+ if (text === null) {
19
+ return undefined;
20
+ }
21
+ return text.trim().toLowerCase() === 'any' ? 'any' : Number(text);
22
+ },
23
+ toAttribute: (step) => step === undefined ? undefined : String(step),
24
+ };
25
+ const phoneOf = (platform) => {
26
+ const agent = platform?.userAgent ?? '';
27
+ return /iPhone|iPod/u.test(agent) ? 'iphone' : /android/iu.test(agent) ? 'android' : '';
28
+ };
15
29
  const PLAIN = /^-?(\d+(\.\d*)?|\.\d+)$/;
16
30
  const REPEAT_DELAY = 400;
17
31
  const REPEAT_EVERY = 60;
@@ -53,6 +67,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
53
67
  this.editing = true;
54
68
  };
55
69
  this.touchApple = appleTouch(typeof navigator === 'undefined' ? undefined : navigator);
70
+ this.phone = phoneOf(typeof navigator === 'undefined' ? undefined : navigator);
56
71
  this.pressStepped = false;
57
72
  this.handleStepperDown = (event) => {
58
73
  if (event.button !== 0) {
@@ -60,6 +75,9 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
60
75
  }
61
76
  const button = event.currentTarget;
62
77
  const up = button.getAttribute('part') === 'increment';
78
+ if (event.pointerType === 'mouse' && this.control && !this.control.matches(':focus')) {
79
+ this.control.focus({ preventScroll: true });
80
+ }
63
81
  clearTimeout(this.repeat);
64
82
  this.pressStepped = true;
65
83
  button.setPointerCapture?.(event.pointerId);
@@ -79,6 +97,9 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
79
97
  this.repeat = setTimeout(step, REPEAT_DELAY);
80
98
  }
81
99
  };
100
+ this.handleStepperMouseDown = (event) => {
101
+ event.preventDefault();
102
+ };
82
103
  this.stopRepeat = () => {
83
104
  clearTimeout(this.repeat);
84
105
  this.repeat = undefined;
@@ -217,6 +238,18 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
217
238
  }
218
239
  return formatNumber(parsed, this.locale, this.formatOptions);
219
240
  }
241
+ get keypad() {
242
+ const negative = this.min === undefined || this.min < 0;
243
+ const grid = this.grid;
244
+ const fraction = grid === 0 || !Number.isInteger(grid);
245
+ if (this.phone === 'iphone') {
246
+ return negative ? 'text' : fraction ? 'decimal' : 'numeric';
247
+ }
248
+ if (this.phone === 'android') {
249
+ return negative ? 'numeric' : fraction ? 'decimal' : 'numeric';
250
+ }
251
+ return 'numeric';
252
+ }
220
253
  get control() {
221
254
  return this.partNamed('control');
222
255
  }
@@ -227,15 +260,18 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
227
260
  }
228
261
  return parseNumber(text, this.locale, this.formatOptions);
229
262
  }
263
+ get stepSize() {
264
+ return typeof this.step === 'number' ? Math.abs(this.step) || 0 : 0;
265
+ }
230
266
  get stride() {
231
- return Math.abs(this.step ?? 0) || strideOf(this.formatOptions);
267
+ return this.stepSize || strideOf(this.formatOptions);
232
268
  }
233
269
  get grid() {
234
- const step = Math.abs(this.step ?? 0);
235
- const options = this.formatOptions;
236
- if (step || !options) {
237
- return step || 1;
270
+ const step = this.stepSize;
271
+ if (step || this.step === 'any') {
272
+ return step;
238
273
  }
274
+ const options = this.formatOptions ?? PLAIN_FORMAT;
239
275
  const [locale, read, grain] = this.grainOf ?? [];
240
276
  if (locale === this.locale && read === options && grain !== undefined) {
241
277
  return grain;
@@ -252,6 +288,9 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
252
288
  }
253
289
  onStep(value) {
254
290
  const step = this.grid;
291
+ if (!step) {
292
+ return true;
293
+ }
255
294
  const anchor = this.min ?? 0;
256
295
  const steps = (value - anchor) / step;
257
296
  return Math.abs(steps - Math.round(steps)) < 1e-9;
@@ -259,11 +298,11 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
259
298
  clamp(value) {
260
299
  const step = this.grid;
261
300
  const anchor = this.min ?? 0;
262
- const stepped = anchor + Math.round((value - anchor) / step) * step;
301
+ const stepped = step ? anchor + Math.round((value - anchor) / step) * step : value;
263
302
  const floored = this.min === undefined ? stepped : Math.max(this.min, stepped);
264
303
  const capped = this.max === undefined ? floored : Math.min(this.max, floored);
265
304
  const places = Math.max(decimals(step), decimals(anchor));
266
- return Number(capped.toFixed(places));
305
+ return step ? Number(capped.toFixed(places)) : capped;
267
306
  }
268
307
  nudge(steps) {
269
308
  if (this.isDisabled || this.readOnly) {
@@ -387,6 +426,9 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
387
426
  this.releaseCaret?.();
388
427
  this.releaseCaret = undefined;
389
428
  this.sync();
429
+ const parsed = this.numeric;
430
+ this.toggleState('at-min', parsed !== null && this.min !== undefined && parsed <= this.min);
431
+ this.toggleState('at-max', parsed !== null && this.max !== undefined && parsed >= this.max);
390
432
  }
391
433
  get blocksImplicitSubmission() {
392
434
  return true;
@@ -419,6 +461,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
419
461
  ?disabled=${this.isDisabled || this.readOnly || atMin}
420
462
  @click=${this.handleStepperClick}
421
463
  @lostpointercapture=${this.stopRepeat}
464
+ @mousedown=${this.handleStepperMouseDown}
422
465
  @pointercancel=${this.stopRepeat}
423
466
  @pointerdown=${this.handleStepperDown}
424
467
  @pointerup=${this.stopRepeat}
@@ -434,7 +477,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
434
477
  type="text"
435
478
  role=${this.touchApple ? nothing : 'spinbutton'}
436
479
  aria-roledescription=${this.touchApple || this.roleLabel === '' ? nothing : this.roleLabel}
437
- inputmode="decimal"
480
+ inputmode=${this.keypad}
438
481
  autocomplete=${this.autocomplete || nothing}
439
482
  name=${this.name || nothing}
440
483
  placeholder=${this.placeholder || nothing}
@@ -474,6 +517,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
474
517
  ?disabled=${this.isDisabled || this.readOnly || atMax}
475
518
  @click=${this.handleStepperClick}
476
519
  @lostpointercapture=${this.stopRepeat}
520
+ @mousedown=${this.handleStepperMouseDown}
477
521
  @pointercancel=${this.stopRepeat}
478
522
  @pointerdown=${this.handleStepperDown}
479
523
  @pointerup=${this.stopRepeat}
@@ -542,7 +586,7 @@ __decorate([
542
586
  property({ reflect: true, type: Boolean })
543
587
  ], HelixHeadlessNumberInput.prototype, "scrub", void 0);
544
588
  __decorate([
545
- property({ type: Number })
589
+ property({ converter: stepConverter })
546
590
  ], HelixHeadlessNumberInput.prototype, "step", void 0);
547
591
  __decorate([
548
592
  words,
@@ -0,0 +1 @@
1
+ export * from './option-root.js';
@@ -0,0 +1 @@
1
+ export * from './option-root.js';
@@ -0,0 +1 @@
1
+ export * from './tag-root.js';
@@ -0,0 +1 @@
1
+ export * from './tag-root.js';
@@ -1,6 +1,6 @@
1
1
  import { AriaForwardController, HelixFormElement, type HourCycle } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
- import '../../tag/index.js';
3
+ import '../../tag/root/index.js';
4
4
  import '../../time-picker/index.js';
5
5
  declare const HelixHeadlessTimeInput_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinSegmentedFieldInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinPickerEditingInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinPickerFieldInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerOpenInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinTokensInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerValueInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinFieldInterface) & typeof HelixFormElement;
6
6
  /**
@@ -349,8 +349,9 @@ export declare class HelixHeadlessTimeInput extends HelixHeadlessTimeInput_base
349
349
  /**
350
350
  * Each time, where `mode` holds several, sent one entry each.
351
351
  *
352
- * One time that is text and no time, such as `2:45 P`, is sent as an empty
353
- * entry, as a native time field sends a time it cannot read.
352
+ * Text that is no time, such as `2:45 P`, is sent as one empty entry, as a
353
+ * native time field sends a time it cannot read. Measured before this: a
354
+ * range written `09:00/10:30` was invalid and submitted the text.
354
355
  */
355
356
  protected get answers(): readonly string[] | undefined;
356
357
  /** What is wrong with the times, which the text input cannot say. */
@@ -4,13 +4,13 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { AriaForwardController, valueAsTime as asTime, clockLocale, customElement, drawIcon, formatWords, timeAsValue as fromTime, HelixClearEvent, HelixFormElement, handedLocale, isClockValue, MixinControllerOpen, MixinControllerValue, MixinField, MixinPickerEditing, MixinPickerField, MixinSegmentedField, MixinTokens, readWrittenTime, timeFault, words, wordsLocale, writeClock, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, valueAsTime as asTime, clockLocale, customElement, drawIcon, formatWords, timeAsValue as fromTime, HelixClearEvent, HelixFormElement, handedLocale, hostWords, isClockValue, MixinControllerOpen, MixinControllerValue, MixinField, MixinPickerEditing, MixinPickerField, MixinSegmentedField, MixinTokens, readWrittenTime, slottedWords, timeFault, words, wordsLocale, writeClock, } from '@fusion.dev/helix-core';
8
8
  import { clock, x } from '@fusion.dev/helix-icons';
9
9
  import { nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { html } from 'lit/static-html.js';
13
- import '../../tag/index.js';
13
+ import '../../tag/root/index.js';
14
14
  import '../../time-picker/index.js';
15
15
  let HelixHeadlessTimeInput = class HelixHeadlessTimeInput extends MixinSegmentedField(MixinPickerEditing(MixinPickerField(MixinControllerOpen(MixinTokens(MixinControllerValue(MixinField(HelixFormElement))))))) {
16
16
  constructor() {
@@ -174,7 +174,8 @@ let HelixHeadlessTimeInput = class HelixHeadlessTimeInput extends MixinSegmented
174
174
  }
175
175
  get answers() {
176
176
  if (this.mode !== undefined) {
177
- return this.held;
177
+ const held = this.held;
178
+ return held.every((one) => this.isTime(one)) ? held : [''];
178
179
  }
179
180
  const text = this.value.trim();
180
181
  return text === '' || this.isTime(text) ? undefined : [''];
@@ -242,7 +243,7 @@ let HelixHeadlessTimeInput = class HelixHeadlessTimeInput extends MixinSegmented
242
243
  variant=${this.variant ?? nothing}
243
244
  disable-times=${this.disableTimes || nothing}
244
245
  manual
245
- label=${this.label || 'Time'}
246
+ label=${hostWords(this, slottedWords(this, ['label']) || this.label) || 'Time'}
246
247
  hour-cycle=${this.hourCycle ?? nothing}
247
248
  locale=${handedLocale(this) || nothing}
248
249
  max=${this.max}
@@ -1,7 +1,7 @@
1
1
  import { HelixFormElement, type HourCycle, type PickerFailure } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
3
  import '../../listbox/index.js';
4
- import '../../option/index.js';
4
+ import '../../option/root/index.js';
5
5
  declare const HelixHeadlessTimePicker_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinPickerFeedbackInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerValueInterface) & typeof HelixFormElement;
6
6
  /**
7
7
  * @summary A control for choosing a time from a list or from wheels.
@@ -10,7 +10,7 @@ import { property, state } from 'lit/decorators.js';
10
10
  import { html } from 'lit/static-html.js';
11
11
  import { wheelName } from '../wheel-name.js';
12
12
  import '../../listbox/index.js';
13
- import '../../option/index.js';
13
+ import '../../option/root/index.js';
14
14
  let HelixHeadlessTimePicker = class HelixHeadlessTimePicker extends MixinPickerFeedback(MixinControllerValue(HelixFormElement)) {
15
15
  constructor() {
16
16
  super(...arguments);
@@ -275,7 +275,8 @@ let HelixHeadlessTimePicker = class HelixHeadlessTimePicker extends MixinPickerF
275
275
  this.internals.setFormValue(null);
276
276
  return;
277
277
  }
278
- reportValues(this.internals, this.name, this.held);
278
+ const held = this.held;
279
+ reportValues(this.internals, this.name, held.every((one) => valueAsSeconds(one) !== null) ? held : ['']);
279
280
  }
280
281
  async revealWhenSettled() {
281
282
  const settling = [
@@ -356,6 +357,7 @@ let HelixHeadlessTimePicker = class HelixHeadlessTimePicker extends MixinPickerF
356
357
  data-column=${part}
357
358
  aria-describedby=${this.describedBy}
358
359
  aria-invalid=${this.marked ? 'true' : nothing}
360
+ aria-required=${this.required ? 'true' : nothing}
359
361
  exportparts="empty: column-empty, error: column-error, feedback: column-feedback, filter-icon: column-filter-icon,
360
362
  filter-mark: column-filter-mark, hint: column-hint, items: column-items, label: column-label,
361
363
  live: column-live, loading: column-loading, search: column-search, search-field: column-search-field"
@@ -408,6 +410,7 @@ let HelixHeadlessTimePicker = class HelixHeadlessTimePicker extends MixinPickerF
408
410
  part="list"
409
411
  aria-describedby=${this.describedBy}
410
412
  aria-invalid=${this.marked ? 'true' : nothing}
413
+ aria-required=${this.required ? 'true' : nothing}
411
414
  exportparts="empty: list-empty, error: list-error, feedback: list-feedback, filter-icon: list-filter-icon,
412
415
  filter-mark: list-filter-mark, hint: list-hint, items: list-items, label: list-label,
413
416
  live: list-live, loading: list-loading, search: list-search, search-field: list-search-field"