@fusion.dev/helix-headless 0.177.0 → 0.177.1

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.
@@ -173,6 +173,7 @@ let HelixHeadlessButton = class HelixHeadlessButton extends HelixFormElement {
173
173
  this.watchForm(null);
174
174
  }
175
175
  formAssociatedCallback(form) {
176
+ super.formAssociatedCallback(form);
176
177
  this.watchForm(form);
177
178
  }
178
179
  render() {
@@ -49,7 +49,7 @@ import { type PropertyValues, type TemplateResult } from 'lit';
49
49
  *
50
50
  * @event {HelixSendEvent} helix-send - Fired when the user sends their turn, and cancelable.
51
51
  * @event {HelixTypingEvent} helix-typing - The user started or stopped writing, with `typing` saying which, as the composer's `typing` does.
52
- * @event {Event} input - The user changed the text. Read it from `value`, for a typing indicator or a saved draft. Sent on the composer and does not bubble.
52
+ * @event {Event} input - The user changed the text. Read it from `value`, for a typing indicator or a saved draft. Bubbles and is composed, as a native field's is.
53
53
  *
54
54
  * @csspart composer - The whole composer.
55
55
  * @csspart end - The box around the `end` slot.
@@ -41,7 +41,7 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
41
41
  event.stopPropagation();
42
42
  this.value = event.target.value;
43
43
  this.edited = true;
44
- this.dispatchEvent(new Event('input'));
44
+ this.announceInput();
45
45
  this.markTyping(this.value !== '');
46
46
  };
