@fusion.dev/helix-headless 0.47.1 → 0.49.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.
@@ -29,6 +29,7 @@ declare const HelixHeadlessRating_base: (new (...args: any[]) => import("@fusion
29
29
  * @event {Event} input - Announced once the value has moved.
30
30
  *
31
31
  * @csspart feedback - What is wrong with it.
32
+ * @csspart field - The row: the marks, and the score beside them.
32
33
  * @csspart fill - The filled marks, clipped to the score.
33
34
  * @csspart hint - Help under the marks, while nothing is wrong.
34
35
  * @csspart label - The question, over the marks.
@@ -40,8 +41,8 @@ declare const HelixHeadlessRating_base: (new (...args: any[]) => import("@fusion
40
41
  *
41
42
  * @cssproperty --rating-full - How many marks are wholly filled.
42
43
  * @cssproperty --rating-part - How much of the next mark is filled, 0 to 1.
43
- * @cssproperty --rating-step - The width of one step, as a fraction of a mark.
44
- * @cssproperty --rating-steps - How many steps one mark is divided into.
44
+ * @cssproperty --rating-step - Which step along the row a target is, counting from one, whatever `min` leaves open.
45
+ * @cssproperty --rating-steps - How many steps the whole row holds, which is `max` over `step`.
45
46
  *
46
47
  * @cssstate invalid - While there is feedback to show: the platform's verdict, or the consumer's `feedback`.
47
48
  */
@@ -58,6 +59,40 @@ export declare class HelixHeadlessRating extends HelixHeadlessRating_base {
58
59
  * different answers and a control that cannot express the first has to
59
60
  * pretend the second. A survey that requires an answer says so with
60
61
  * `required`, not by making one unreachable.
62
+ *
63
+ * **It is the floor of the scale, and every edge follows from that.**
64
+ *
65
+ * *Clearing* returns the row to it. An empty rating, a value nothing can
66
+ * parse, and a form reset with nothing declared are all the same situation,
67
+ * and all three draw `min` filled: at the default of zero that is the empty
68
+ * row this always drew, and at `min="2"` it is two marks, the way an empty
69
+ * `Slider` sits at its own minimum rather than at zero. The value is still
70
+ * `""` through all of it, so `required` still refuses it and `valueAsNumber`
71
+ * is still `NaN`: what the row shows is the bottom of the scale, not an
72
+ * answer somebody gave.
73
+ *
74
+ * *The steps* are the scores from `step` up to `max` that are not below it,
75
+ * so `min="2"` on five marks offers 2 to 5 and leaves the first two marks
76
+ * filled and unpressable. At zero nothing is dropped, which is why the
77
+ * default row is unchanged.
78
+ *
79
+ * *The keyboard* moves through those steps and no others. `wrap` is already
80
+ * false here, so arrowing down from the lowest one holds its ground rather
81
+ * than reaching a score this property forbids, and the tab stop an unrated
82
+ * rating carries sits on the lowest score offered rather than on the first
83
+ * mark.
84
+ *
85
+ * *A `min` between two steps* is a floor and not a step: `min="1.5"` with
86
+ * whole marks clamps the value at 1.5 and offers 2 upward, since 1.5 is not
87
+ * somewhere the row can be pressed. That is a real thing to ask for and is
88
+ * left to mean what it says.
89
+ *
90
+ * *A `min` outside the marks is refused rather than obeyed*, and says so
91
+ * once on the console. Below zero and anything that is not a number are read
92
+ * as zero, there being no mark to draw for a negative score; above `max` is
93
+ * read as `max`, which collapses the scale to its top score rather than
94
+ * leaving a rating with nothing to press. A property that silently accepts
95
+ * nonsense is the fault this pays off, so neither case is quietly ignored.
61
96
  */
62
97
  min: number;
63
98
  /**
@@ -120,9 +155,28 @@ export declare class HelixHeadlessRating extends HelixHeadlessRating_base {
120
155
  */
121
156
  private previewing;
122
157
  protected readonly keys: KeyboardNavigationController;
158
+ /**
159
+ * The floor the row actually uses, which is `min` where `min` is one.
160
+ *
161
+ * Between zero and `max`, and zero for anything that is not a number, per
162
+ * the note on `min`. Derived on read rather than corrected into the property,
163
+ * so `rating.min` still reports what the consumer set and the warning below
164
+ * can tell the two apart.
165
+ */
166
+ private get floor();
123
167
  /** The score, as a number, clamped to what the marks can show. */
124
168
  get score(): number;
125
- /** Every score a reader can choose, which is `max / step` of them. */
169
+ /**
170
+ * Every score the marks can show, which is `max / step` of them.
171
+ *
172
+ * The whole row rather than the part `min` leaves open, because this is what
173
+ * the stylesheet lays the targets out against: `--rating-steps` is the pitch
174
+ * and a step's own place in this list is where it sits along the row. Counted
175
+ * from the part a reader can press instead, a floor above zero would widen
176
+ * every target and slide the row left.
177
+ */
178
+ private get positions();
179
+ /** Every score a reader can choose, which is the positions `min` leaves open. */
126
180
  private get steps();
127
181
  /**
128
182
  * The options, as the shared controller wants them.
@@ -182,10 +236,12 @@ export declare class HelixHeadlessRating extends HelixHeadlessRating_base {
182
236
  * form holding an unrated required rating submits, `:valid` true and the
183
237
  * entry present but empty. `reportRequired` is what makes the property real.
184
238
  *
185
- * **Empty is `value === ''` rather than a score of zero**, because zero is a
186
- * rating a reader can deliberately give when `min` allows it, and refusing to
187
- * submit it would be the control disagreeing with the property that permits
188
- * it. What `required` asks is whether they answered at all.
239
+ * **Empty is `value === ''` rather than a score at the floor**, and the two
240
+ * are not the same thing: an unanswered row draws `min`, per the note on that
241
+ * property, so a control that read its own picture would call every rating
242
+ * with `min="2"` answered the moment it rendered. What `required` asks is
243
+ * whether the reader answered at all, which only the value knows. A consumer
244
+ * who writes `value="0"` has answered, and is not refused.
189
245
  *
190
246
  * `Slider` is deliberately not given the same treatment: a native
191
247
  * `<input type="range" required>` is always valid, because a range always
@@ -197,6 +253,8 @@ export declare class HelixHeadlessRating extends HelixHeadlessRating_base {
197
253
  private readonly handleLeave;
198
254
  connectedCallback(): void;
199
255
  disconnectedCallback(): void;
256
+ /** Whether the console has already been told about a `min` off the scale. */
257
+ private warned;
200
258
  protected updated(changed: PropertyValues): void;
201
259
  protected willUpdate(changed: PropertyValues): void;
202
260
  protected firstUpdated(changed: PropertyValues): void;
@@ -89,18 +89,28 @@ let HelixHeadlessRating = class HelixHeadlessRating extends MixinControllerValue
89
89
  this.handleLeave = () => {
90
90
  this.previewing = undefined;
91
91
  };
92
+ this.warned = false;
93
+ }
94
+ get floor() {
95
+ if (!Number.isFinite(this.min)) {
96
+ return 0;
97
+ }
98
+ return Math.min(Math.max(this.min, 0), Math.max(this.max, 0));
92
99
  }
93
100
  get score() {
94
101
  const parsed = Number.parseFloat(this.value);
95
102
  if (!Number.isFinite(parsed)) {
96
- return 0;
103
+ return this.floor;
97
104
  }
98
- return Math.min(Math.max(parsed, 0), this.max);
105
+ return Math.min(Math.max(parsed, this.floor), this.max);
99
106
  }
100
- get steps() {
107
+ get positions() {
101
108
  const count = Math.max(1, Math.round(this.max / this.step));
102
109
  return Array.from({ length: count }, (_, index) => Number(((index + 1) * this.step).toFixed(2)));
103
110
  }
111
+ get steps() {
112
+ return this.positions.filter((score) => score >= this.floor);
113
+ }
104
114
  get options() {
105
115
  const found = [...(this.renderRoot?.querySelectorAll('[part="option"]') ?? [])];
106
116
  return found.map((element) => ({
@@ -157,6 +167,12 @@ let HelixHeadlessRating = class HelixHeadlessRating extends MixinControllerValue
157
167
  updated(changed) {
158
168
  super.updated?.(changed);
159
169
  this.sync();
170
+ if (this.floor !== this.min && !this.warned) {
171
+ this.warned = true;
172
+ console.warn(`<${this.localName}> has min="${this.min}", which is not a score between 0 and its max of ` +
173
+ `${this.max}, so the lowest score it offers is ${this.floor}. Put min inside the marks, ` +
174
+ 'or move max.');
175
+ }
160
176
  }
161
177
  willUpdate(changed) {
162
178
  super.willUpdate(changed);
@@ -177,12 +193,16 @@ let HelixHeadlessRating = class HelixHeadlessRating extends MixinControllerValue
177
193
  const first = this.steps[0];
178
194
  const mark = (layer, index) => html `<span part="mark">${this.markFor(layer, index)}</span>`;
179
195
  const marksIn = (layer) => Array.from({ length: count }, (_, index) => mark(layer, index));
180
- return html `<div
196
+ return html `${this.renderGroupLabel()}
197
+ <div part="field">
198
+ <div
181
199
  part="marks"
182
200
  role=${this.readOnly ? 'img' : 'radiogroup'}
183
201
  aria-label=${this.readOnly ? said : this.label || nothing}
202
+ aria-describedby=${this.describedBy ?? nothing}
203
+ aria-required=${this.required && !this.readOnly ? 'true' : nothing}
184
204
  data-slotted=${this.markSource ? '' : nothing}
185
- style=${`--rating-full: ${Math.floor(showing)}; --rating-part: ${showing - Math.floor(showing)}; --rating-steps: ${this.steps.length}`}
205
+ style=${`--rating-full: ${Math.floor(showing)}; --rating-part: ${showing - Math.floor(showing)}; --rating-steps: ${this.positions.length}`}
186
206
  @pointercancel=${this.handleDragCancel}
187
207
  @pointerdown=${this.handleDragStart}
188
208
  @pointermove=${this.handleDragMove}
@@ -207,7 +227,10 @@ let HelixHeadlessRating = class HelixHeadlessRating extends MixinControllerValue
207
227
  >
208
228
  ${this.readOnly
209
229
  ? nothing
210
- : this.steps.map((score, index) => html `<button
230
+ :
231
+ this.positions.map((score, index) => score < this.floor
232
+ ? nothing
233
+ : html `<button
211
234
  part="option"
212
235
  role="radio"
213
236
  type="button"
@@ -216,7 +239,9 @@ let HelixHeadlessRating = class HelixHeadlessRating extends MixinControllerValue
216
239
  data-score=${String(score)}
217
240
  style=${`--rating-step: ${index + 1}`}
218
241
  ?disabled=${this.isDisabled}
219
- tabindex=${this.score === score || (this.score === 0 && score === first) ? '0' : '-1'}
242
+ tabindex=${this.score === score || (this.score === this.floor && score === first)
243
+ ? '0'
244
+ : '-1'}
220
245
  @click=${this.handleOptionClick}
221
246
  @pointerenter=${this.handleOptionEnter}
222
247
  ></button>`)}
@@ -227,7 +252,9 @@ let HelixHeadlessRating = class HelixHeadlessRating extends MixinControllerValue
227
252
  aria-hidden="true"
228
253
  >${showing}</span
229
254
  >`
230
- : nothing}`;
255
+ : nothing}
256
+ </div>
257
+ ${this.renderHint()}${this.renderFeedback()}`;
231
258
  }
232
259
  get valueAsNumber() {
233
260
  return asNumber(this.value);
@@ -9,7 +9,7 @@ import { type PropertyValues, type TemplateResult } from 'lit';
9
9
  * server's markup for a crawler regardless of which tab is open.
10
10
  *
11
11
  * @since 0.1.0
12
- * @status experimental
12
+ * @status stable
13
13
  * @aliases TabPanel
14
14
  * @documentation https://fusion.dev/helix/components/tabs
15
15
  *
@@ -16,7 +16,7 @@ declare const HelixHeadlessTabs_base: (new (...args: any[]) => import("@fusion.d
16
16
  * showing a panel is cheap; a set where it is not should use `manual`.
17
17
  *
18
18
  * @since 0.1.0
19
- * @status experimental
19
+ * @status stable
20
20
  * @category navigation
21
21
  * @aliases TabList, Segmented control
22
22
  * @documentation https://fusion.dev/helix/components/tabs
@@ -12,7 +12,7 @@ import { type PropertyValues, type TemplateResult } from 'lit';
12
12
  * `aria-controls` pointing at nothing is worse than not being there.
13
13
  *
14
14
  * @since 0.1.0
15
- * @status experimental
15
+ * @status stable
16
16
  * @aliases Tab
17
17
  * @documentation https://fusion.dev/helix/components/tabs
18
18
  *
@@ -43,6 +43,19 @@ declare const HelixHeadlessTimePicker_base: (new (...args: any[]) => import("@fu
43
43
  * string, which is the whole reason the shape is that one. What each row *says*
44
44
  * is the reader's own convention, from `Intl`.
45
45
  *
46
+ * **The chosen row is marked in the markup, not worked out on upgrade.** The
47
+ * listbox is handed the answer as `.value`, and lit-ssr serializes no property
48
+ * binding, so a served `value="09:30"` shipped ninety-six rows with nothing on
49
+ * any of them saying which one it was: the mark arrived when the listbox
50
+ * upgraded and ran `place`. So each option is given `selected` as well, which
51
+ * is an attribute binding and does serve, and which the listbox then agrees
52
+ * with rather than fights, `place` writing the same answer from the same value.
53
+ * `selected` is the declaration a consumer writes on `Option` anywhere else in
54
+ * the library, and Date Picker expresses its own choice as markup for the same
55
+ * reason: a picker whose answer is invisible until a script runs is a picker
56
+ * whose answer is not served at all. Both readings do it, the list and the
57
+ * wheels, a wheel being a listbox like the list is.
58
+ *
46
59
  * @since 0.1.0
47
60
  * @status experimental
48
61
  * @category forms
@@ -248,6 +248,7 @@ let HelixHeadlessTimePicker = class HelixHeadlessTimePicker extends MixinControl
248
248
  label: option-label, text: option-text"
249
249
  value=${one}
250
250
  ?disabled=${barred(one)}
251
+ ?selected=${one === value}
251
252
  >${written(one)}</${this.tag('option')}
252
253
  >`)}
253
254
  </${this.tag('listbox')}>`;
@@ -291,6 +292,7 @@ let HelixHeadlessTimePicker = class HelixHeadlessTimePicker extends MixinControl
291
292
  label: option-label, text: option-text"
292
293
  value=${at}
293
294
  ?disabled=${this.barred(at)}
295
+ ?selected=${this.held.includes(at)}
294
296
  data-between=${between ? '' : nothing}
295
297
  >${writeTime(at, this.locale)}</${this.tag('option')}
296
298
  >`;
@@ -20,7 +20,7 @@ import type { HelixHeadlessTourStop } from '../stop/tour-stop.js';
20
20
  * nothing needs to be: a tour starts when a reader asks for it.
21
21
  *
22
22
  * @since 0.1.0
23
- * @status experimental
23
+ * @status stable
24
24
  * @category overlays
25
25
  * @aliases Coachmark, Product tour, Walkthrough, Onboarding
26
26
  * @documentation https://fusion.dev/helix/components/tour
@@ -35,7 +35,7 @@ import '../../button/index.js';
35
35
  * tree points at a target in that tree.
36
36
  *
37
37
  * @since 0.1.0
38
- * @status experimental
38
+ * @status stable
39
39
  * @category overlays
40
40
  * @documentation https://fusion.dev/helix/components/tour
41
41
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-headless",
3
3
  "description": "Headless component tier. Structure, accessibility, state, and form participation. No styling.",
4
- "version": "0.47.1",
4
+ "version": "0.49.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -46,14 +46,14 @@
46
46
  },
47
47
  "bugs": "https://github.com/project-fusion/fusion/issues",
48
48
  "dependencies": {
49
- "@fusion.dev/helix-core": "0.47.1",
50
- "@fusion.dev/helix-icons": "0.47.1",
49
+ "@fusion.dev/helix-core": "0.49.0",
50
+ "@fusion.dev/helix-icons": "0.49.0",
51
51
  "uqr": "^0.1.3"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@custom-elements-manifest/analyzer": "^0.11.0",
55
- "@fusion.dev/helix-cem": "0.47.1",
56
- "@fusion.dev/helix-testing": "0.47.1",
55
+ "@fusion.dev/helix-cem": "0.49.0",
56
+ "@fusion.dev/helix-testing": "0.49.0",
57
57
  "@fusion.dev/scripts": "0.0.0-dev",
58
58
  "@happy-dom/global-registrator": "^20.0.0",
59
59
  "@lit-labs/ssr": "^4.1.0",