@fusion.dev/helix-headless 0.153.0 → 0.155.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 (81) hide show
  1. package/custom-elements.json +1885 -140
  2. package/dist/accordion/item/accordion-item.d.ts +15 -0
  3. package/dist/accordion/item/accordion-item.js +12 -1
  4. package/dist/accordion/root/accordion-root.d.ts +2 -1
  5. package/dist/accordion/root/accordion-root.js +1 -0
  6. package/dist/badge/root/badge-root.d.ts +15 -2
  7. package/dist/badge/root/badge-root.js +20 -3
  8. package/dist/breadcrumbs/breadcrumb/breadcrumb.d.ts +36 -3
  9. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +42 -2
  10. package/dist/breadcrumbs/root/breadcrumbs-root.d.ts +15 -5
  11. package/dist/breadcrumbs/root/breadcrumbs-root.js +29 -4
  12. package/dist/button-group/root/button-group-root.d.ts +2 -0
  13. package/dist/button-group/root/button-group-root.js +1 -0
  14. package/dist/card/root/card-root.d.ts +31 -11
  15. package/dist/card/root/card-root.js +25 -11
  16. package/dist/code/root/code-root.d.ts +7 -0
  17. package/dist/code/root/code-root.js +1 -1
  18. package/dist/code-block/root/code-block-root.d.ts +37 -1
  19. package/dist/code-block/root/code-block-root.js +38 -5
  20. package/dist/color-input/root/color-input-root.d.ts +9 -3
  21. package/dist/color-input/root/color-input-root.js +3 -1
  22. package/dist/color-picker/channels.d.ts +5 -3
  23. package/dist/color-picker/channels.js +79 -21
  24. package/dist/color-picker/root/color-picker-root.d.ts +58 -5
  25. package/dist/color-picker/root/color-picker-root.js +75 -13
  26. package/dist/date-input/root/date-input-root.d.ts +1 -0
  27. package/dist/date-input/root/date-input-root.js +2 -1
  28. package/dist/date-picker/root/date-picker-root.d.ts +43 -13
  29. package/dist/date-picker/root/date-picker-root.js +54 -31
  30. package/dist/element-events.generated.js +2 -1
  31. package/dist/empty/root/empty-root.d.ts +19 -1
  32. package/dist/empty/root/empty-root.js +13 -4
  33. package/dist/icon/fetch.d.ts +20 -0
  34. package/dist/icon/fetch.js +70 -0
  35. package/dist/icon/root/icon-root.d.ts +27 -5
  36. package/dist/icon/root/icon-root.js +41 -17
  37. package/dist/navigation/root/navigation-root.d.ts +26 -0
  38. package/dist/navigation/root/navigation-root.js +58 -1
  39. package/dist/quote/root/quote-root.d.ts +17 -1
  40. package/dist/quote/root/quote-root.js +15 -2
  41. package/dist/select/root/select-root.d.ts +29 -3
  42. package/dist/select/root/select-root.js +34 -9
  43. package/dist/skeleton/root/skeleton-root.d.ts +7 -0
  44. package/dist/skeleton/root/skeleton-root.js +4 -1
  45. package/dist/slider/root/slider-root.d.ts +71 -9
  46. package/dist/slider/root/slider-root.js +129 -42
  47. package/dist/speed-dial/item/speed-dial-item.d.ts +29 -1
  48. package/dist/speed-dial/item/speed-dial-item.js +48 -7
  49. package/dist/speed-dial/root/speed-dial-root.d.ts +32 -6
  50. package/dist/speed-dial/root/speed-dial-root.js +50 -5
  51. package/dist/splitter/root/splitter-root.d.ts +22 -0
  52. package/dist/splitter/root/splitter-root.js +36 -7
  53. package/dist/stat/group/stat-group.d.ts +23 -4
  54. package/dist/stat/group/stat-group.js +13 -1
  55. package/dist/stat/root/stat-root.d.ts +46 -1
  56. package/dist/stat/root/stat-root.js +29 -5
  57. package/dist/steps/reach.d.ts +5 -1
  58. package/dist/steps/reach.js +2 -2
  59. package/dist/steps/root/steps-root.d.ts +37 -2
  60. package/dist/steps/root/steps-root.js +49 -7
  61. package/dist/steps/step/step.d.ts +6 -0
  62. package/dist/steps/step/step.js +4 -0
  63. package/dist/tabs/root/tabs-root.d.ts +37 -0
  64. package/dist/tabs/root/tabs-root.js +92 -2
  65. package/dist/tabs/tab/tab.d.ts +38 -2
  66. package/dist/tabs/tab/tab.js +45 -4
  67. package/dist/tag/group/tag-group.d.ts +29 -3
  68. package/dist/tag/group/tag-group.js +42 -11
  69. package/dist/toggle/root/toggle-root.d.ts +2 -0
  70. package/dist/toggle/root/toggle-root.js +1 -1
  71. package/dist/tree/item/tree-item.d.ts +10 -0
  72. package/dist/tree/item/tree-item.js +12 -0
  73. package/dist/tree/root/tree-root.d.ts +40 -1
  74. package/dist/tree/root/tree-root.js +120 -4
  75. package/dist/tree/selection.d.ts +6 -0
  76. package/dist/tree/selection.js +11 -0
  77. package/lint.json +1 -1
  78. package/package.json +5 -5
  79. package/types/jsx.d.ts +1412 -207
  80. package/types/vue.d.ts +294 -65
  81. package/types/words.d.ts +49 -1
