@adia-ai/web-components 0.5.15 → 0.5.16

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.
@@ -46,13 +46,17 @@ export class UISwiper extends UIElement {
46
46
  snap: { type: String, default: 'start', reflect: true },
47
47
  gap: { type: String, default: '', reflect: true },
48
48
  slidesPerView: { type: String, default: '', reflect: true, attribute: 'slides-per-view' },
49
+ chrome: { type: String, default: 'default', reflect: true },
50
+ label: { type: String, default: '' },
51
+ counter: { type: Boolean, default: false, reflect: true },
49
52
  };
50
53
 
51
54
  static template = () => null;
52
55
 
53
56
  #track = null;
54
57
  #timer = null;
55
- #observer = null;
58
+ #resizeObserver = null;
59
+ #scrollHandler = null;
56
60
  #activeIndex = 0;
57
61
  #bound = false;
58
62
  #fallbackNav = false;
@@ -110,6 +114,9 @@ export class UISwiper extends UIElement {
110
114
  this.#track.addEventListener('pointercancel', this.#onPointerUp);
111
115
  // Capture-phase click suppression so a drag-on-button doesn't activate it.
112
116
  this.#track.addEventListener('click', this.#onClickCapture, true);
117
+ // Suppress native HTML5 drag (e.g. on <img>) so it doesn't preempt
118
+ // the pointermove gesture before our threshold is reached.
119
+ this.#track.addEventListener('dragstart', this.#onDragStart);
113
120
  }
114
121
 
115
122
  if (this.autoplay) this.play();
