@fusion.dev/helix-headless 0.50.0 → 0.52.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.
@@ -9,6 +9,15 @@ import { caretLeft, caretRight, play, stop } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
+ const COUNT_OR_AUTO = {
13
+ fromAttribute: (value) => {
14
+ if (value === null) {
15
+ return undefined;
16
+ }
17
+ return value === 'auto' ? 'auto' : Number(value);
18
+ },
19
+ toAttribute: (value) => value === undefined ? null : String(value),
20
+ };
12
21
  let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
13
22
  constructor() {
14
23
  super(...arguments);
@@ -20,6 +29,16 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
20
29
  this.label = 'Carousel';
21
30
  this.loop = false;
22
31
  this.rotating = false;
32
+ this.readFit = () => {
33
+ const fitted = this.measure();
34
+ if (fitted === undefined || fitted === this.fitted) {
35
+ return;
36
+ }
37
+ this.fitted = fitted;
38
+ if (this.index > this.positions - 1) {
39
+ this.goTo(this.positions - 1);
40
+ }
41
+ };
23
42
  this.handleIndicatorKeydown = (event) => {
24
43
  const step = inlineStep(this, event.key);
25
44
  if (step === 0) {
@@ -95,12 +114,55 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
95
114
  this.indexState = next;
96
115
  this.requestUpdate('index', previous);
97
116
  }
98
- get across() {
117
+ get clamped() {
99
118
  const asked = this.perView ?? 1;
100
- return Number.isFinite(asked) ? Math.max(1, Math.floor(asked)) : 1;
119
+ return typeof asked === 'number' && Number.isFinite(asked) ? Math.max(1, Math.floor(asked)) : 1;
120
+ }
121
+ get fits() {
122
+ return this.perView === 'auto' && this.orientation !== 'vertical';
123
+ }
124
+ get across() {
125
+ return this.fits ? this.fitted : this.clamped;
101
126
  }
102
127
  get positions() {
103
- return Math.max(1, this.slides.length - this.across + 1);
128
+ return Math.max(1, this.slides.length - (this.across ?? 1) + 1);
129
+ }
130
+ measure() {
131
+ const track = this.partNamed('track');
132
+ const pane = this.panes[0]?.getBoundingClientRect().width;
133
+ if (!track || pane === undefined) {
134
+ return undefined;
135
+ }
136
+ const asked = Number.parseFloat(getComputedStyle(track).columnGap);
137
+ const gap = Number.isNaN(asked) ? 0 : asked;
138
+ const room = track.clientWidth - 2 * this.bleed(track);
139
+ if (pane <= 0 || room <= 0) {
140
+ return undefined;
141
+ }
142
+ return Math.max(1, Math.round((room + gap) / (pane + gap)));
143
+ }
144
+ handed() {
145
+ return this.fits ? nothing : `--carousel-per-view: ${this.clamped}`;
146
+ }
147
+ watchPane() {
148
+ if (!this.fits || typeof ResizeObserver === 'undefined') {
149
+ this.sizes?.disconnect();
150
+ this.sizes = undefined;
151
+ this.watched = undefined;
152
+ return;
153
+ }
154
+ this.sizes ??= new ResizeObserver(this.readFit);
155
+ const pane = this.panes[0];
156
+ if (pane === this.watched) {
157
+ return;
158
+ }
159
+ if (this.watched) {
160
+ this.sizes.unobserve(this.watched);
161
+ }
162
+ this.watched = pane;
163
+ if (pane) {
164
+ this.sizes.observe(pane);
165
+ }
104
166
  }
105
167
  get panes() {
106
168
  return [...(this.renderRoot?.querySelectorAll?.('[part="slide"]') ?? [])];
@@ -196,6 +258,7 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
196
258
  this.addEventListener('pointerenter', this.pause);
197
259
  this.watcher = new MutationObserver(() => this.requestUpdate());
198
260
  this.watcher.observe(this, { childList: true });
261
+ this.watchPane();
199
262
  }
200
263
  disconnectedCallback() {
201
264
  super.disconnectedCallback();
@@ -207,6 +270,9 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
207
270
  this.removeEventListener('pointerenter', this.pause);
208
271
  this.watcher?.disconnect();
209
272
  this.watcher = undefined;
273
+ this.sizes?.disconnect();
274
+ this.sizes = undefined;
275
+ this.watched = undefined;
210
276
  clearInterval(this.timer);
211
277
  this.timer = undefined;
212
278
  }
@@ -218,25 +284,29 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
218
284
  if (changed.has('index')) {
219
285
  this.showing = undefined;
220
286
  }
287
+ if (changed.has('perView')) {
288
+ this.fitted = undefined;
289
+ }
221
290
  hostAria(this.internals, { label: this.label, role: 'group' });
222
291
  this.internals.ariaRoleDescription = 'carousel';
223
292
  }
224
293
  firstUpdated(changed) {
225
294
  super.firstUpdated(changed);
295
+ this.watchPane();
226
296
  this.finishHydration();
227
297
  }
228
298
  updated(changed) {
229
299
  super.updated(changed);
230
- const count = this.slides.length;
231
300
  const drawn = this.renderRoot.querySelectorAll('[part="slide"]').length;
232
301
  if (changed.has('index') || (this.scrolledFor === 0 && drawn > 0)) {
233
302
  this.scrollToIndex();
234
303
  }
235
304
  this.scrolledFor = drawn;
236
305
  this.runTimer();
237
- if (count > 0 && this.index > count - 1) {
238
- this.goTo(count - 1);
306
+ if (this.slides.length > 0 && this.index > this.positions - 1) {
307
+ this.goTo(this.positions - 1);
239
308
  }
309
+ this.watchPane();
240
310
  this.assign();
241
311
  }
242
312
  assign() {
@@ -308,7 +378,7 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
308
378
  <div
309
379
  data-scroller
310
380
  part="track"
311
- style=${`--carousel-per-view: ${this.across}`}
381
+ style=${this.handed()}
312
382
  @scroll=${this.handleTrackScroll}
313
383
  @scrollend=${this.handleScroll}
314
384
  @pointerdown=${this.handleTake}
@@ -328,7 +398,7 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
328
398
  ></slot>
329
399
  </div>`)}
330
400
  </div>
331
- ${this.slides.length > 1
401
+ ${this.slides.length > 1 && this.across !== undefined
332
402
  ? html `<div part="indicators" role="group" aria-label=${`${this.label} slides`}>
333
403
  ${''}
334
404
  ${Array.from({ length: this.positions }).map((_place, at) => {
@@ -376,8 +446,11 @@ __decorate([
376
446
  state()
377
447
  ], HelixHeadlessCarousel.prototype, "rotating", void 0);
378
448
  __decorate([
379
- property({ attribute: 'per-view', reflect: true, type: Number })
449
+ property({ attribute: 'per-view', converter: COUNT_OR_AUTO, reflect: true })
380
450
  ], HelixHeadlessCarousel.prototype, "perView", void 0);
451
+ __decorate([
452
+ state()
453
+ ], HelixHeadlessCarousel.prototype, "fitted", void 0);
381
454
  __decorate([
382
455
  state()
383
456
  ], HelixHeadlessCarousel.prototype, "showing", void 0);
@@ -27,13 +27,15 @@ import { type PropertyValues, type TemplateResult } from 'lit';
27
27
  * **A pane can be shut, and that is not the same as a divider at the edge.**
28
28
  * The geometry is identical and the knowledge is not: only a shut pane knows
29
29
  * where to open back to. So `collapsed` is a second state rather than a reading
30
- * of the position, and the position it opens to is the one collapsing never
31
- * overwrote. `collapsed-size` keeps a sliver showing where a pane has a tab
32
- * strip worth leaving, and collapse overrides `min` and `max`, which is what
33
- * those are for.
30
+ * of the position, and the position it opens to is where the gesture that shut
31
+ * it began, whichever gesture that was: a drag takes its reading on the press,
32
+ * and every other door takes it in `settle`, nothing having been applied there
33
+ * yet. `collapsed-size` keeps a sliver showing where a pane has a tab strip
34
+ * worth leaving, and collapse overrides `min` and `max`, which is what those
35
+ * are for.
34
36
  *
35
37
  * @since 0.1.0
36
- * @status experimental
38
+ * @status stable
37
39
  * @category layout
38
40
  * @aliases SplitPane, Resizable, SplitView
39
41
  * @documentation https://fusion.dev/helix/components/splitter
@@ -41,8 +43,8 @@ import { type PropertyValues, type TemplateResult } from 'lit';
41
43
  * @slot end - The pane after it.
42
44
  * @slot start - The pane before the divider.
43
45
  *
44
- * @event {HelixBeforeCollapseEvent} helix-before-collapse - Fired before a pane shuts or reopens, and only where the position it is derived from stood. Cancelable: refusing it leaves the pane as it was and the divider where the gesture put it.
45
- * @event {HelixBeforeResizeEvent} helix-before-resize - Fired before the divider settles, and only where the gesture moved it: a press that moved nothing, a drag that came back, an arrow with nowhere left to go and a corner that moved the other divider are all silent on this one. Cancelable: refusing it takes the whole of where the gesture ended, so no collapse follows from it either. A drag has already drawn itself by then and holds where it was dropped until the consumer assigns their own.
46
+ * @event {HelixBeforeCollapseEvent} helix-before-collapse - Fired before a pane shuts or reopens, whether or not the resize that reached it was allowed, and carrying its own position: a shut pane sits at `collapsed-size` rather than at the number the resize named. Cancelable: refusing it leaves the pane as it was and the divider where the gesture put it.
47
+ * @event {HelixBeforeResizeEvent} helix-before-resize - Fired before the divider settles, and only where the gesture moved it: a press that moved nothing, a drag that came back, an arrow with nowhere left to go and a corner that moved the other divider are all silent on this one. Cancelable: refusing it takes the position and leaves the rest of the gesture, so a collapse the gesture reached is still announced and still yours to refuse. A drag has already drawn itself by then and holds where it was dropped until the consumer assigns their own.
46
48
  *
47
49
  * @csspart corner - The handle where two dividers cross, added on the client.
48
50
  * @csspart end - The box holding the second.
@@ -181,6 +183,14 @@ export declare class HelixHeadlessSplitter extends HelixElement {
181
183
  * the edge the number it started from is gone. Taken once, on the press, and
182
184
  * written back if the gesture ends shut, so the pane opens to where it was
183
185
  * rather than to the edge it was dragged against.
186
+ *
187
+ * **A pointer gesture only, because it is the only one that loses it**, and
188
+ * `settle` answers for the rest from `began`: a key and a double click apply
189
+ * nothing before they settle, so where the divider is at the top of that
190
+ * method is where it was. Read once and cleared there, exactly as `began` is,
191
+ * and for the second reason as well as the first. Left standing it measured
192
+ * the next gesture against the last one's press, so a collapse pressed from
193
+ * 20 reopened at the 60 a drag had started from an hour earlier.
184
194
  */
185
195
  private opened;
186
196
  /**
@@ -331,21 +341,48 @@ export declare class HelixHeadlessSplitter extends HelixElement {
331
341
  * Through the audited path, so a consumer who owns `position` is told what was
332
342
  * asked for and decides, and one who does not has already had it applied.
333
343
  *
334
- * **The second announcement is conditional on the first, because it is derived
335
- * from it.** `shutAt` reads the position the gesture ended on, so a refused
336
- * `helix-before-resize` leaves a collapse computed from a number that was
337
- * refused. Announced anyway, the refusal produced the outcome it was refusing:
338
- * measured on a divider at 40 with a listener calling `preventDefault`, Home
339
- * left `position` reporting 0 and `collapsed` reporting `start`, because the
340
- * collapse applied and `position` reads the sliver once a pane is shut. The
341
- * return of the first request is what says so, and it was being dropped: a
342
- * cancelable event dispatched as a bare statement with nothing reading the
343
- * answer, which is the shape the navigation cohort named on `Outline` and left.
344
+ * **A refused resize ends the resize and not the gesture.** `shutAt` reads the
345
+ * position the gesture ended on, so the collapse is computed from a number the
346
+ * consumer may have declined, and the reading this replaces was that the two
347
+ * are therefore one decision: refuse the first and the second cannot stand.
348
+ * That is a reasonable thing to believe about a splitter nobody else owns, and
349
+ * it takes "no" to mean the gesture is over. What it misses is that a consumer
350
+ * who owns the position refuses in order to *take* the decision, not to end
351
+ * it. The sandbox two folders away refuses every `helix-before-resize` from
352
+ * the splitter inside it, re-announces the same move in its own vocabulary and
353
+ * writes the answer back through `position`, which is what makes it the single
354
+ * decision point; returning on its refusal left `panel-collapsed-size` inert
355
+ * there, the shipped `with-a-panel` example measuring a 0px panel against the
356
+ * 36 it asks for. So the collapse is reached either way and is refused, or
357
+ * not, on its own.
358
+ *
359
+ * **Which is honest because the collapse carries its own position.** A shut
360
+ * pane sits at `collapsed-size`, which is where `position` reads it from once
361
+ * `collapsed` is set, and never at the number the resize named: refuse a drag
362
+ * to 100 on a splitter with `collapsed-size="10"`, allow the collapse, and the
363
+ * divider is at 90. Nothing writes the refused number either, `requestChange`
364
+ * applying nothing when it is refused and the collapse below writing the open
365
+ * position back over whatever a drag had already drawn, so a consumer who says
366
+ * no to the position and yes to the collapse gets the pane shut and keeps the
367
+ * number they had to reopen to. The two coincide only where `collapsed-size`
368
+ * is zero, and the states differ even there: a shut pane knows where to open
369
+ * back to and a divider parked at the edge does not.
344
370
  *
345
371
  * Refusing the collapse alone still leaves the resize, which is the other way
346
372
  * round and is coherent: the divider goes where the drag put it and the pane
347
- * is not declared shut. What a consumer cannot have is a pane that collapses
348
- * out from under a position they said no to.
373
+ * is not declared shut.
374
+ *
375
+ * **The open position is taken here for every door but the pointer's, which
376
+ * is the same split `began` makes and for the same reason.** A drag has
377
+ * overwritten the position on the way to the edge, so it reads it on the
378
+ * press; a key, a double click and `moveTo` apply nothing until the request
379
+ * below, so where the divider is at the top of this method is where it was.
380
+ * Captured on the press alone, the class docstring's promise that a shut pane
381
+ * knows where to open back to held for a drag and for nothing else: measured
382
+ * 17 Sep 2026, `Home` on a splitter resting at 60 shut the start pane and
383
+ * reopened it at 0, the resize above having written the edge and the collapse
384
+ * having nothing to put back. Stale, it was worse than absent, `opened` never
385
+ * being cleared: a press at 60, then a key collapse from 20, reopened at 60.
349
386
  *
350
387
  * **A gesture that moved nothing announces nothing, and this is the one place
351
388
  * that can say so.** A change carrying the position the divider already had
@@ -302,10 +302,12 @@ let HelixHeadlessSplitter = HelixHeadlessSplitter_1 = class HelixHeadlessSplitte
302
302
  const shut = this.shutAt(wanted);
303
303
  const began = this.began ?? this.position;
304
304
  this.began = undefined;
305
+ const opened = this.opened ?? (this.collapsed ? undefined : began);
306
+ this.opened = undefined;
305
307
  if (wanted === began && shut === this.collapsed) {
306
308
  return;
307
309
  }
308
- const moved = requestChange({
310
+ requestChange({
309
311
  apply: (value) => {
310
312
  const previous = this.position;
311
313
  this.positionState = value;
@@ -315,15 +317,12 @@ let HelixHeadlessSplitter = HelixHeadlessSplitter_1 = class HelixHeadlessSplitte
315
317
  host: this,
316
318
  next: wanted,
317
319
  });
318
- if (!moved) {
319
- return;
320
- }
321
320
  if (shut !== this.collapsed) {
322
321
  requestChange({
323
322
  apply: (value) => {
324
323
  const previous = this.collapsed;
325
- if (value !== '' && this.opened !== undefined) {
326
- this.positionState = this.opened;
324
+ if (value !== '' && opened !== undefined) {
325
+ this.positionState = opened;
327
326
  }
328
327
  this.collapsedState = value === '' ? 'none' : value;
329
328
  this.requestUpdate('collapsed', previous);
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.50.0",
4
+ "version": "0.52.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.50.0",
50
- "@fusion.dev/helix-icons": "0.50.0",
49
+ "@fusion.dev/helix-core": "0.52.0",
50
+ "@fusion.dev/helix-icons": "0.52.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.50.0",
56
- "@fusion.dev/helix-testing": "0.50.0",
55
+ "@fusion.dev/helix-cem": "0.52.0",
56
+ "@fusion.dev/helix-testing": "0.52.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",