@@ -4,15 +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 { customElement, HelixElement, hostAria, slotFilled } from '@fusion.dev/helix-core';
7
+ import { customElement, HelixElement, headingIn, hostAria, slotFilled, } from '@fusion.dev/helix-core';
8
8
  import { html } from 'lit';
9
9
  import { property, state } from 'lit/decorators.js';
10
10
  let HelixHeadlessCard = class HelixHeadlessCard extends HelixElement {
11
11
  constructor() {
12
12
  super(...arguments);
13
13
  this.label = '';
14
- this.labelledBy = null;
15
- this.namedBy = null;
16
14
  this.footerActions = false;
17
15
  this.headerActions = false;
18
16
  this.readRegions = () => {
@@ -22,12 +20,34 @@ let HelixHeadlessCard = class HelixHeadlessCard extends HelixElement {
22
20
  this.toggleState('has-media', filled('media'));
23
21
  this.toggleState('has-header', this.headerActions || filled('header'));
24
22
  this.toggleState('has-footer', this.footerActions || filled('footer'));
23
+ this.writeName();
25
24
  };
26
25
  }
26
+ static get observedAttributes() {
27
+ return [...super.observedAttributes, 'aria-label', 'aria-labelledby'];
28
+ }
29
+ attributeChangedCallback(name, old, value) {
30
+ super.attributeChangedCallback(name, old, value);
31
+ if (name === 'aria-label' || name === 'aria-labelledby') {
32
+ this.requestUpdate();
33
+ }
34
+ }
27
35
  willUpdate(changed) {
28
36
  super.willUpdate(changed);
29
- const named = Boolean(this.label.trim() || this.namedBy?.trim() || this.labelledBy?.trim());
30
- hostAria(this.internals, { label: this.label, role: named ? 'group' : '' });
37
+ this.writeName();
38
+ }
39
+ writeName() {
40
+ const attribute = (name) => Boolean(this.getAttribute?.(name)?.trim());
41
+ const named = this.label.trim() !== '' || attribute('aria-label') || attribute('aria-labelledby');
42
+ const heading = named
43
+ ? null
44
+ : headingIn(this.knownChildren.filter((child) => child.slot === 'header'));
45
+ const references = 'ariaLabelledByElements' in this.internals;
46
+ hostAria(this.internals, {
47
+ label: heading && !references ? (heading.textContent ?? '').trim() : this.label,
48
+ labelledBy: heading ? [heading] : [],
49
+ role: named || heading ? 'group' : '',
50
+ });
31
51
  }
32
52
  updated(changed) {
33
53
  super.updated(changed);
@@ -64,12 +84,6 @@ __decorate([
64
84
  __decorate([
65
85
  property({ attribute: 'media-placement', reflect: true })
66
86
  ], HelixHeadlessCard.prototype, "mediaPlacement", void 0);
67
- __decorate([
68
- property({ attribute: 'aria-labelledby' })
69
- ], HelixHeadlessCard.prototype, "labelledBy", void 0);
70
- __decorate([
71
- property({ attribute: 'aria-label' })
72
- ], HelixHeadlessCard.prototype, "namedBy", void 0);
73
87
  __decorate([
74
88
  state()
75
89
  ], HelixHeadlessCard.prototype, "footerActions", void 0);
@@ -8,6 +8,13 @@ import { type TemplateResult } from 'lit';
8
8
  * tools and document search. A styled span carries no such meaning, and a
9
9
  * screen reader announces nothing about it.
10
10
  *
11
+ * **Set `translate="no"` on the element to keep a page translator off the
12
+ * code.** A translator that rewrites prose would also rewrite an identifier.
13
+ * The slotted text takes its translation mode from the element, its parent in
14
+ * the page, and the element writes nothing onto itself, so the attribute is
15
+ * yours to set. The `<code>` inside carries `translate="no"` for anything the
16
+ * element draws itself.
17
+ *
11
18
  * `Code` is inline only and never scrolls. For a block of code with its own
12
19
  * box, its own overflow, and copying, use `CodeBlock`, which is a separate
13
20
  * family.
@@ -8,7 +8,7 @@ import { customElement, HelixElement } from '@fusion.dev/helix-core';
8
8
  import { html } from 'lit';
9
9
  let HelixHeadlessCode = class HelixHeadlessCode extends HelixElement {
10
10
  render() {
11
- return html `<code part="code"><slot></slot></code>`;
11
+ return html `<code part="code" translate="no"><slot></slot></code>`;
12
12
  }
13
13
  };
14
14
  HelixHeadlessCode = __decorate([
@@ -38,6 +38,18 @@ import '../../copy-button/index.js';
38
38
  * and its copy button copies the new version of the file. `variant="split"`
39
39
  * shows the old version beside the new, each removal opposite its replacement.
40
40
  *
41
+ * **A block that scrolls is a tab stop.** While its lines run past its width or
42
+ * its cap, the scroller is a region named for the block, such as "Code,
43
+ * app.ts", that Tab reaches, so a keyboard scrolls it with the arrow keys in
44
+ * every browser. Chromium and Firefox make a scrolling box focusable on their
45
+ * own, and Safari does not. A block that fits is no stop at all.
46
+ *
47
+ * **The code the block draws is marked untranslated**, `translate="no"` on
48
+ * its `<pre>`, so a page translator leaves highlighted lines and a diff as
49
+ * written. Code in the slot takes its translation mode from the element, which
50
+ * writes nothing onto itself, so set `translate="no"` on the element to keep a
51
+ * translator off plain code too.
52
+ *
41
53
  * On the server, the slot renders as ordinary code, so a diff first appears as
42
54
  * the patch text in a `<pre>`. The client makes the same first render, which
43
55
  * keeps hydration clean, and draws the diff once it can read the content. Diff
@@ -95,6 +107,18 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
95
107
  * `registerWords('de', { 'helix-code-block': { copyLabel: 'Code kopieren' } })`.
96
108
  */
97
109
  static words: string;
110
+ /**
111
+ * The name of a block that scrolls, which is a tab stop while it does.
112
+ *
113
+ * Where the block has a name, the scroller says it too, such as "Code,
114
+ * app.ts", as `code-name-label` puts it.
115
+ */
116
+ codeLabel: string;
117
+ /**
118
+ * The name of a block that scrolls, where the block has a name. `{code}` is
119
+ * `code-label` and `{name}` the block's name.
120
+ */
121
+ codeNameLabel: string;
98
122
  /**
99
123
  * The expand button's words while the block shows every line.
100
124
  */
@@ -249,7 +273,7 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
249
273
  private capped;
250
274
  /** Whether the expand button has shown every line. */
251
275
  private expanded;
252
- /** Watches the block's size, while it is expandable. */
276
+ /** Watches the block's size. */
253
277
  private sizes;
254
278
  /**
255
279
  * Reads whether the cap hides lines: the scroller is taller inside than it
@@ -258,6 +282,18 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
258
282
  * everything.
259
283
  */
260
284
  private readonly measure;
285
+ /**
286
+ * Makes each box that scrolls a tab stop and a region named for its block,
287
+ * and takes both away from a box that fits.
288
+ *
289
+ * **A reader on a keyboard scrolls the code from the box itself.** Chromium
290
+ * and Firefox make a scrolling box focusable on their own, and Safari does
291
+ * not, so there the arrow keys could not reach the end of a long line. A
292
+ * box counts while its sheet lets it scroll and its content is wider or
293
+ * taller than it is drawn: an unstyled `<pre>` overflows without scrolling,
294
+ * and a stop on it would scroll nothing.
295
+ */
296
+ private markScrollers;
261
297
  protected updated(changed: PropertyValues): void;
262
298
  connectedCallback(): void;
263
299
  disconnectedCallback(): void;
@@ -16,6 +16,8 @@ import { pairLines, parsePatch } from '../patch.js';
16
16
  let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
17
17
  constructor() {
18
18
  super(...arguments);
19
+ this.codeLabel = 'Code';
20
+ this.codeNameLabel = '{code}, {name}';
19
21
  this.collapseLabel = 'Show fewer lines';
20
22
  this.copiedLabel = 'Copied';
21
23
  this.copyLabel = 'Copy code';
@@ -31,6 +33,7 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
31
33
  this.capped = false;
32
34
  this.expanded = false;
33
35
  this.measure = () => {
36
+ this.markScrollers();
34
37
  if (!this.expanded) {
35
38
  this.capped =
36
39
  this.expandable &&
@@ -60,9 +63,31 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
60
63
  .map((node) => node.textContent ?? '')
61
64
  .join('');
62
65
  }
66
+ markScrollers() {
67
+ for (const box of this.renderRoot.querySelectorAll('[part="block"], [part="code"], [part="old"], [part="new"]')) {
68
+ const style = getComputedStyle(box);
69
+ const scrolls = (value) => value === 'auto' || value === 'scroll';
70
+ const overflows = (scrolls(style.overflowX) && box.scrollWidth > box.clientWidth + 1) ||
71
+ (scrolls(style.overflowY) && box.scrollHeight > box.clientHeight + 1);
72
+ if (overflows) {
73
+ const name = box.closest('[part="file"]')?.querySelector('[part="label"]')?.textContent;
74
+ const named = name ?? (this.label || this.language);
75
+ box.tabIndex = 0;
76
+ box.setAttribute('role', 'region');
77
+ box.setAttribute('aria-label', named
78
+ ? formatWords(this, 'codeNameLabel', { code: this.codeLabel, name: named })
79
+ : this.codeLabel);
80
+ }
81
+ else {
82
+ box.removeAttribute('tabindex');
83
+ box.removeAttribute('role');
84
+ box.removeAttribute('aria-label');
85
+ }
86
+ }
87
+ }
63
88
  updated(changed) {
64
89
  super.updated(changed);
65
- if (this.expandable && typeof ResizeObserver !== 'undefined') {
90
+ if (typeof ResizeObserver !== 'undefined') {
66
91
  this.sizes ??= new ResizeObserver(this.measure);
67
92
  this.sizes.observe(this);
68
93
  }
@@ -114,7 +139,7 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
114
139
  const name = this.label || this.language;
115
140
  return html `${this.renderHeader(name)}
116
141
  <div part="frame">
117
- <pre id="block" part="block">${gutter}${code}</pre>
142
+ <pre id="block" part="block" translate="no">${gutter}${code}</pre>
118
143
  ${name ? nothing : this.renderCopy()}
119
144
  </div>${this.renderExpand()}`;
120
145
  }
@@ -179,8 +204,8 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
179
204
  const hunks = file.hunks.map((hunk) => ({ hunk, rows: pairLines(hunk.lines) }));
180
205
  const filler = html `<span part="filler" aria-hidden="true">\n</span>`;
181
206
  return html `<div part="split">
182
- <pre part="old"><code part="code">${hunks.map(({ hunk, rows }) => html `${this.renderHunk(hunk)}${rows.map(({ left }) => left ? this.renderLine(left, width, 'old') : filler)}`)}</code></pre>
183
- <pre part="new"><code part="code">${hunks.map(({ hunk, rows }) => html `${this.renderHunk(hunk, true)}${rows.map(({ right }) => right ? this.renderLine(right, width, 'new') : filler)}`)}</code></pre>
207
+ <pre part="old" translate="no"><code part="code">${hunks.map(({ hunk, rows }) => html `${this.renderHunk(hunk)}${rows.map(({ left }) => left ? this.renderLine(left, width, 'old') : filler)}`)}</code></pre>
208
+ <pre part="new" translate="no"><code part="code">${hunks.map(({ hunk, rows }) => html `${this.renderHunk(hunk, true)}${rows.map(({ right }) => right ? this.renderLine(right, width, 'new') : filler)}`)}</code></pre>
184
209
  </div>`;
185
210
  }
186
211
  renderDiff(files) {
@@ -197,13 +222,21 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
197
222
  <div part="frame">
198
223
  ${this.variant === 'split'
199
224
  ? this.renderSplit(file, width)
200
- : html `<pre part="block"><code part="code">${file.hunks.map((hunk) => html `${this.renderHunk(hunk)}${hunk.lines.map((line) => this.renderLine(line, width))}`)}</code></pre>`}
225
+ : html `<pre part="block" translate="no"><code part="code">${file.hunks.map((hunk) => html `${this.renderHunk(hunk)}${hunk.lines.map((line) => this.renderLine(line, width))}`)}</code></pre>`}
201
226
  ${name ? nothing : this.renderCopy(now)}
202
227
  </div>
203
228
  </div>`;
204
229
  })}<slot hidden @slotchange=${() => this.requestUpdate()}></slot>`;
205
230
  }
206
231
  };
232
+ __decorate([
233
+ words,
234
+ property({ attribute: 'code-label' })
235
+ ], HelixHeadlessCodeBlock.prototype, "codeLabel", void 0);
236
+ __decorate([
237
+ words,
238
+ property({ attribute: 'code-name-label' })
239
+ ], HelixHeadlessCodeBlock.prototype, "codeNameLabel", void 0);
207
240
  __decorate([
208
241
  words,
209
242
  property({ attribute: 'collapse-label' })
@@ -17,8 +17,9 @@ declare const HelixHeadlessColorInput_base: (new (...args: any[]) => import("@fu
17
17
  * style the panel to match your interface.
18
18
  *
19
19
  * The field reads every color syntax, including `#f00`, `#ff0000`,
20
- * `rgb(255 0 0)`, `rgba(255,0,0,.5)`, `rgb(255 0 0 / 50%)`, and
21
- * `hsl(0 100% 50%)`. It writes colors in the syntax that `format` names. The
20
+ * `rgb(255 0 0)`, `rgba(255,0,0,.5)`, `rgb(255 0 0 / 50%)`,
21
+ * `hsl(0 100% 50%)`, `oklch(62.8% 0.258 29.2)`, and `hsb(0, 100%, 100%)`. It
22
+ * writes colors in the syntax that `format` names. The
22
23
  * field shows the value without reformatting it, because half-typed input does
23
24
  * not survive a round trip through a formatter. Date Input does the same with
24
25
  * dates.
@@ -75,6 +76,8 @@ declare const HelixHeadlessColorInput_base: (new (...args: any[]) => import("@fu
75
76
  * @csspart picker-channel-marker-label - The value a mark stands at, when `marker-labels` is set.
76
77
  * @csspart picker-channel-markers - The row of marks along the track.
77
78
  * @csspart picker-channel-output - The current value, shown beside the track.
79
+ * @csspart picker-channel-thumb - A ramp's thumb box, which holds no content in a picker.
80
+ * @csspart picker-channel-thumbs - The layer over a ramp's track that holds its thumb box.
78
81
  * @csspart picker-channel-tooltip - A ramp's value in a bubble, where a ramp shows one.
79
82
  * @csspart picker-channel-tooltips - The layer over a ramp's track that holds its bubble.
80
83
  * @csspart picker-channels - The channel sliders beside the sample.
@@ -99,7 +102,9 @@ declare const HelixHeadlessColorInput_base: (new (...args: any[]) => import("@fu
99
102
  * @csspart picker-grid-brightness - The range input for the square's vertical axis.
100
103
  * @csspart picker-grid-handle - The mark showing where in the square the color sits.
101
104
  * @csspart picker-grid-saturation - The range input for the square's horizontal axis.
105
+ * @csspart picker-hint - The picker's own help, which the field's `hint` stands in for here, so it stays hidden.
102
106
  * @csspart picker-icon - An icon drawn by the picker.
107
+ * @csspart picker-label - The picker's own name, which the field's `label` stands in for here, so it stays hidden.
103
108
  * @csspart picker-sample - A chip of the current color. Pressing it copies the value.
104
109
  * @csspart picker-swatch - One preset.
105
110
  * @csspart picker-swatches - The row of presets.
@@ -150,7 +155,8 @@ export declare class HelixHeadlessColorInput extends HelixHeadlessColorInput_bas
150
155
  */
151
156
  colorWords: Partial<ColorWords>;
152
157
  /**
153
- * The syntax the value is written in: `hex`, `rgb`, or `hsl`.
158
+ * The syntax the value is written in: `hex`, `rgb`, `hsl`, `oklch`, or
159
+ * `hsb`, as Color Picker's `format`.
154
160
  *
155
161
  * **Undefaulted, because it reflects.** The two together stamp an attribute
156
162
  * onto every host anybody renders, including the ones nobody configured: the
@@ -126,6 +126,7 @@ let HelixHeadlessColorInput = class HelixHeadlessColorInput extends MixinPickerF
126
126
  channel-hint: picker-channel-hint, channel-label: picker-channel-label,
127
127
  channel-marker: picker-channel-marker, channel-marker-label: picker-channel-marker-label,
128
128
  channel-markers: picker-channel-markers, channel-output: picker-channel-output,
129
+ channel-thumb: picker-channel-thumb, channel-thumbs: picker-channel-thumbs,
129
130
  channel-tooltip: picker-channel-tooltip, channel-tooltips: picker-channel-tooltips,
130
131
  channels: picker-channels, controls: picker-controls, eyedropper: picker-eyedropper,
131
132
  eyedropper-button: picker-eyedropper-button, eyedropper-live: picker-eyedropper-live, eyedropper-spinner: picker-eyedropper-spinner, eyedropper-text: picker-eyedropper-text,
@@ -133,7 +134,8 @@ let HelixHeadlessColorInput = class HelixHeadlessColorInput extends MixinPickerF
133
134
  fields: picker-fields, footer: picker-footer, format: picker-format,
134
135
  format-button: picker-format-button, format-live: picker-format-live, format-spinner: picker-format-spinner, format-text: picker-format-text, grid: picker-grid,
135
136
  grid-brightness: picker-grid-brightness, grid-handle: picker-grid-handle,
136
- grid-saturation: picker-grid-saturation, icon: picker-icon, sample: picker-sample,
137
+ grid-saturation: picker-grid-saturation, hint: picker-hint, icon: picker-icon,
138
+ label: picker-label, sample: picker-sample,
137
139
  swatch: picker-swatch, swatches: picker-swatches, value: picker-value,
138
140
  wheel: picker-wheel, wheel-hue: picker-wheel-hue"
139
141
  ?invalid=${this.resolvedFeedback !== ''}
@@ -1,17 +1,19 @@
1
1
  import { type Color, type ColorFormat } from '@fusion.dev/helix-core';
2
2
  /** One number a color is written with, in the syntax the picker writes. */
3
- export type ChannelName = 'alpha' | 'blue' | 'green' | 'hue' | 'lightness' | 'red' | 'saturation';
4
- /** A channel as a field shows it: its number and the range it may take. */
3
+ export type ChannelName = 'alpha' | 'blue' | 'brightness' | 'chroma' | 'green' | 'hue' | 'lightness' | 'red' | 'saturation';
4
+ /** A channel as a field shows it: its number, the range it may take, and its step. */
5
5
  export interface ChannelField {
6
6
  channel: ChannelName;
7
7
  max: number;
8
8
  min: number;
9
+ step: number;
9
10
  value: number;
10
11
  }
11
12
  /**
12
13
  * The numbers a color is written with in `format`, for a field each, as React
13
14
  * Aria's ColorField takes a `channel`: red, green and blue for hex and rgb,
14
- * hue, saturation and lightness for hsl, and the opacity where the picker
15
+ * hue, saturation and lightness for hsl, hue, saturation and brightness for
16
+ * hsb, lightness, chroma and hue for oklch, and the opacity where the picker
15
17
  * carries one.
16
18
  *
17
19
  * Hex is written as bytes, so its fields are the bytes: a field of two hex
@@ -1,4 +1,16 @@
1
- import { clamp, toHsl, toRgb } from '@fusion.dev/helix-core';
1
+ import { clamp, toHsl, toOklch, toRgb } from '@fusion.dev/helix-core';
2
+ const onStep = (value, step) => {
3
+ const decimals = String(step).split('.')[1]?.length ?? 0;
4
+ return Number((Math.round(value / step) * step).toFixed(decimals));
5
+ };
6
+ const FAMILY = {
7
+ hex: /^rgba?$/,
8
+ hsb: /^hsba?$/,
9
+ hsl: /^hsla?$/,
10
+ oklch: /^oklch$/,
11
+ rgb: /^rgba?$/,
12
+ };
13
+ const OKLCH_STEPS = [0.1, 0.001, 0.1];
2
14
  const percent = (token) => {
3
15
  if (token === undefined) {
4
16
  return 100;
@@ -8,7 +20,8 @@ const percent = (token) => {
8
20
  };
9
21
  const readWritten = (value, format) => {
10
22
  const text = value.trim().toLowerCase();
11
- if (format !== 'hsl' && /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/.test(text)) {
23
+ if ((format === 'hex' || format === 'rgb') &&
24
+ /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/.test(text)) {
12
25
  const digits = text.slice(1);
13
26
  const width = digits.length <= 4 ? 1 : 2;
14
27
  const byte = (index) => {
@@ -18,23 +31,46 @@ const readWritten = (value, format) => {
18
31
  const count = digits.length / width;
19
32
  return [byte(0), byte(1), byte(2), count === 4 ? Math.round((byte(3) / 255) * 100) : 100];
20
33
  }
21
- const call = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text);
22
- if (!call?.[1] || call[2] === undefined) {
23
- return null;
24
- }
25
- if (call[1].startsWith('hsl') !== (format === 'hsl')) {
34
+ const call = /^(rgba?|hsla?|hsba?|oklch)\(([^)]*)\)$/.exec(text);
35
+ if (!call?.[1] || call[2] === undefined || !FAMILY[format].test(call[1])) {
26
36
  return null;
27
37
  }
28
38
  const [first, second, third, fourth] = call[2].replace(/[,/]/g, ' ').split(/\s+/).filter(Boolean);
29
- const numbers = [first, second, third].map((one) => Number.parseFloat(one ?? ''));
39
+ const numbers = [first, second, third].map((one) => one === 'none' ? 0 : Number.parseFloat(one ?? ''));
30
40
  if (numbers.some(Number.isNaN)) {
31
41
  return null;
32
42
  }
43
+ if (format === 'oklch') {
44
+ const [lightness = 0, chroma = 0, hue = 0] = numbers;
45
+ return [
46
+ onStep(first?.endsWith('%') ? lightness : lightness * 100, OKLCH_STEPS[0]),
47
+ onStep(second?.endsWith('%') ? (chroma / 100) * 0.4 : chroma, OKLCH_STEPS[1]),
48
+ onStep(hue, OKLCH_STEPS[2]),
49
+ Math.round(percent(fourth)),
50
+ ];
51
+ }
33
52
  return [...numbers, percent(fourth)].map((one) => Math.round(one));
34
53
  };
35
54
  export const channelFields = (value, color, format, alpha) => {
36
55
  const written = readWritten(value, format);
37
56
  const fromModel = () => {
57
+ if (format === 'hsb') {
58
+ return [
59
+ Math.round(color.hue),
60
+ Math.round(color.saturation),
61
+ Math.round(color.brightness),
62
+ Math.round(color.alpha),
63
+ ];
64
+ }
65
+ if (format === 'oklch') {
66
+ const { chroma, hue, lightness } = toOklch(color);
67
+ return [
68
+ onStep(lightness * 100, OKLCH_STEPS[0]),
69
+ onStep(chroma, OKLCH_STEPS[1]),
70
+ onStep(hue, OKLCH_STEPS[2]),
71
+ Math.round(color.alpha),
72
+ ];
73
+ }
38
74
  if (format === 'hsl') {
39
75
  const { hue, lightness, saturation } = toHsl(color);
40
76
  return [Math.round(hue), saturation, lightness, Math.round(color.alpha)];
@@ -43,18 +79,32 @@ export const channelFields = (value, color, format, alpha) => {
43
79
  return [red, green, blue, Math.round(color.alpha)];
44
80
  };
45
81
  const [one = 0, two = 0, three = 0, four = 100] = written ?? fromModel();
46
- const fields = format === 'hsl'
47
- ? [
48
- { channel: 'hue', max: 360, min: 0, value: one },
49
- { channel: 'saturation', max: 100, min: 0, value: two },
50
- { channel: 'lightness', max: 100, min: 0, value: three },
51
- ]
52
- : [
53
- { channel: 'red', max: 255, min: 0, value: one },
54
- { channel: 'green', max: 255, min: 0, value: two },
55
- { channel: 'blue', max: 255, min: 0, value: three },
56
- ];
57
- return alpha ? [...fields, { channel: 'alpha', max: 100, min: 0, value: four }] : fields;
82
+ const byFormat = {
83
+ hex: [],
84
+ hsb: [
85
+ { channel: 'hue', max: 360, min: 0, step: 1, value: one },
86
+ { channel: 'saturation', max: 100, min: 0, step: 1, value: two },
87
+ { channel: 'brightness', max: 100, min: 0, step: 1, value: three },
88
+ ],
89
+ hsl: [
90
+ { channel: 'hue', max: 360, min: 0, step: 1, value: one },
91
+ { channel: 'saturation', max: 100, min: 0, step: 1, value: two },
92
+ { channel: 'lightness', max: 100, min: 0, step: 1, value: three },
93
+ ],
94
+ oklch: [
95
+ { channel: 'lightness', max: 100, min: 0, step: OKLCH_STEPS[0], value: one },
96
+ { channel: 'chroma', max: 0.4, min: 0, step: OKLCH_STEPS[1], value: two },
97
+ { channel: 'hue', max: 360, min: 0, step: OKLCH_STEPS[2], value: three },
98
+ ],
99
+ rgb: [],
100
+ };
101
+ const bytes = [
102
+ { channel: 'red', max: 255, min: 0, step: 1, value: one },
103
+ { channel: 'green', max: 255, min: 0, step: 1, value: two },
104
+ { channel: 'blue', max: 255, min: 0, step: 1, value: three },
105
+ ];
106
+ const fields = byFormat[format].length > 0 ? byFormat[format] : bytes;
107
+ return alpha ? [...fields, { channel: 'alpha', max: 100, min: 0, step: 1, value: four }] : fields;
58
108
  };
59
109
  export const writeChannel = (fields, format, alpha, channel, value) => {
60
110
  if (Number.isNaN(value)) {
@@ -65,7 +115,7 @@ export const writeChannel = (fields, format, alpha, channel, value) => {
65
115
  if (field === undefined) {
66
116
  return null;
67
117
  }
68
- numbers.set(channel, clamp(Math.round(value), field.min, field.max));
118
+ numbers.set(channel, clamp(onStep(value, field.step), field.min, field.max));
69
119
  const get = (name, fallback = 0) => numbers.get(name) ?? fallback;
70
120
  const opacity = get('alpha', 100);
71
121
  const fraction = Number((opacity / 100).toFixed(2));
@@ -73,6 +123,14 @@ export const writeChannel = (fields, format, alpha, channel, value) => {
73
123
  const body = `${get('hue')}, ${get('saturation')}%, ${get('lightness')}%`;
74
124
  return alpha ? `hsla(${body}, ${fraction})` : `hsl(${body})`;
75
125
  }
126
+ if (format === 'hsb') {
127
+ const body = `${get('hue')}, ${get('saturation')}%, ${get('brightness')}%`;
128
+ return alpha ? `hsba(${body}, ${fraction})` : `hsb(${body})`;
129
+ }
130
+ if (format === 'oklch') {
131
+ const body = `${get('lightness')}% ${get('chroma')} ${get('hue')}`;
132
+ return alpha ? `oklch(${body} / ${fraction})` : `oklch(${body})`;
133
+ }
76
134
  if (format === 'rgb') {
77
135
  const body = `${get('red')}, ${get('green')}, ${get('blue')}`;
78
136
  return alpha ? `rgba(${body}, ${fraction})` : `rgb(${body})`;
@@ -32,6 +32,11 @@ declare const HelixHeadlessColorPicker_base: (new (...args: any[]) => import("@f
32
32
  * is named by its color. The opacity ramp is heard as a percentage. Set
33
33
  * `color-words` to speak the names in another language.
34
34
  *
35
+ * `label` names the picker, which is then a group with that name, and the
36
+ * square's ramps say it before their own, as "Brand, Saturation". `hint`
37
+ * writes help under the picker, which the square, the ramps and the value
38
+ * field are described by. Inside Color Input the field carries both.
39
+ *
35
40
  * The presets are a listbox with the current color selected, and one tab
36
41
  * stop. The arrow keys move between them, Home and End go to the ends, and
37
42
  * Enter, Space or a press chooses one.
@@ -43,6 +48,8 @@ declare const HelixHeadlessColorPicker_base: (new (...args: any[]) => import("@f
43
48
  * @documentation https://fusion.dev/helix/components/color-picker
44
49
  *
45
50
  * @slot eyedropper-icon - The icon on the eyedropper button, usually a dropper.
51
+ * @slot hint - Help under the picker, for content richer than the `hint` text.
52
+ * @slot label - The name above the picker, for content richer than the `label` text.
46
53
  *
47
54
  * @event {Event} change - Fired after the value changes.
48
55
  * @event {HelixBeforeChangeEvent} helix-before-change - Fired before the color changes. Cancelable: call `preventDefault()` to keep the current color.
@@ -62,6 +69,8 @@ declare const HelixHeadlessColorPicker_base: (new (...args: any[]) => import("@f
62
69
  * @csspart channel-marker-label - The value a mark stands at, when `marker-labels` is set.
63
70
  * @csspart channel-markers - The row of marks along the track.
64
71
  * @csspart channel-output - The current value, shown beside the track.
72
+ * @csspart channel-thumb - A ramp's thumb box, which holds no content in a picker.
73
+ * @csspart channel-thumbs - The layer over a ramp's track that holds its thumb box.
65
74
  * @csspart channel-tooltip - A ramp's value in a bubble, where a ramp shows one.
66
75
  * @csspart channel-tooltips - The layer over a ramp's track that holds its bubble.
67
76
  * @csspart channels - The ramps beside the sample.
@@ -88,6 +97,8 @@ declare const HelixHeadlessColorPicker_base: (new (...args: any[]) => import("@f
88
97
  * `--color-picker-brightness`, and this tier ships no CSS.
89
98
  * @csspart grid-brightness - The range input for the square's vertical axis.
90
99
  * @csspart grid-saturation - The range input for the square's horizontal axis.
100
+ * @csspart hint - The help under the picker, while `hint` is set or the `hint` slot has content.
101
+ * @csspart label - The name above the picker, while `label` is set or the `label` slot has content.
91
102
  * @csspart sample - A chip of the current color. Pressing it copies the value.
92
103
  * @csspart swatch - One preset, an option in the `swatches` listbox.
93
104
  * @csspart swatches - The row of presets, a listbox.
@@ -100,7 +111,10 @@ declare const HelixHeadlessColorPicker_base: (new (...args: any[]) => import("@f
100
111
  * @cssproperty --color-picker-saturation - The chosen saturation, 0 to 100, as a percentage.
101
112
  * @cssproperty --color-picker-swatch - The color a chip shows: the current color on the sample, and the preset's color on each preset.
102
113
  *
114
+ * @cssstate copied - The moment after the sample copies the value, while it says `copied-label`.
115
+ * @cssstate dragging - While a pointer drags the square's handle.
103
116
  * @cssstate invalid - While `invalid` is set or `feedback` says what is wrong.
117
+ * @cssstate sampling - While the eyedropper is open over the screen.
104
118
  */
105
119
  export declare class HelixHeadlessColorPicker extends HelixHeadlessColorPicker_base {
106
120
  /**
@@ -142,14 +156,20 @@ export declare class HelixHeadlessColorPicker extends HelixHeadlessColorPicker_b
142
156
  /**
143
157
  * Whether the picker shows a number field for each channel of the color, as
144
158
  * React Aria's ColorField does with `channel`: red, green and blue for hex
145
- * and rgb, hue, saturation and lightness for hsl, and the opacity with
146
- * `alpha`.
159
+ * and rgb, hue, saturation and lightness for hsl, hue, saturation and
160
+ * brightness for hsb, lightness, chroma and hue for oklch, and the opacity
161
+ * with `alpha`.
147
162
  *
148
163
  * For a reader who has the numbers from a brand guide, and for a keyboard,
149
164
  * where typing 128 is quicker than holding an arrow key. The fields follow
150
165
  * the syntax the format toggle sets.
151
166
  */
152
167
  channelFields: boolean;
168
+ /**
169
+ * What the chroma channel's field is called, in `channel-fields` with
170
+ * oklch.
171
+ */
172
+ chromaLabel: string;
153
173
  /**
154
174
  * The words a color's name is spoken in, as JSON keyed by their English,
155
175
  * such as `{"red": "rot", "vibrant": "kräftiges"}`.
@@ -179,10 +199,16 @@ export declare class HelixHeadlessColorPicker extends HelixHeadlessColorPicker_b
179
199
  */
180
200
  eyedropperLabel: string;
181
201
  /**
182
- * The syntax the value is written in: `hex`, `rgb`, or `hsl`.
202
+ * The syntax the value is written in: `hex`, `rgb`, `hsl`, `oklch`, or
203
+ * `hsb`.
183
204
  *
184
205
  * Only what is written. Everything is read whatever this says, so a picker on
185
- * `hex` still takes an `rgb()` handed to it and stores the hex for it.
206
+ * `hex` still takes an `rgb()` or an `oklch()` handed to it and stores the
207
+ * hex for it. `oklch` writes the sRGB color the picker holds, such as
208
+ * `oklch(62.8% 0.258 29.2)`; a color outside sRGB that is handed in is
209
+ * clipped to it. `hsb` writes the picker's own model, `hsb(0, 100%, 100%)`,
210
+ * as React Aria's and Zag's color strings do. It is not CSS, so the toggle
211
+ * walks the other four.
186
212
  */
187
213
  format?: ColorFormat;
188
214
  /**
@@ -193,13 +219,23 @@ export declare class HelixHeadlessColorPicker extends HelixHeadlessColorPicker_b
193
219
  * What the green channel's field is called, in `channel-fields`.
194
220
  */
195
221
  greenLabel: string;
222
+ /**
223
+ * Help under the picker, such as what the color is used for. The `hint`
224
+ * slot takes richer content.
225
+ */
226
+ hint: string;
196
227
  /**
197
228
  * What the hue channel is called.
198
229
  */
199
230
  hueLabel: string;
231
+ /**
232
+ * The name above the picker, such as "Brand color". The picker is then a
233
+ * group with this name. The `label` slot takes richer content.
234
+ */
235
+ label: string;
200
236
  /**
201
237
  * What the lightness channel's field is called, in `channel-fields` with
202
- * hsl.
238
+ * hsl and oklch.
203
239
  */
204
240
  lightnessLabel: string;
205
241
  /**
@@ -353,6 +389,23 @@ export declare class HelixHeadlessColorPicker extends HelixHeadlessColorPicker_b
353
389
  private sampling;
354
390
  /** The preset the reader was last on, which holds the row's one tab stop. */
355
391
  private swatchAt;
392
+ /**
393
+ * The children in a slot, read off the children rather than heard from
394
+ * `slotchange`: a declarative shadow root fires that before anything
395
+ * listens, so a served picker kept its label hidden.
396
+ */
397
+ private slottedIn;
398
+ private readonly handleNamingSlot;
399
+ /** The picker's own name, from `label` or the `label` slot's words. */
400
+ private get named();
401
+ /** Whether help is shown under the picker. */
402
+ private get hinted();
403
+ /** The ids a control in the picker is described by: the help, then the message. */
404
+ private get describedBy();
405
+ /** Whether a pointer is dragging the square's handle, said as `:state(dragging)`. */
406
+ private dragging;
407
+ /** Whether the eyedropper is open, said as `:state(sampling)`. */
408
+ private picking;
356
409
  /**
357
410
  * Whether the value on this element was produced here.
358
411
  *