@@ -117,15 +124,24 @@ export class UISwiper extends UIElement {
117
124
 
118
125
  render() {
119
126
  if (this.gap && this.#track) {
120
- const space = `var(--a-space-${this.gap})`;
121
- this.style.setProperty('--swiper-gap', space);
127
+ // Pure-numeric values ("0", "4") are space-token indices that
128
+ // resolve to `--a-space-N`. Anything else (e.g. "1rem", "8px",
129
+ // "var(--my-gap)") is treated as a raw CSS length — matches the
130
+ // example HTML's "Accepts any CSS length or spacing token" claim.
131
+ const isTokenIndex = /^[0-9]+$/.test(this.gap);
132
+ const value = isTokenIndex ? `var(--a-space-${this.gap})` : this.gap;
133
+ this.style.setProperty('--swiper-gap', value);
122
134
  }
123
135
  }
124
136
 
125
137
  disconnected() {
126
138
  this.pause();
127
- this.#observer?.disconnect();
128
- this.#observer = null;
139
+ this.#resizeObserver?.disconnect();
140
+ this.#resizeObserver = null;
141
+ if (this.#track && this.#scrollHandler) {
142
+ this.#track.removeEventListener('scroll', this.#scrollHandler);
143
+ }
144
+ this.#scrollHandler = null;
129
145
  this.removeEventListener('pointerenter', this.#onPauseHover);
130
146
  this.removeEventListener('pointerleave', this.#onResumeHover);
131
147
  this.removeEventListener('focusin', this.#onPauseFocus);
@@ -137,6 +153,7 @@ export class UISwiper extends UIElement {
137
153
  this.#track.removeEventListener('pointerup', this.#onPointerUp);
138
154
  this.#track.removeEventListener('pointercancel', this.#onPointerUp);
139
155
  this.#track.removeEventListener('click', this.#onClickCapture, true);
156
+ this.#track.removeEventListener('dragstart', this.#onDragStart);
140
157
  }
141
158
  this.#drag = null;
142
159
  this.#track = null;
@@ -146,27 +163,30 @@ export class UISwiper extends UIElement {
146
163
  // ── Public API ──
147
164
 
148
165
  next() {
149
- const slides = this.slides;
150
- const nextIndex = Math.min(this.#activeIndex + 1, slides.length - 1);
151
- if (nextIndex !== this.#activeIndex || this.loop) {
152
- this.goTo(this.loop && nextIndex === this.#activeIndex ? 0 : nextIndex);
166
+ const lastPage = this.#getPageCount() - 1;
167
+ const currentPage = this.#slideToPageIndex(this.#activeIndex);
168
+ const nextPage = Math.min(currentPage + 1, lastPage);
169
+ if (nextPage !== currentPage || this.loop) {
170
+ const target = this.loop && nextPage === currentPage ? 0 : nextPage;
171
+ this.goTo(this.#pageToSlideIndex(target));
153
172
  }
154
173
  }
155
174
 
156
175
  prev() {
157
- const slides = this.slides;
158
- const prevIndex = Math.max(this.#activeIndex - 1, 0);
159
- if (prevIndex !== this.#activeIndex || this.loop) {
160
- this.goTo(this.loop && prevIndex === this.#activeIndex ? slides.length - 1 : prevIndex);
176
+ const lastPage = this.#getPageCount() - 1;
177
+ const currentPage = this.#slideToPageIndex(this.#activeIndex);
178
+ const prevPage = Math.max(currentPage - 1, 0);
179
+ if (prevPage !== currentPage || this.loop) {
180
+ const target = this.loop && prevPage === currentPage ? lastPage : prevPage;
181
+ this.goTo(this.#pageToSlideIndex(target));
161
182
  }
162
183
  }
163
184
 
164
185
  goTo(index) {
165
- const slides = this.slides;
166
- const slide = slides[index];
167
- if (slide && this.#track) {
168
- this.#track.scrollTo({ left: slide.offsetLeft - this.#track.offsetLeft, behavior: 'smooth' });
169
- }
186
+ const slide = this.slides[index];
187
+ if (!slide || !this.#track) return;
188
+ const target = Math.max(0, this.#snapTargetFor(slide));
189
+ this.#track.scrollTo({ left: target, behavior: 'smooth' });
170
190
  }
171
191
 
172
192
  play() {
@@ -174,9 +194,10 @@ export class UISwiper extends UIElement {
174
194
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
175
195
 
176
196
  this.#timer = setInterval(() => {
177
- const slides = this.slides;
178
- const atEnd = this.#activeIndex >= slides.length - 1;
179
- if (atEnd && this.loop) this.goTo(0);
197
+ const lastPage = this.#getPageCount() - 1;
198
+ const currentPage = this.#slideToPageIndex(this.#activeIndex);
199
+ const atEnd = currentPage >= lastPage;
200
+ if (atEnd && this.loop) this.goTo(this.#pageToSlideIndex(0));
180
201
  else if (!atEnd) this.next();
181
202
  else this.pause();
182
203
  }, this.interval || 5000);
@@ -203,33 +224,123 @@ export class UISwiper extends UIElement {
203
224
  }
204
225
 
205
226
  #setupObserver() {
206
- this.#observer = new IntersectionObserver(entries => {
207
- for (const entry of entries) {
208
- if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
209
- const slides = this.slides;
210
- const index = slides.indexOf(entry.target);
211
- if (index !== -1 && index !== this.#activeIndex) {
212
- this.#activeIndex = index;
213
- this.#updateFallbackDots();
214
- this.dispatchEvent(new CustomEvent('change', {
215
- bubbles: true,
216
- detail: { index, slide: entry.target },
217
- }));
218
- }
219
- }
220
- }
221
- }, { root: this.#track, threshold: 0.5 });
227
+ // Scroll-based, snap-aware active tracking. `activeIndex` is the
228
+ // slide whose snap target sits closest to the current scrollLeft:
229
+ // start → slide whose left edge is at the track's left edge
230
+ // center slide whose center is at the track's center
231
+ // end → slide whose right edge is at the track's right edge
232
+ //
233
+ // Replaces an earlier IntersectionObserver-based "leftmost visible"
234
+ // approach which couldn't distinguish the last two snap positions
235
+ // for center- and end-snap (multiple slides remained >50% visible).
236
+ this.#scrollHandler = () => {
237
+ const newIndex = this.#computeActiveIndex();
238
+ if (newIndex === this.#activeIndex) return;
239
+ this.#activeIndex = newIndex;
240
+ this.#updateFallbackDots();
241
+ this.dispatchEvent(new CustomEvent('change', {
242
+ bubbles: true,
243
+ detail: { index: newIndex, slide: this.slides[newIndex] },
244
+ }));
245
+ };
246
+ this.#track.addEventListener('scroll', this.#scrollHandler, { passive: true });
247
+ // Initial stamp (no event — there's no prior state to diff against).
248
+ this.#activeIndex = this.#computeActiveIndex();
249
+ }
222
250
 
223
- for (const slide of this.slides) {
224
- this.#observer.observe(slide);
251
+ // ── Snap & page helpers ──
252
+ // `--swiper-columns` is set on the host by `[slides-per-view]` and
253
+ // cascades to the track; the responsive @container rules set it on
254
+ // the track directly. Reading the computed value on the track sees
255
+ // both paths.
256
+
257
+ #getColumns() {
258
+ if (!this.#track) return 1;
259
+ const v = getComputedStyle(this.#track).getPropertyValue('--swiper-columns').trim();
260
+ const n = parseInt(v, 10);
261
+ return Number.isFinite(n) && n > 0 ? n : 1;
262
+ }
263
+
264
+ #getPageCount() {
265
+ const total = this.slides.length;
266
+ if (total === 0) return 0;
267
+ // Center-snap allows every slide to be the centered one (edge
268
+ // slides clamped at the scroll bounds), so each slide is its own
269
+ // "page". Start- and end-snap collapse equivalent positions to
270
+ // (slides − cols + 1) distinct pages.
271
+ if (this.snap === 'center') return total;
272
+ return Math.max(1, total - this.#getColumns() + 1);
273
+ }
274
+
275
+ // Snap target for a slide (in scrollLeft coordinates, unclamped).
276
+ #snapTargetFor(slide) {
277
+ const trackWidth = this.#track.clientWidth;
278
+ const left = slide.offsetLeft - this.#track.offsetLeft;
279
+ const width = slide.offsetWidth;
280
+ if (this.snap === 'center') return left + width / 2 - trackWidth / 2;
281
+ if (this.snap === 'end') return left + width - trackWidth;
282
+ return left;
283
+ }
284
+
285
+ // Returns the slide index whose clamped snap target is closest to
286
+ // the current scrollLeft. Snap-mode-aware so the "active" slide
287
+ // matches the snap point currently aligned with the track.
288
+ #computeActiveIndex() {
289
+ if (!this.#track) return 0;
290
+ const slides = this.slides;
291
+ if (slides.length === 0) return 0;
292
+ const sl = this.#track.scrollLeft;
293
+ const maxScroll = Math.max(0, this.#track.scrollWidth - this.#track.clientWidth);
294
+ let bestIndex = 0;
295
+ let bestDistance = Infinity;
296
+ for (let i = 0; i < slides.length; i++) {
297
+ const target = Math.max(0, Math.min(maxScroll, this.#snapTargetFor(slides[i])));
298
+ const distance = Math.abs(sl - target);
299
+ if (distance < bestDistance) {
300
+ bestDistance = distance;
301
+ bestIndex = i;
302
+ }
225
303
  }
304
+ return bestIndex;
305
+ }
306
+
307
+ // Page↔slide mapping. Center- and start-snap use the slide index
308
+ // directly as page index; end-snap shifts by (cols−1) because page 0
309
+ // shows slide (cols−1) end-aligned.
310
+ #pageToSlideIndex(page) {
311
+ if (this.snap !== 'end') return page;
312
+ const cols = this.#getColumns();
313
+ return Math.min(this.slides.length - 1, page + cols - 1);
314
+ }
315
+
316
+ #slideToPageIndex(slideIdx) {
317
+ if (this.snap !== 'end') return slideIdx;
318
+ const cols = this.#getColumns();
319
+ return Math.max(0, slideIdx - cols + 1);
226
320
  }
227
321
 
228
322
  #setupFallbackNav() {
229
323
  if (this.#fallbackNav) return;
230
324
  this.#fallbackNav = true;
231
325
 
232
- // Stamp prev/next paddles
326
+ if (this.chrome === 'toolbar') {
327
+ this.#stampToolbarChrome();
328
+ } else {
329
+ this.#stampDefaultChrome();
330
+ }
331
+
332
+ this.#refreshDots();
333
+
334
+ // Container queries can change `--swiper-columns` mid-life; re-stamp
335
+ // dots when the host width crosses a breakpoint.
336
+ if (typeof ResizeObserver !== 'undefined') {
337
+ this.#resizeObserver = new ResizeObserver(() => this.#refreshDots());
338
+ this.#resizeObserver.observe(this);
339
+ }
340
+ }
341
+
342
+ #stampDefaultChrome() {
343
+ // Overlay paddles (absolutely positioned over the track) + centered dot row.
233
344
  const prev = document.createElement('button-ui');
234
345
  prev.setAttribute('data-swiper-btn', '');
235
346
  prev.setAttribute('data-swiper-prev', '');
@@ -249,31 +360,102 @@ export class UISwiper extends UIElement {
249
360
  this.appendChild(prev);
250
361
  this.appendChild(next);
251
362
 
252
- // Stamp pagination dots
253
363
  const dots = document.createElement('div');
254
364
  dots.setAttribute('data-swiper-dots', '');
255
365
  dots.setAttribute('role', 'tablist');
256
366
  dots.setAttribute('aria-label', 'Slide indicators');
367
+ this.appendChild(dots);
368
+ }
257
369
 
258
- this.slides.forEach((_, i) => {
259
- const dot = document.createElement('button');
260
- dot.setAttribute('role', 'tab');
261
- dot.setAttribute('aria-label', `Slide ${i + 1}`);
262
- if (i === 0) dot.setAttribute('aria-current', 'true');
263
- dot.addEventListener('click', () => this.goTo(i));
264
- dots.appendChild(dot);
265
- });
370
+ #stampToolbarChrome() {
371
+ // Header row: [label] [spacer] [prev | next].
372
+ const head = document.createElement('div');
373
+ head.setAttribute('data-swiper-head', '');
266
374
 
267
- this.appendChild(dots);
375
+ const labelEl = document.createElement('span');
376
+ labelEl.setAttribute('data-swiper-label', '');
377
+ labelEl.textContent = this.label || '';
378
+ head.appendChild(labelEl);
379
+
380
+ const paddles = document.createElement('div');
381
+ paddles.setAttribute('data-swiper-paddles', '');
382
+
383
+ const prev = document.createElement('button-ui');
384
+ prev.setAttribute('icon', 'caret-left');
385
+ prev.setAttribute('variant', 'ghost');
386
+ prev.setAttribute('size', 'sm');
387
+ prev.setAttribute('aria-label', 'Previous slide');
388
+ prev.addEventListener('press', () => this.prev());
389
+
390
+ const next = document.createElement('button-ui');
391
+ next.setAttribute('icon', 'caret-right');
392
+ next.setAttribute('variant', 'ghost');
393
+ next.setAttribute('size', 'sm');
394
+ next.setAttribute('aria-label', 'Next slide');
395
+ next.addEventListener('press', () => this.next());
396
+
397
+ paddles.appendChild(prev);
398
+ paddles.appendChild(next);
399
+ head.appendChild(paddles);
400
+
401
+ // Insert head BEFORE the track so it sits above the slides.
402
+ this.insertBefore(head, this.#track);
403
+
404
+ // Footer row: [counter] [spacer] [dots].
405
+ const foot = document.createElement('div');
406
+ foot.setAttribute('data-swiper-foot', '');
407
+
408
+ const counterEl = document.createElement('span');
409
+ counterEl.setAttribute('data-swiper-counter', '');
410
+ foot.appendChild(counterEl);
411
+
412
+ const dots = document.createElement('div');
413
+ dots.setAttribute('data-swiper-dots', '');
414
+ dots.setAttribute('role', 'tablist');
415
+ dots.setAttribute('aria-label', 'Slide indicators');
416
+ foot.appendChild(dots);
417
+
418
+ this.appendChild(foot);
419
+ this.#updateCounter();
420
+ }
421
+
422
+ #updateCounter() {
423
+ const el = this.querySelector('[data-swiper-counter]');
424
+ if (!el) return;
425
+ if (!this.counter) { el.textContent = ''; return; }
426
+ const cur = this.#slideToPageIndex(this.#activeIndex) + 1;
427
+ const total = this.#getPageCount();
428
+ el.textContent = `${cur} / ${total}`;
429
+ }
430
+
431
+ #refreshDots() {
432
+ const dots = this.querySelector('[data-swiper-dots]');
433
+ if (!dots) return;
434
+ const desired = this.#getPageCount();
435
+ if (dots.children.length !== desired) {
436
+ while (dots.firstChild) dots.removeChild(dots.firstChild);
437
+ for (let i = 0; i < desired; i++) {
438
+ const dot = document.createElement('button');
439
+ dot.setAttribute('role', 'tab');
440
+ dot.setAttribute('aria-label', `Page ${i + 1}`);
441
+ dot.addEventListener('click', () => this.goTo(this.#pageToSlideIndex(i)));
442
+ dots.appendChild(dot);
443
+ }
444
+ }
445
+ this.#updateFallbackDots();
268
446
  }
269
447
 
270
448
  #updateFallbackDots() {
271
449
  if (!this.#fallbackNav) return;
272
450
  const dots = this.querySelector('[data-swiper-dots]');
273
- if (!dots) return;
274
- [...dots.children].forEach((dot, i) => {
275
- dot.setAttribute('aria-current', i === this.#activeIndex ? 'true' : 'false');
276
- });
451
+ if (dots) {
452
+ const lastPage = this.#getPageCount() - 1;
453
+ const active = Math.min(Math.max(this.#slideToPageIndex(this.#activeIndex), 0), lastPage);
454
+ [...dots.children].forEach((dot, i) => {
455
+ dot.setAttribute('aria-current', i === active ? 'true' : 'false');
456
+ });
457
+ }
458
+ this.#updateCounter();
277
459
  }
278
460
 
279
461
  // ── Event handlers ──
@@ -370,4 +552,15 @@ export class UISwiper extends UIElement {
370
552
  e.stopPropagation();
371
553
  }
372
554
  };
555
+
556
+ // Suppress native HTML5 drag (e.g. `<img>` ghost-drag) so it doesn't
557
+ // hijack the pointer gesture before `#onPointerMove` crosses its
558
+ // 5px threshold. Without this, dragging on top of an image inside
559
+ // a slide never reaches our pointermove handler. We only suppress
560
+ // when a swipe gesture is actually in progress (set by `#onPointerDown`)
561
+ // so genuinely-draggable content (e.g. a palette item the consumer
562
+ // wants to drag out of the swiper) is preserved when not swiping.
563
+ #onDragStart = (e) => {
564
+ if (this.#drag) e.preventDefault();
565
+ };
373
566
  }
@@ -18,9 +18,23 @@
18
18
  "type": "boolean",
19
19
  "default": false
20
20
  },
21
+ "chrome": {
22
+ "description": "Pagination chrome layout. `default` (overlay paddles + centered dots below) or `toolbar` (header row with label + paddles, footer row with counter + dots).",
23
+ "type": "string",
24
+ "enum": [
25
+ "default",
26
+ "toolbar"
27
+ ],
28
+ "default": "default"
29
+ },
21
30
  "component": {
22
31
  "const": "Swiper"
23
32
  },
33
+ "counter": {
34
+ "description": "When `chrome=\"toolbar\"`, show a \"current / total\" page counter in the footer (left).",
35
+ "type": "boolean",
36
+ "default": false
37
+ },
24
38
  "gap": {
25
39
  "description": "Gap between slides (token: sm, md, lg)",
26
40
  "type": "string",
@@ -31,6 +45,11 @@
31
45
  "type": "number",
32
46
  "default": 5000
33
47
  },
48
+ "label": {
49
+ "description": "Title rendered in the toolbar header (left). Only used when `chrome=\"toolbar\"`.",
50
+ "type": "string",
51
+ "default": ""
52
+ },
34
53
  "loop": {
35
54
  "description": "Infinite loop",
36
55
  "type": "boolean",
@@ -28,11 +28,15 @@
28
28
 
29
29
  /* ── Pagination dots ── */
30
30
  /* Component-intrinsic visual constant; no --a-space-* equivalent */
31
- --swiper-dot-size: 6px;
32
- --swiper-dot-gap: var(--a-space-2);
33
- --swiper-dot-bg: var(--a-border);
34
- --swiper-dot-bg-active: var(--a-fg);
35
- --swiper-dot-radius: var(--a-radius-full);
31
+ --swiper-dot-size: 6px;
32
+ /* Active dot is one space token (4px @ density=1) larger than the
33
+ inactive dot — small, deliberate emphasis without rearranging
34
+ the row. The hitbox stays at 1rem; only the visible circle grows. */
35
+ --swiper-dot-size-active: calc(var(--swiper-dot-size) + var(--a-space-1));
36
+ --swiper-dot-gap: var(--a-space-2);
37
+ --swiper-dot-bg: var(--a-border);
38
+ --swiper-dot-bg-active: var(--a-fg);
39
+ --swiper-dot-radius: var(--a-radius-full);
36
40
 
37
41
  /* ── Host vertical rhythm ── */
38
42
  --swiper-gap-vertical: var(--a-space-3);
@@ -67,6 +71,11 @@
67
71
  gap: var(--swiper-gap-vertical);
68
72
  width: 100%;
69
73
  position: relative;
74
+ /* Host is the container so @container queries below read the
75
+ swiper's own width, not a wider ancestor's. Named to avoid
76
+ collision with any ancestor that also queries by size. */
77
+ container-type: inline-size;
78
+ container-name: swiper;
70
79
  }
71
80
 
72
81
  /* ── Scroll track (JS wraps slides into this) ── */
@@ -84,7 +93,6 @@
84
93
  scrollbar-width: none;
85
94
  -webkit-overflow-scrolling: touch;
86
95
  gap: var(--swiper-gap);
87
- container-type: inline-size;
88
96
  }
89
97
 
90
98
  :scope > [data-swiper-track]::-webkit-scrollbar { display: none; }
@@ -126,18 +134,22 @@
126
134
  :scope[slides-per-view="3"] { --swiper-columns: 3; }
127
135
  :scope[slides-per-view="4"] { --swiper-columns: 4; }
128
136
 
129
- /* ── Responsive auto (container queries) ── */
137
+ /* ── Responsive auto (container queries) ──
138
+ The host IS the named `swiper` container, so @container queries
139
+ match descendants of it. `:scope` can't be its own descendant —
140
+ we target the track (a descendant) so the cv resolves against
141
+ the swiper's own width. */
130
142
 
131
143
  :scope:not([slides-per-view]) {
132
144
  --swiper-columns: 1;
133
145
  }
134
146
 
135
- @container (min-width: 640px) {
136
- :scope:not([slides-per-view]) { --swiper-columns: 2; }
147
+ @container swiper (min-width: 640px) {
148
+ :scope:not([slides-per-view]) > [data-swiper-track] { --swiper-columns: 2; }
137
149
  }
138
150
 
139
- @container (min-width: 960px) {
140
- :scope:not([slides-per-view]) { --swiper-columns: 3; }
151
+ @container swiper (min-width: 960px) {
152
+ :scope:not([slides-per-view]) > [data-swiper-track] { --swiper-columns: 3; }
141
153
  }
142
154
 
143
155
  /* ══════════════════════════════════════════════════════════
@@ -166,7 +178,7 @@
166
178
  Normal flow below the track — not inside the scroll area.
167
179
  ══════════════════════════════════════════════════════════ */
168
180
 
169
- :scope > [data-swiper-dots] {
181
+ :scope [data-swiper-dots] {
170
182
  display: flex;
171
183
  align-items: center;
172
184
  justify-content: center;
@@ -174,7 +186,12 @@
174
186
  min-height: 1.5rem;
175
187
  }
176
188
 
177
- :scope > [data-swiper-dots] > button {
189
+ /* Dot geometry every dot has the same 1rem hitbox so the row layout
190
+ is invariant across state changes. The visible circle inside is
191
+ `--swiper-dot-size` for idle dots and `--swiper-dot-size-active`
192
+ for the active one (one space token larger). Padding is computed
193
+ from the size so the dot stays centered in the hitbox. */
194
+ :scope [data-swiper-dots] > button {
178
195
  width: var(--swiper-dot-size);
179
196
  height: var(--swiper-dot-size);
180
197
  border-radius: var(--swiper-dot-radius);
@@ -184,21 +201,70 @@
184
201
  background-clip: content-box;
185
202
  box-sizing: content-box;
186
203
  cursor: pointer;
187
- transition: background var(--swiper-duration) var(--swiper-easing),
188
- transform var(--swiper-duration) var(--swiper-easing);
204
+ transition: background var(--swiper-duration) var(--swiper-easing);
189
205
  }
190
206
 
191
- :scope > [data-swiper-dots] > button:hover {
192
- background: var(--swiper-dot-bg-active);
207
+ /* Long-hand `background-color` (not the `background` shorthand!) so
208
+ `background-clip: content-box` from the base rule isn't reset to
209
+ its initial `border-box` value — the colored area would otherwise
210
+ fill the full 1rem hitbox and turn into a huge circle. */
211
+ :scope [data-swiper-dots] > button:hover {
212
+ background-color: var(--swiper-dot-bg-active);
193
213
  }
194
214
 
195
- :scope > [data-swiper-dots] > button:focus-visible {
215
+ /* Focus ring is rendered INSIDE the padding area (inset shadow), so
216
+ the dot's visual footprint stays the size of every other dot. */
217
+ :scope [data-swiper-dots] > button:focus-visible {
196
218
  outline: none;
197
- box-shadow: var(--a-focus-ring);
219
+ box-shadow: inset 0 0 0 var(--a-focus-width) var(--a-focus-color);
220
+ }
221
+
222
+ :scope [data-swiper-dots] > button[aria-current="true"] {
223
+ background-color: var(--swiper-dot-bg-active);
224
+ width: var(--swiper-dot-size-active);
225
+ height: var(--swiper-dot-size-active);
226
+ padding: calc((1rem - var(--swiper-dot-size-active)) / 2);
227
+ }
228
+
229
+ /* ══════════════════════════════════════════════════════════
230
+ Toolbar chrome — [chrome="toolbar"]
231
+ Header row: label | spacer | paddles
232
+ Footer row: counter | spacer | dots
233
+ Replaces the overlay paddles + centered dots layout.
234
+ ══════════════════════════════════════════════════════════ */
235
+
236
+ :scope[chrome="toolbar"] > [data-swiper-head],
237
+ :scope[chrome="toolbar"] > [data-swiper-foot] {
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: space-between;
241
+ gap: var(--a-space-3);
242
+ min-height: 2rem;
243
+ }
244
+
245
+ :scope[chrome="toolbar"] > [data-swiper-head] > [data-swiper-label] {
246
+ font-size: var(--a-ui-md);
247
+ font-weight: var(--a-weight-semibold);
248
+ color: var(--a-fg);
249
+ }
250
+
251
+ :scope[chrome="toolbar"] > [data-swiper-head] > [data-swiper-paddles] {
252
+ display: flex;
253
+ gap: var(--a-space-1);
254
+ }
255
+
256
+ :scope[chrome="toolbar"] > [data-swiper-foot] > [data-swiper-counter] {
257
+ font-size: var(--a-ui-sm);
258
+ color: var(--a-fg-subtle);
259
+ font-variant-numeric: tabular-nums;
198
260
  }
199
261
 
200
- :scope > [data-swiper-dots] > button[aria-current="true"] {
201
- background: var(--swiper-dot-bg-active);
262
+ /* In toolbar mode, the dots cluster lives inside the footer and is
263
+ right-aligned via space-between on the parent — drop the default
264
+ justify-content: center and zero out vertical breathing room. */
265
+ :scope[chrome="toolbar"] > [data-swiper-foot] > [data-swiper-dots] {
266
+ min-height: 0;
267
+ justify-content: flex-end;
202
268
  }
203
269
 
204
270
  /* ── Autoplay indicator ── */
@@ -31,10 +31,16 @@ export type SwiperChangeEvent = CustomEvent<SwiperChangeEventDetail>;
31
31
  export class UISwiper extends UIElement {
32
32
  /** Enable auto-advance */
33
33
  autoplay: boolean;
34
+ /** Pagination chrome layout. `default` (overlay paddles + centered dots below) or `toolbar` (header row with label + paddles, footer row with counter + dots). */
35
+ chrome: 'default' | 'toolbar';
36
+ /** When `chrome="toolbar"`, show a "current / total" page counter in the footer (left). */
37
+ counter: boolean;
34
38
  /** Gap between slides (token: sm, md, lg) */
35
39
  gap: string;
36
40
  /** Autoplay interval in ms */
37
41
  interval: number;
42
+ /** Title rendered in the toolbar header (left). Only used when `chrome="toolbar"`. */
43
+ label: string;
38
44
  /** Infinite loop */
39
45
  loop: boolean;
40
46
  /** Suppress the default pause-on-hover/focus behavior for autoplay. */
@@ -50,6 +50,24 @@ props:
50
50
  description: Show peek of adjacent slides
51
51
  type: boolean
52
52
  default: false
53
+ chrome:
54
+ description: >-
55
+ Pagination chrome layout. `default` (overlay paddles +
56
+ centered dots below) or `toolbar` (header row with label +
57
+ paddles, footer row with counter + dots).
58
+ type: string
59
+ default: default
60
+ enum:
61
+ - default
62
+ - toolbar
63
+ label:
64
+ description: Title rendered in the toolbar header (left). Only used when `chrome="toolbar"`.
65
+ type: string
66
+ default: ''
67
+ counter:
68
+ description: When `chrome="toolbar"`, show a "current / total" page counter in the footer (left).
69
+ type: boolean
70
+ default: false
53
71
  events:
54
72
  autoplay-pause:
55
73
  description: Fired on autoplay-pause.
@@ -35,7 +35,12 @@ export class UITag extends UIElement {
35
35
 
36
36
  static properties = {
37
37
  text: { type: String, default: '', reflect: true },
38
- textContent: { type: String, default: '' },
38
+ // NOTE: `textContent` is intentionally NOT declared as a reactive
39
+ // prop — `installProps()` would override the native DOM setter, so
40
+ // `el.textContent = '…'` would silently no-op. `_api-table.js`,
41
+ // `catalog.examples.js`, and the drop-target demo all rely on
42
+ // `tag.textContent = …` working natively; the prop declaration
43
+ // broke that path. v0.5.x §327.
39
44
  variant: { type: String, default: 'default', reflect: true },
40
45
  size: { type: String, default: 'md', reflect: true },
41
46
  removable: { type: Boolean, default: false, reflect: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.5.15",
3
+ "version": "0.5.16",
4
4
  "description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",