47
47
  this.handleSend = () => {
@@ -633,6 +633,16 @@ export declare class HelixHeadlessColorPicker extends HelixHeadlessColorPicker_b
633
633
  * is redrawn though the value it settles on is the one drawn before.
634
634
  */
635
635
  protected renderFields(color: Color): TemplateResult;
636
+ /**
637
+ * A channel's name, after the picker's own where the picker has one.
638
+ *
639
+ * **Every channel the same way**, as React Aria's color area names its own:
640
+ * "Brand, Saturation" says which picker on a page with two. The square's
641
+ * axes and the wheel took the picker's name, and the hue and opacity ramps
642
+ * and the number fields did not, so two pickers on a page gave two sliders
643
+ * named "Hue", measured in Chromium's tree.
644
+ */
645
+ protected channelName(channel: string): string;
636
646
  /**
637
647
  * One of the ramps under the square.
638
648
  *
@@ -407,11 +407,15 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
407
407
  }
408
408
  renderFields(color) {
409
409
  return html `<div part="fields">
410
- ${channelFields(this.value, color, this.format ?? 'hex', this.alpha).map((field) => html `<label part="field">
411
- <span part="field-label">${this.channelWord(field.channel)}</span>
410
+ ${channelFields(this.value, color, this.format ?? 'hex', this.alpha).map((field) => {
411
+ const word = this.channelWord(field.channel);
412
+ const name = this.channelName(word);
413
+ return html `<label part="field">
414
+ <span part="field-label">${word}</span>
412
415
  <input
413
416
  part="field-input"
414
417
  type="number"
418
+ aria-label=${name === word ? nothing : name}
415
419
  inputmode=${field.step < 1 ? 'decimal' : 'numeric'}
416
420
  min=${field.min}
417
421
  max=${field.max}
@@ -421,14 +425,19 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
421
425
  ?readonly=${this.readOnly}
422
426
  @input=${(event) => this.handleField(field.channel, event)}
423
427
  @change=${(event) => {
424
- event.stopPropagation();
425
- this.settle();
426
- this.requestUpdate();
427
- }}
428
+ event.stopPropagation();
429
+ this.settle();
430
+ this.requestUpdate();
431
+ }}
428
432
  />
429
- </label>`)}
433
+ </label>`;
434
+ })}
430
435
  </div>`;
431
436
  }
437
+ channelName(channel) {
438
+ const owner = this.getAttribute('aria-label')?.trim() || this.named;
439
+ return owner ? `${owner}, ${channel}` : channel;
440
+ }
432
441
  renderChannel(channel, label, max, value, spoken = '') {
433
442
  return html `<${this.tag('slider')}
434
443
  part="channel"
@@ -439,7 +448,7 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
439
448
  tooltips: channel-tooltips"
440
449
  data-channel=${channel}
441
450
  aria-invalid=${this.marked ? 'true' : nothing}
442
- label=${label}
451
+ label=${this.channelName(label)}
443
452
  max=${max}
444
453
  min="0"
445
454
  value-text=${spoken || nothing}
@@ -568,8 +577,6 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
568
577
  const reversed = square('brightness');
569
578
  const hue = hueName({ alpha: 100, brightness: 100, hue: color.hue, saturation: 100 }, this.colorWords, locale);
570
579
  const hueSaid = (value) => new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([String(value), hue]);
571
- const owner = this.getAttribute('aria-label')?.trim() || this.named;
572
- const axisName = (channel) => (owner ? `${owner}, ${channel}` : channel);
573
580
  const area = html `<div
574
581
  part="grid"
575
582
  style="--color-picker-hue: ${color.hue}; --color-picker-saturation: ${color.saturation}%; --color-picker-brightness: ${color.brightness}%"
@@ -584,7 +591,7 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
584
591
  type="range"
585
592
  min="0"
586
593
  max="100"
587
- aria-label=${axisName(this.saturationLabel)}
594
+ aria-label=${this.channelName(this.saturationLabel)}
588
595
  aria-describedby=${this.describedBy}
589
596
  aria-valuetext=${both}
590
597
  aria-invalid=${this.marked ? 'true' : nothing}
@@ -601,7 +608,7 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
601
608
  max="100"
602
609
  tabindex="-1"
603
610
  style="writing-mode: vertical-lr; direction: rtl"
604
- aria-label=${axisName(this.brightnessLabel)}
611
+ aria-label=${this.channelName(this.brightnessLabel)}
605
612
  aria-describedby=${this.describedBy}
606
613
  aria-valuetext=${reversed}
607
614
  aria-invalid=${this.marked ? 'true' : nothing}
@@ -630,7 +637,7 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
630
637
  min="0"
631
638
  max="360"
632
639
  dir="ltr"
633
- aria-label=${axisName(this.hueLabel)}
640
+ aria-label=${this.channelName(this.hueLabel)}
634
641
  aria-describedby=${this.describedBy}
635
642
  aria-valuetext=${hueSaid(color.hue)}
636
643
  aria-invalid=${this.marked ? 'true' : nothing}
@@ -395,6 +395,8 @@ export declare class HelixHeadlessCombobox extends HelixHeadlessCombobox_base {
395
395
  * "France", and the form received two entries.
396
396
  */
397
397
  private get values();
398
+ /** The values a value holds, read as `values` reads this element's own. */
399
+ private valuesIn;
398
400
  /** The answers created here, which no option holds, kept so their commas read as theirs. */
399
401
  private readonly created;
400
402
  /**
@@ -577,6 +579,17 @@ export declare class HelixHeadlessCombobox extends HelixHeadlessCombobox_base {
577
579
  * `result-label` for one where a page still sets it.
578
580
  */
579
581
  private countSaid;
582
+ /**
583
+ * Says the option a choice changed, for VoiceOver, which says nothing when
584
+ * `aria-selected` changes: `Beta, selected` for one taken, and its words
585
+ * alone for one let go, as the highlight says an option not chosen.
586
+ *
587
+ * **The option the choice changed, read from the values before and after.**
588
+ * With `multiple` the value is the whole list, and the option was looked up
589
+ * by it, so no option matched and no choice was ever said: measured with
590
+ * Beta chosen, Enter let it go and the live region kept `Beta, selected`.
591
+ */
592
+ private sayChoice;
580
593
  /**
581
594
  * Takes what the list announced and stops it there.
582
595
  *
@@ -188,12 +188,12 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
188
188
  this.handleChange = (event) => {
189
189
  event.stopPropagation();
190
190
  const { value } = event.detail;
191
+ const before = this.values;
191
192
  if (!this.requestValue(value)) {
192
193
  return;
193
194
  }
194
- const chosen = this.listbox?.options.find((option) => option.value === value);
195
- if (chosen && onApple()) {
196
- this.status.say(formatWords(this, 'selectedLabel', { name: chosen.text }));
195
+ if (onApple()) {
196
+ this.sayChoice(before, this.valuesIn(value));
197
197
  }
198
198
  this.emptied = false;
199
199
  this.typed = '';
@@ -253,11 +253,14 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
253
253
  return this.partNamed('listbox');
254
254
  }
255
255
  get values() {
256
+ return this.valuesIn(this.value);
257
+ }
258
+ valuesIn(value) {
256
259
  const known = new Set(this.created);
257
260
  for (const option of optionsAmong(this.knownChildren)) {
258
261
  known.add(option.value);
259
262
  }
260
- return chosenValues(this.value, known, this.multiple);
263
+ return chosenValues(value, known, this.multiple);
261
264
  }
262
265
  get chosen() {
263
266
  const options = optionsAmong(this.knownChildren);
@@ -460,6 +463,15 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
460
463
  ? countLabel(count, this.resultLabel, this.resultsLabel, wordsLocale(this))
461
464
  : formatWords(this, 'resultsLabel', { count });
462
465
  }
466
+ sayChoice(before, after) {
467
+ const taken = this.multiple ? after.find((one) => !before.includes(one)) : after[0];
468
+ const changed = taken ?? before.find((one) => !after.includes(one));
469
+ const option = this.listbox?.options.find((one) => one.value === changed);
470
+ if (!option) {
471
+ return;
472
+ }
473
+ this.status.say(taken === undefined ? option.text : formatWords(this, 'selectedLabel', { name: option.text }));
474
+ }
463
475
  removeLastToken() {
464
476
  const last = this.values.at(-1);
465
477
  if (last !== undefined) {
@@ -360,8 +360,9 @@ export declare class HelixHeadlessFileInput extends HelixHeadlessFileInput_base
360
360
  *
361
361
  * Not through `commit`, and that is the point of the split: a reset is the
362
362
  * form's decision and there is nothing here for a listener to refuse. What it
363
- * owes is the report and the platform's own announcement, which is what
364
- * `settle` is.
363
+ * owes is the report to the form, and no `input` or `change`: a native file
364
+ * input sends neither on `form.reset()`, measured in Chromium, where this
365
+ * sent both.
365
366
  */
366
367
  formResetCallback(): void;
367
368
  /**
@@ -224,10 +224,13 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
224
224
  }
225
225
  control.files = holding.files;
226
226
  }
227
- settle() {
227
+ settle(announce = true) {
228
228
  this.committed = this.files;
229
229
  this.sync();
230
230
  this.requestUpdate();
231
+ if (!announce) {
232
+ return;
233
+ }
231
234
  this.announceInput();
232
235
  this.announceChange();
233
236
  }
@@ -265,7 +268,7 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
265
268
  if (this.control) {
266
269
  restoreValue(this.control, '');
267
270
  }
268
- this.settle();
271
+ this.settle(false);
269
272
  }
270
273
  renderMeta(file) {
271
274
  return html `<${this.tag('tag')} part="size"
@@ -133,6 +133,7 @@ let HelixHeadlessPinInput = class HelixHeadlessPinInput extends MixinControllerV
133
133
  }
134
134
  this.placeCaret(true);
135
135
  if (applied && this.value === code && code.length === this.count) {
136
+ this.runPendingUpdate();
136
137
  this.dispatchEvent(new HelixCompleteEvent({ value: code }));
137
138
  this.commit();
138
139
  if (this.autosubmit) {
@@ -277,7 +277,13 @@ export declare class HelixHeadlessTimeInput extends HelixHeadlessTimeInput_base
277
277
  unavailableMessage: string;
278
278
  /**
279
279
  * The message for text that is no time, where `bad-input-message` is not
280
- * set. `{mode}` is `single`, `range`, or `multiple`, and `{shape}` is `HH:mm`, or `HH:mm:ss` with `seconds`.
280
+ * set. `{mode}` is `single`, `range`, or `multiple`, `{example}` is a time
281
+ * written as the field writes one, `2:30 PM` in `en-US` and `14:30` in
282
+ * `de-DE`, and `{shape}` is `HH:mm`, or `HH:mm:ss` with `seconds`.
283
+ *
284
+ * **The default names a time the field reads back.** It said "Enter a time
285
+ * as HH:mm." to a reader whose field showed `2:30 PM`, and a pattern token
286
+ * is words for a developer.
281
287
  */
282
288
  unreadableMessage: string;
283
289
  /** Every time in the value, which may hold more than one. */
@@ -36,7 +36,7 @@ let HelixHeadlessTimeInput = class HelixHeadlessTimeInput extends MixinSegmented
36
36
  this.timeStyle = 'local';
37
37
  this.triggerLabel = 'Choose a time';
38
38
  this.unavailableMessage = 'Choose a time that is available.';
39
- this.unreadableMessage = '{mode, select, single {Enter a time as {shape}.} other {Enter times as {shape}, separated by commas.}}';
39
+ this.unreadableMessage = '{mode, select, single {Enter a time such as {example}.} other {Enter times such as {example}, separated by commas.}}';
40
40
  this.draft = '';
41
41
  this.handleTextCommit = (event) => {
42
42
  if (this.mode !== 'multiple') {
@@ -182,10 +182,13 @@ let HelixHeadlessTimeInput = class HelixHeadlessTimeInput extends MixinSegmented
182
182
  }
183
183
  validate() {
184
184
  const held = this.held;
185
- const shape = this.seconds ? 'HH:mm:ss' : 'HH:mm';
186
185
  if (held.length > 0 && !held.every((one) => this.isTime(one))) {
187
186
  return (this.badInputMessage ||
188
- formatWords(this, 'unreadableMessage', { mode: this.mode ?? 'single', shape }));
187
+ formatWords(this, 'unreadableMessage', {
188
+ example: this.written(this.seconds ? '14:30:15' : '14:30'),
189
+ mode: this.mode ?? 'single',
190
+ shape: this.seconds ? 'HH:mm:ss' : 'HH:mm',
191
+ }));
189
192
  }
190
193
  const rules = { disableTimes: this.disableTimes, max: this.max, min: this.min };
191
194
  const faults = held.map((one) => timeFault(one, rules));