@fusion.dev/helix-headless 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.
Files changed (47) hide show
  1. package/custom-elements.json +1068 -46
  2. package/dist/callout/root/callout-root.d.ts +43 -4
  3. package/dist/callout/root/callout-root.js +58 -2
  4. package/dist/code-block/patch.d.ts +42 -0
  5. package/dist/code-block/patch.js +118 -0
  6. package/dist/code-block/root/code-block-root.d.ts +37 -1
  7. package/dist/code-block/root/code-block-root.js +57 -9
  8. package/dist/color-picker/root/color-picker-root.d.ts +7 -3
  9. package/dist/color-picker/root/color-picker-root.js +7 -4
  10. package/dist/combobox/root/combobox-root.d.ts +1 -0
  11. package/dist/combobox/root/combobox-root.js +10 -1
  12. package/dist/data-table/root/data-table-root.d.ts +1 -0
  13. package/dist/data-table/root/data-table-root.js +1 -0
  14. package/dist/date-input/root/date-input-root.d.ts +3 -3
  15. package/dist/date-input/root/date-input-root.js +8 -5
  16. package/dist/element-events.generated.js +7 -1
  17. package/dist/format-date/root/format-date-root.d.ts +3 -1
  18. package/dist/format-date/root/format-date-root.js +4 -1
  19. package/dist/lightbox/item/lightbox-item.js +3 -1
  20. package/dist/lightbox/root/lightbox-root.d.ts +28 -0
  21. package/dist/lightbox/root/lightbox-root.js +21 -2
  22. package/dist/number-input/root/number-input-root.d.ts +32 -9
  23. package/dist/number-input/root/number-input-root.js +53 -9
  24. package/dist/object-inspector/root/object-inspector-root.d.ts +23 -0
  25. package/dist/object-inspector/root/object-inspector-root.js +19 -1
  26. package/dist/option/root/index.d.ts +1 -0
  27. package/dist/option/root/index.js +1 -0
  28. package/dist/table/grid-controller.d.ts +7 -0
  29. package/dist/table/grid-controller.js +15 -1
  30. package/dist/table/root/table-root.d.ts +10 -0
  31. package/dist/table/root/table-root.js +18 -1
  32. package/dist/tag/root/index.d.ts +1 -0
  33. package/dist/tag/root/index.js +1 -0
  34. package/dist/time-input/root/time-input-root.d.ts +4 -3
  35. package/dist/time-input/root/time-input-root.js +5 -4
  36. package/dist/time-picker/root/time-picker-root.d.ts +1 -1
  37. package/dist/time-picker/root/time-picker-root.js +5 -2
  38. package/dist/video/root/video-root.js +2 -1
  39. package/dist/video-playlist/item/video-playlist-item.d.ts +8 -0
  40. package/dist/video-playlist/item/video-playlist-item.js +7 -0
  41. package/dist/video-playlist/root/video-playlist-root.d.ts +60 -0
  42. package/dist/video-playlist/root/video-playlist-root.js +55 -1
  43. package/lint.json +1 -1
  44. package/package.json +5 -5
  45. package/types/jsx.d.ts +388 -40
  46. package/types/vue.d.ts +95 -13
  47. package/types/words.d.ts +58 -0
@@ -26,6 +26,7 @@ declare const HelixHeadlessCombobox_base: (new (...args: any[]) => import("@fusi
26
26
  * | Key | What it does |
27
27
  * |---|---|
28
28
  * | Down, Up | On a closed field, opens the list on the chosen option, or the first or the last. In the open list, moves the highlight, round from the last option to the first. |
29
+ * | Alt with Down, Alt with Up | Opens the list without moving the highlight, and closes it, choosing nothing. |
29
30
  * | Page Down, Page Up | In the open list, moves the highlight by as many options as the list shows at once. |
30
31
  * | Enter | Chooses the highlighted option. With nothing left in the list, creates what was typed with `creatable`, or takes it with `allows-custom-value`. On a closed field, submits the form. |
31
32
  * | Escape | Closes the list and puts the chosen words back. On a closed field, a second Escape clears a single choice. |
@@ -154,7 +154,11 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
154
154
  return;
155
155
  }
156
156
  if (!this.open) {
157
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
157
+ if (event.key === 'ArrowDown' && event.altKey) {
158
+ event.preventDefault();
159
+ this.requestOpen(true);
160
+ }
161
+ else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
158
162
  event.preventDefault();
159
163
  const fallback = event.key === 'ArrowDown' ? 'first' : 'last';
160
164
  this.opening = this.values.length > 0 ? 'selected' : fallback;
@@ -162,6 +166,11 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
162
166
  }
163
167
  return;
164
168
  }
169
+ if (event.key === 'ArrowUp' && event.altKey) {
170
+ event.preventDefault();
171
+ this.close();
172
+ return;
173
+ }
165
174
  if (this.moveAroundCreate(event)) {
166
175
  void this.updateComplete.then(() => this.linkActive());
167
176
  return;
@@ -215,6 +215,7 @@ declare const HelixHeadlessDataTable_base: (new (...args: any[]) => import("@fus
215
215
  *
216
216
  * @cssstate empty - While there are no rows to show and none are loading.
217
217
  * @cssstate error - While `error-text` is set.
218
+ * @cssstate loading - While `loading` is set, as the rows are on their way.
218
219
  */
219
220
  export declare class HelixHeadlessDataTable extends HelixHeadlessDataTable_base {
220
221
  /**
@@ -984,6 +984,7 @@ let HelixHeadlessDataTable = class HelixHeadlessDataTable extends MixinCollectio
984
984
  this.markEmpty();
985
985
  this.toggleState('error', this.errorText !== '');
986
986
  this.toggleState('empty', !this.loading && !this.hasRows);
987
+ this.toggleState('loading', this.loading);
987
988
  this.assign();
988
989
  this.announceSort();
989
990
  this.quietControls();
@@ -1,7 +1,7 @@
1
1
  import { AriaForwardController, type DateCalendar, HelixFormElement, type MonthCalendar, type MonthSystem } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
3
  import '../../date-picker/index.js';
4
- import '../../tag/index.js';
4
+ import '../../tag/root/index.js';
5
5
  declare const HelixHeadlessDateInput_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
  /**
7
7
  * @summary A date, typed or picked from a calendar.
@@ -474,8 +474,8 @@ export declare class HelixHeadlessDateInput extends HelixHeadlessDateInput_base
474
474
  /**
475
475
  * Each date, where `mode` holds several, sent one entry each.
476
476
  *
477
- * One date that is text and no date, such as `August 5`, is sent as an empty
478
- * entry, as a native date field sends a date it cannot read.
477
+ * Text that is no date, such as `August 5`, is sent as one empty entry, as
478
+ * a native date field sends a date it cannot read, in every mode.
479
479
  */
480
480
  protected get answers(): readonly string[] | undefined;
481
481
  /** What is wrong with the dates, which the text input cannot say. */
@@ -11,7 +11,7 @@ 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
13
  import '../../date-picker/index.js';
14
- import '../../tag/index.js';
14
+ import '../../tag/root/index.js';
15
15
  const dates = (value) => value
16
16
  .replace(/\s+[–-]\s+/g, ',')
17
17
  .split(',')
@@ -70,9 +70,11 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinSegmented
70
70
  };
71
71
  this.handleTextCommit = (event) => {
72
72
  if (this.mode !== 'multiple') {
73
- const held = this.mode === 'range' && /\s[–-]\s/.test(this.value)
74
- ? this.value.split(/\s+[–-]\s+/).map((one) => one.trim())
75
- : dates(this.value);
73
+ const held = this.mode !== 'range'
74
+ ? [this.value.trim()].filter(Boolean)
75
+ : /\s[–-]\s/.test(this.value)
76
+ ? this.value.split(/\s+[–-]\s+/).map((one) => one.trim())
77
+ : dates(this.value);
76
78
  const read = held.map((one) => this.read(one));
77
79
  if (held.length > 0 && !held.every(isDateValue) && read.every(Boolean)) {
78
80
  this.requestValue(read.join(','));
@@ -220,7 +222,8 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinSegmented
220
222
  }
221
223
  get answers() {
222
224
  if (this.mode !== undefined) {
223
- return dates(this.value);
225
+ const held = dates(this.value);
226
+ return held.every(isDateValue) ? held : [''];
224
227
  }
225
228
  const text = this.value.trim();
226
229
  return text === '' || isDateValue(text) ? undefined : [''];
@@ -335,7 +335,13 @@ registerElementEvents({
335
335
  'volumechange',
336
336
  'waiting',
337
337
  ],
338
- 'helix-headless-video-playlist': ['helix-activate', 'helix-before-activate', 'helix-end'],
338
+ 'helix-headless-video-playlist': [
339
+ 'helix-activate',
340
+ 'helix-before-activate',
341
+ 'helix-before-reorder',
342
+ 'helix-end',
343
+ 'helix-reorder',
344
+ ],
339
345
  'helix-headless-video-playlist-item': ['helix-select'],
340
346
  'helix-headless-video-playlist-menu': ['helix-select'],
341
347
  'helix-headless-virtual-list': [
@@ -200,7 +200,9 @@ export declare class HelixHeadlessFormatDate extends HelixElement {
200
200
  * `narrow` ("3h ago").
201
201
  *
202
202
  * The reading refreshes as it goes stale, as `elapsed` does, and the whole
203
- * date is its title. `elapsed` wins where both are set.
203
+ * date is its title. `elapsed` wins where both are set. Written with no
204
+ * value, as `elapsed` is, or with a style `Intl` does not know, it reads
205
+ * `long`.
204
206
  */
205
207
  relative?: 'long' | 'narrow' | 'short';
206
208
  /**
@@ -10,6 +10,7 @@ import { property } from 'lit/decorators.js';
10
10
  import { drawFields } from '../fields.js';
11
11
  import { formatOptions } from '../options.js';
12
12
  const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
13
+ const STYLES = ['long', 'narrow', 'short'];
13
14
  let HelixHeadlessFormatDate = class HelixHeadlessFormatDate extends HelixElement {
14
15
  constructor() {
15
16
  super(...arguments);
@@ -53,7 +54,9 @@ let HelixHeadlessFormatDate = class HelixHeadlessFormatDate extends HelixElement
53
54
  ? elapsedTime(this.language)(moment, now)
54
55
  : relativeTime(this.language, {
55
56
  numeric: this.numeric ?? 'auto',
56
- style: this.relative ?? 'long',
57
+ style: STYLES.includes(this.relative)
58
+ ? this.relative
59
+ : 'long',
57
60
  })(moment, now);
58
61
  }
59
62
  connectedCallback() {
@@ -47,7 +47,9 @@ __decorate([
47
47
  property({ type: Boolean })
48
48
  ], HelixHeadlessLightboxItem.prototype, "current", void 0);
49
49
  __decorate([
50
- property()
50
+ property({
51
+ converter: { fromAttribute: (value) => value ?? '' },
52
+ })
51
53
  ], HelixHeadlessLightboxItem.prototype, "label", void 0);
52
54
  __decorate([
53
55
  property({ attribute: false })
@@ -258,6 +258,32 @@ export declare class HelixHeadlessLightbox extends HelixHeadlessLightbox_base {
258
258
  * which is the first time anything asked it for its items on a server.
259
259
  */
260
260
  private get items();
261
+ /**
262
+ * The set changed: render it again.
263
+ *
264
+ * `items` is read on each render and nothing else marks a render as due when
265
+ * an item arrives or leaves, so an item appended after the first render had
266
+ * no role description, no place in the set, and no part in the count until
267
+ * some unrelated property changed.
268
+ */
269
+ private readonly handleSlotChange;
270
+ /**
271
+ * Watches each item's `label` and `value`, which no slot reports.
272
+ *
273
+ * `value` decides which item `active` names, and `label` fills `{label}` in
274
+ * `status-label` and `slide-label`. A change to either left the old item
275
+ * shown, or the old words in the counter, until some unrelated property
276
+ * changed. `Select` watches its options for the same reason.
277
+ *
278
+ * The subtree, since an observer on the host sees no attribute on its
279
+ * children otherwise. Only changes on the items themselves count: a `<progress
280
+ * value>` inside an item, updated while a video plays, would otherwise render
281
+ * the viewer again on each update.
282
+ *
283
+ * Made on connection, there being no `MutationObserver` on a server.
284
+ */
285
+ private itemWatch;
286
+ connectedCallback(): void;
261
287
  /** The native dialog, or nothing where there is no DOM to hold one. */
262
288
  private get dialog();
263
289
  /** Opens the viewer, which makes the rest of the page inert. */
@@ -322,6 +348,8 @@ export declare class HelixHeadlessLightbox extends HelixHeadlessLightbox_base {
322
348
  private zoomOut;
323
349
  /** Home and End, which are an index rather than a step. */
324
350
  private toEnd;
351
+ /** The item shown at the last update, whose zoom a different item does not keep. */
352
+ private zoomedItem;
325
353
  /** The item on screen: the one `active` names, or the first where it names none. */
326
354
  private get shown();
327
355
  /**
@@ -92,6 +92,10 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
92
92
  '--toggle': () => this.toggleOpen(),
93
93
  },
94
94
  });
95
+ this.handleSlotChange = () => {
96
+ this.requestUpdate();
97
+ };
98
+ this.itemWatch = undefined;
95
99
  this.handleCancel = (event) => {
96
100
  event.preventDefault();
97
101
  this.requestOpen(false);
@@ -129,6 +133,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
129
133
  this.toEnd(this.items.length - 1);
130
134
  }
131
135
  };
136
+ this.zoomedItem = undefined;
132
137
  this.handleBackdrop = (event) => {
133
138
  if (this.dragged(event) || !this.onEmptyArea(event)) {
134
139
  return;
@@ -177,6 +182,16 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
177
182
  get items() {
178
183
  return this.knownChildren.filter((child) => child instanceof HelixHeadlessLightboxItem);
179
184
  }
185
+ connectedCallback() {
186
+ super.connectedCallback();
187
+ this.itemWatch = new MutationObserver((records) => {
188
+ if (records.some((record) => record.target instanceof HelixHeadlessLightboxItem &&
189
+ record.target.parentElement === this)) {
190
+ this.requestUpdate();
191
+ }
192
+ });
193
+ this.itemWatch.observe(this, { attributeFilter: ['label', 'value'], subtree: true });
194
+ }
180
195
  get dialog() {
181
196
  return this.renderRoot?.querySelector('dialog') ?? null;
182
197
  }
@@ -292,16 +307,20 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
292
307
  this.toggleState('fullscreen', this.filled);
293
308
  this.toggleState('idle', this.resting);
294
309
  this.toggleState('zoomable', this.maxZoom > 1);
295
- if (changed.has('active') || (changed.has('open') && !this.open)) {
310
+ const shown = this.shown;
311
+ if (shown !== this.zoomedItem || (changed.has('open') && !this.open)) {
296
312
  this.quietZoom = true;
297
313
  this.zoomer.reset();
298
314
  this.quietZoom = false;
299
315
  }
316
+ this.zoomedItem = shown;
300
317
  this.say(changed);
301
318
  }
302
319
  disconnectedCallback() {
303
320
  super.disconnectedCallback();
304
321
  unlockScroll(this);
322
+ this.itemWatch?.disconnect();
323
+ this.itemWatch = undefined;
305
324
  }
306
325
  updated(changed) {
307
326
  super.updated(changed);
@@ -395,7 +414,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
395
414
  >
396
415
  <slot name="header" part="header"></slot>
397
416
  <div part="viewport">
398
- <slot></slot>
417
+ <slot @slotchange=${this.handleSlotChange}></slot>
399
418
  </div>
400
419
  <slot name="footer" part="footer"></slot>
401
420
  ${this.controls ? this.renderControls() : nothing}
@@ -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,
@@ -99,6 +99,8 @@ export type ObjectInspectorRenderer = (value: unknown, entry: ObjectInspectorEnt
99
99
  * @aliases JSON viewer, Object viewer, Inspector, Data explorer
100
100
  * @documentation https://fusion.dev/helix/components/object-inspector
101
101
  *
102
+ * @slot empty - Shown in the tree while it has no entries to show. Defaults to `empty-label`, or `no-matches-label` while a filter matched none.
103
+ *
102
104
  * @event {HelixActivateEvent} helix-activate - The active row moved, by a key or a click. The detail's `value` is its path.
103
105
  * @event {HelixBeforeActivateEvent} helix-before-activate - The active row is about to move, with its new path. Cancelable: cancel it to keep the row where it is.
104
106
  * @event {HelixBeforeCloseEvent} helix-before-close - A reader is about to close the active branch. Cancelable: cancel it to keep the branch open.
@@ -128,6 +130,7 @@ export type ObjectInspectorRenderer = (value: unknown, entry: ObjectInspectorEnt
128
130
  * @csspart copy-tooltip-right - The tooltip while it is right of the copy button. Set alongside `copy-tooltip`.
129
131
  * @csspart copy-tooltip-top - The tooltip while it is above the copy button. Set alongside `copy-tooltip`.
130
132
  * @csspart count - The number of entries a branch holds.
133
+ * @csspart empty - The row shown in the tree while it has no entries to show: no value, or a filter that matched none. It holds the `empty` slot.
131
134
  * @csspart frame - The tree and the copy button beside it.
132
135
  * @csspart header - The bar that holds the label, when there is one.
133
136
  * @csspart key - A property name or an array index.
@@ -146,6 +149,10 @@ export type ObjectInspectorRenderer = (value: unknown, entry: ObjectInspectorEnt
146
149
  * @csspart value - What `renderValue` drew for an entry, in place of its own drawing.
147
150
  *
148
151
  * @cssproperty --object-inspector-level - How deep a row is, from 0 at the root, for indenting it.
152
+ *
153
+ * @cssstate empty - While the tree has no entries to show: no value, or a filter that matched none.
154
+ * @cssstate filtered - While `filter` holds text.
155
+ * @cssstate matched - While `filter` holds text and matched at least one entry.
149
156
  */
150
157
  export declare class HelixHeadlessObjectInspector extends HelixElement {
151
158
  /**
@@ -183,6 +190,11 @@ export declare class HelixHeadlessObjectInspector extends HelixElement {
183
190
  * wall of text this exists to avoid.
184
191
  */
185
192
  defaultDepth: number;
193
+ /**
194
+ * What the tree says while there is no value to show, such as before a
195
+ * fetch answers. The `empty` slot replaces it.
196
+ */
197
+ emptyLabel: string;
186
198
  /**
187
199
  * The open branches, by path, in no set order.
188
200
  *
@@ -254,6 +266,11 @@ export declare class HelixHeadlessObjectInspector extends HelixElement {
254
266
  * reach a value.
255
267
  */
256
268
  noCopy: boolean;
269
+ /**
270
+ * What the tree says while a filter matched no entry. The `empty` slot
271
+ * replaces it.
272
+ */
273
+ noMatchesLabel: string;
257
274
  /**
258
275
  * Draws an entry's value your way, given the value and its entry. Set it as
259
276
  * a property.
@@ -358,6 +375,12 @@ export declare class HelixHeadlessObjectInspector extends HelixElement {
358
375
  private seed;
359
376
  /** Opens the branches around each path, where the value has them. */
360
377
  private openAround;
378
+ /**
379
+ * Whether the tree has no entries to show. Not while a value named by `for`
380
+ * is still to be read, as on a server and while hydrating what one sent,
381
+ * where it says nothing yet about the value.
382
+ */
383
+ private get blank();
361
384
  protected willUpdate(changed: PropertyValues): void;
362
385
  /**
363
386
  * A count in its plural message, `items-label`, `keys-label` or
@@ -32,6 +32,7 @@ let HelixHeadlessObjectInspector = class HelixHeadlessObjectInspector extends He
32
32
  this.copyLabel = 'Copy {name}';
33
33
  this.copyValueLabel = 'Copy value';
34
34
  this.defaultDepth = 1;
35
+ this.emptyLabel = 'Nothing to show';
35
36
  this.filter = '';
36
37
  this.filterable = false;
37
38
  this.filterLabel = 'Filter';
@@ -40,6 +41,7 @@ let HelixHeadlessObjectInspector = class HelixHeadlessObjectInspector extends He
40
41
  this.keysLabel = '{count, plural, one {# key} other {# keys}}';
41
42
  this.label = '';
42
43
  this.noCopy = false;
44
+ this.noMatchesLabel = 'No matches';
43
45
  this.resultsLabel = '{count, plural, one {# result} other {# results}}';
44
46
  this.treeLabel = 'Value';
45
47
  this.value = undefined;
@@ -213,6 +215,9 @@ let HelixHeadlessObjectInspector = class HelixHeadlessObjectInspector extends He
213
215
  openAround(paths) {
214
216
  this.opened = withAncestors(this.opened, this.branches, paths);
215
217
  }
218
+ get blank() {
219
+ return this.rows.length === 0 && (this.for === '' || this.loadedFor === this.for);
220
+ }
216
221
  willUpdate(changed) {
217
222
  super.willUpdate(changed);
218
223
  this.hadFocus = this.hasFocusedRow();
@@ -248,6 +253,9 @@ let HelixHeadlessObjectInspector = class HelixHeadlessObjectInspector extends He
248
253
  if (changed.has('filter') && this.hasUpdated) {
249
254
  this.tell();
250
255
  }
256
+ this.toggleState('empty', this.blank);
257
+ this.toggleState('filtered', this.found !== undefined);
258
+ this.toggleState('matched', (this.found?.size ?? 0) > 0);
251
259
  }
252
260
  counted(which, count) {
253
261
  const one = this[`${which}Label`];
@@ -526,7 +534,9 @@ let HelixHeadlessObjectInspector = class HelixHeadlessObjectInspector extends He
526
534
  @scroll=${() => this.virtualize.measure()}
527
535
  >
528
536
  ${this.windowed ? html `<div part="before" role="presentation" style="block-size: ${before}px"></div>` : nothing}
529
- ${repeat(shown, ([row]) => row.path, ([row, at]) => this.renderRow(row, at))}
537
+ ${repeat(shown, ([row]) => row.path, ([row, at]) => this.renderRow(row, at))}${this.blank
538
+ ? html `<div part="empty" role="row"><div role="gridcell"><slot name="empty">${this.found ? this.noMatchesLabel : this.emptyLabel}</slot></div></div>`
539
+ : nothing}
530
540
  ${this.windowed ? html `<div part="after" role="presentation" style="block-size: ${after}px"></div>` : nothing}
531
541
  </div>
532
542
  ${this.noCopy ? nothing : this.renderCopyButton(rows[this.cursor])}
@@ -548,6 +558,10 @@ __decorate([
548
558
  __decorate([
549
559
  property({ attribute: 'default-depth', type: Number })
550
560
  ], HelixHeadlessObjectInspector.prototype, "defaultDepth", void 0);
561
+ __decorate([
562
+ words,
563
+ property({ attribute: 'empty-label' })
564
+ ], HelixHeadlessObjectInspector.prototype, "emptyLabel", void 0);
551
565
  __decorate([
552
566
  property({ attribute: false })
553
567
  ], HelixHeadlessObjectInspector.prototype, "expanded", null);
@@ -584,6 +598,10 @@ __decorate([
584
598
  __decorate([
585
599
  property({ attribute: 'no-copy', type: Boolean })
586
600
  ], HelixHeadlessObjectInspector.prototype, "noCopy", void 0);
601
+ __decorate([
602
+ words,
603
+ property({ attribute: 'no-matches-label' })
604
+ ], HelixHeadlessObjectInspector.prototype, "noMatchesLabel", void 0);
587
605
  __decorate([
588
606
  property({ attribute: false })
589
607
  ], HelixHeadlessObjectInspector.prototype, "renderValue", void 0);