@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.
@@ -1,24 +1,98 @@
1
1
  @scope (slider-ui) {
2
2
  :where(:scope) {
3
- /* ── Layout ── (sizes scale with universal [size] attribute) */
3
+ /* ═══════════════════════════════════════════════════════════════
4
+ SLIDER GEOMETRY — Mathematical foundation
5
+ ═══════════════════════════════════════════════════════════════
6
+
7
+ Variables
8
+ W = track width (100% of the track element)
9
+ t = thumb width (full outer width including internal padding)
10
+ h = track height (var(--slider-track-height))
11
+ pad = internal padding on the thumb (var(--slider-thumb-padding))
12
+ p = progress (0.0 → 1.0, written by JS as --slider-pct)
13
+
14
+ The thumb consists of two layers:
15
+ • Container: the geometry element that the fill aligns to.
16
+ Sized to t × h. Provides the full-height grab area.
17
+ • Visual: the white pill rendered inside the container via
18
+ ::before. Sized to (t−2·pad) × (h−2·pad), centered
19
+ by inset = pad on all sides.
20
+
21
+ ── Progressive fill ──
22
+ The blue fill starts at the thumb's full outer width and
23
+ grows incrementally:
24
+
25
+ fill_width(p) = t + p · (W − t) (1)
26
+
27
+ At p = 0 → fill = t (entire thumb container is blue)
28
+ At p = 1 → fill = W (blue spans the full track)
29
+
30
+ The fill is anchored at the left track edge (left: 0), so:
31
+
32
+ fill_right(p) = t + p · (W − t) (2)
33
+
34
+ ── Thumb travel ──
35
+ The thumb container's center moves across the travel zone:
36
+
37
+ travel = W − t (3)
38
+
39
+ thumb_center(p) = t/2 + p · (W − t) (4)
40
+
41
+ At p = 0 → center at t/2 (container left flush, visual inset by pad)
42
+ At p = 1 → center at W−t/2 (container right flush, visual inset by pad)
43
+
44
+ In CSS (percentages relative to W):
45
+
46
+ left = calc(t/2 + p · (W − t))
47
+ = calc(var(--slider-thumb-width) / 2
48
+ + var(--slider-pct) * (100% - var(--slider-thumb-width)))
49
+
50
+ The element is shifted back by 50% via transform so its
51
+ geometric center lands on the computed point.
52
+
53
+ ── Thumb visual inset ──
54
+ The white pill is rendered by ::before with:
55
+ inset: pad (applied on all four sides)
56
+
57
+ This means the visual pill is inset from the container edge
58
+ by pad pixels, giving breathing room between the white surface
59
+ and the track boundary at both extremes.
60
+ ═══════════════════════════════════════════════════════════════ */
61
+
62
+ /* ── Layout ── */
4
63
  --slider-gap: var(--a-space-1);
5
64
  --slider-readout-gap: var(--a-space-0-5);
6
65
  --slider-radius: var(--a-radius-full);
7
- --slider-track-height: calc(var(--a-size) * 0.125);
8
- --slider-track-area-height: calc(var(--a-size) * 0.75);
9
- --slider-thumb-size: calc(var(--a-size) * 0.375);
10
66
 
11
- /* ── Colors ── */
67
+ /* Track height scales with the universal [size] attribute
68
+ via --a-toggle-size: 16 sm / 20 md / 24 lg at density=1.
69
+ Override --slider-track-height directly for custom sizes. */
70
+ --slider-track-height: var(--a-toggle-size);
71
+
72
+ /* Thumb padding: internal breathing room on all sides.
73
+ The visual pill sits pad px away from the container edge. */
74
+ --slider-thumb-padding: 2px;
75
+
76
+ /* Visual thumb height: track height minus top and bottom padding. */
77
+ --slider-thumb-visual-h: calc(var(--slider-track-height) - 2 * var(--slider-thumb-padding));
78
+
79
+ /* Visual thumb width: 2×1 pill ratio. */
80
+ --slider-thumb-visual-w: calc(var(--slider-thumb-visual-h) * 2);
81
+
82
+ /* Container / geometry thumb width: visual width plus left and
83
+ right padding. This is the width used in the travel equation. */
84
+ --slider-thumb-width: calc(var(--slider-thumb-visual-w) + 2 * var(--slider-thumb-padding));
85
+
86
+ /* Progress fraction (0.0 → 1.0) written by JS. */
87
+ --slider-pct: 0;
88
+
89
+ /* ── Colors ──
90
+ Track: dim recessed surface | Fill: primary | Thumb: white chrome */
12
91
  --slider-track-bg: var(--a-bg-muted);
13
- --slider-fill-bg: var(--a-accent-bg);
14
- --slider-thumb-bg: var(--a-accent-bg);
15
- --slider-thumb-border: var(--a-bg); /* intentional: cutout ring must match page bg */
16
- --slider-thumb-hover-ring: 0 0 0 4px var(--a-bg-muted);
92
+ --slider-fill-bg: var(--a-primary-bg);
93
+ --slider-thumb-bg: var(--a-chrome-light);
17
94
  --slider-fill-bg-disabled: var(--a-border-subtle);
18
95
  --slider-thumb-bg-disabled: var(--a-fg-muted);
19
- --slider-label-fg: var(--a-fg-muted);
20
- --slider-value-fg: var(--a-fg);
21
- --slider-suffix-fg: var(--a-fg-muted);
22
96
 
23
97
  /* ── Typography ── */
24
98
  --slider-font-size: var(--a-ui-size);
@@ -28,7 +102,7 @@
28
102
  --slider-duration: var(--a-duration-fast);
29
103
  --slider-easing: var(--a-easing);
30
104
 
31
- /* ── State ── */
105
+ /* ── Focus ── */
32
106
  --slider-focus-ring: var(--a-focus-ring);
33
107
  }
34
108
 
@@ -55,7 +129,7 @@
55
129
  }
56
130
 
57
131
  [slot="label"] {
58
- color: var(--slider-label-fg);
132
+ color: var(--a-fg-subtle);
59
133
  }
60
134
 
61
135
  [slot="readout"] {
@@ -65,86 +139,102 @@
65
139
  }
66
140
 
67
141
  [slot="value"] {
68
- color: var(--slider-value-fg);
142
+ color: var(--a-fg);
69
143
  font-weight: var(--slider-value-weight);
70
144
  font-variant-numeric: tabular-nums;
71
145
  }
72
146
 
73
147
  [slot="suffix"] {
74
- color: var(--slider-suffix-fg);
148
+ color: var(--a-fg-muted);
75
149
  }
76
150
 
77
- /* Track */
151
+ /* Track: the reference frame W for all geometry calculations. */
78
152
  [slot="track"] {
79
153
  position: relative;
80
- height: var(--slider-track-area-height);
81
- display: flex;
82
- align-items: center;
154
+ height: var(--slider-track-height);
155
+ border-radius: var(--slider-radius);
156
+ background: var(--slider-track-bg);
83
157
  cursor: pointer;
84
158
  touch-action: none;
85
159
  }
86
160
 
161
+ /* Fill — equation (1): width = t + p·(W−t) */
87
162
  [slot="fill"] {
88
163
  position: absolute;
164
+ top: 0;
89
165
  left: 0;
90
- height: var(--slider-track-height);
91
- border-radius: var(--slider-radius);
166
+ height: 100%;
167
+ border-radius: inherit;
92
168
  background: var(--slider-fill-bg);
93
169
  pointer-events: none;
170
+ width: calc(var(--slider-thumb-width)
171
+ + var(--slider-pct) * (100% - var(--slider-thumb-width)));
94
172
  }
95
173
 
96
- [slot="track"]::before {
97
- content: '';
98
- position: absolute;
99
- left: 0;
100
- right: 0;
101
- height: var(--slider-track-height);
102
- border-radius: var(--slider-radius);
103
- background: var(--slider-track-bg);
104
- }
105
-
106
- /* Thumb */
174
+ /* Thumb CONTAINER: full track height, geometry width.
175
+ Transparent background; the white pill is rendered by ::before.
176
+ This element provides a generous vertical grab area. */
107
177
  [slot="thumb"] {
108
178
  position: absolute;
109
- width: var(--slider-thumb-size);
110
- height: var(--slider-thumb-size);
179
+ top: 50%;
180
+ left: calc(var(--slider-thumb-width) / 2
181
+ + var(--slider-pct) * (100% - var(--slider-thumb-width)));
182
+ width: var(--slider-thumb-width);
183
+ height: var(--slider-track-height);
111
184
  border-radius: var(--slider-radius);
112
- background: var(--slider-thumb-bg);
113
- border: 2px solid var(--slider-thumb-border);
114
- transform: translateX(-50%);
185
+ background: transparent;
186
+ transform: translate(-50%, -50%);
115
187
  cursor: grab;
116
188
  touch-action: none;
117
189
  transition:
118
- transform var(--slider-duration) var(--slider-easing),
119
- box-shadow var(--slider-duration) var(--slider-easing);
190
+ left var(--slider-duration) var(--slider-easing),
191
+ transform var(--slider-duration) var(--slider-easing);
120
192
  z-index: 1;
121
193
  }
194
+
195
+ /* Thumb VISUAL: white pill rendered inside the transparent
196
+ container via ::before. Inset = padding on all four sides,
197
+ so the pill is vertically and horizontally inset from the
198
+ container edge by pad pixels. */
199
+ [slot="thumb"]::before {
200
+ content: '';
201
+ position: absolute;
202
+ inset: var(--slider-thumb-padding);
203
+ border-radius: inherit;
204
+ background: var(--slider-thumb-bg);
205
+ }
206
+
207
+ /* During drag: kill the left transition so the thumb follows
208
+ the pointer frame-by-frame without CSS interpolation lag. */
209
+ :scope[data-dragging] [slot="thumb"] {
210
+ transition: transform var(--slider-duration) var(--slider-easing);
211
+ }
212
+
122
213
  [slot="thumb"]:hover {
123
- box-shadow: var(--slider-thumb-hover-ring);
214
+ transform: translate(-50%, -50%) scale(1.05);
124
215
  }
216
+
125
217
  [slot="thumb"]:active {
126
- transform: translateX(-50%) scale(1.15);
218
+ transform: translate(-50%, -50%) scale(1.1);
127
219
  cursor: grabbing;
128
220
  }
221
+
129
222
  :scope:focus-visible { outline: none; }
130
223
  :scope:focus-visible [slot="thumb"] { box-shadow: var(--slider-focus-ring); }
131
224
 
132
225
  /* Disabled */
133
226
  :scope[disabled] [slot="track"] { cursor: not-allowed; }
134
- :scope[disabled] [slot="fill"] { background: var(--slider-fill-bg-disabled); }
135
- :scope[disabled] [slot="thumb"] { cursor: not-allowed; background: var(--slider-thumb-bg-disabled); }
136
- :scope[disabled] [slot="thumb"]:hover { box-shadow: none; }
137
-
138
- /* ── Hint (§184, v0.5.5, FEEDBACK-08 §7) ──
139
- Small caption rendered beneath the track. aria-describedby is
140
- wired on the host in class.js so screen readers announce this
141
- as a description (distinct from aria-label, which comes from
142
- [label]). Uses the same muted typography as field-ui's hint. */
227
+ :scope[disabled] [slot="fill"] { background: var(--slider-fill-bg-disabled); }
228
+ :scope[disabled] [slot="thumb"]::before {
229
+ background: var(--slider-thumb-bg-disabled);
230
+ }
231
+
232
+ /* ── Hint (§184, v0.5.5, FEEDBACK-08 §7) ── */
143
233
  [slot="hint"] {
144
234
  display: block;
145
235
  margin-top: var(--slider-hint-mt, var(--a-space-1));
146
236
  font-size: var(--slider-hint-size, var(--a-ui-xs));
147
- color: var(--slider-hint-fg, var(--a-fg-muted));
237
+ color: var(--slider-hint-fg, var(--a-fg-muted));
148
238
  line-height: var(--slider-hint-lh, 1.4);
149
239
  }
150
240
  }
@@ -14,43 +14,46 @@ function mount(html) {
14
14
  describe('slider-ui', () => {
15
15
  beforeEach(() => { document.body.innerHTML = ''; });
16
16
 
17
- it('renders thumb at correct % for initial value', async () => {
17
+ // §330 (v0.5.16) slider iOS-style pill redesign. Thumb position no
18
+ // longer set via thumb.style.left; instead the host gets a CSS custom
19
+ // property `--slider-pct` (fraction 0.0–1.0) and CSS calc() composes
20
+ // the full geometry: `fill_width(p) = t + p·(W−t)`, where t = thumb
21
+ // width and W = track width. Test asserts the CSS variable carries
22
+ // the expected fraction (50% → 0.5).
23
+ const pct = (s) => s.style.getPropertyValue('--slider-pct').trim();
24
+
25
+ it('renders host --slider-pct for initial value', async () => {
18
26
  const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
19
27
  await tick();
20
- const thumb = s.querySelector('[slot="thumb"]');
21
- expect(thumb.style.left).toBe('50%');
28
+ expect(pct(s)).toBe('0.5');
22
29
  });
23
30
 
24
31
  // Property reactivity contract — locks in the behavior that consumer
25
32
  // feedback (FEEDBACK-adia-packages.md §5, 2026-05-12) incorrectly
26
33
  // claimed was broken. UIElement's signal-backed property setters
27
- // (core/element.js:31-50) trigger the host's render effect on every
28
- // property change; slider-ui's render() reads this.value and updates
29
- // [slot="thumb"].style.left. This test catches any regression that
30
- // would break undo/redo or any other programmatic-value flow.
31
- it('moves thumb when .value is set programmatically', async () => {
34
+ // trigger the host's render effect on every property change; slider-ui's
35
+ // render() reads this.value and writes --slider-pct on the host.
36
+ it('updates --slider-pct when .value is set programmatically', async () => {
32
37
  const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
33
38
  await tick();
34
- const thumb = s.querySelector('[slot="thumb"]');
35
- expect(thumb.style.left).toBe('50%');
39
+ expect(pct(s)).toBe('0.5');
36
40
 
37
41
  s.value = 75;
38
42
  await tick();
39
- expect(thumb.style.left).toBe('75%');
43
+ expect(pct(s)).toBe('0.75');
40
44
 
41
45
  s.value = 10;
42
46
  await tick();
43
- expect(thumb.style.left).toBe('10%');
47
+ expect(pct(s)).toBe('0.1');
44
48
  });
45
49
 
46
- it('moves thumb when attribute is set imperatively', async () => {
50
+ it('updates --slider-pct when attribute is set imperatively', async () => {
47
51
  const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
48
52
  await tick();
49
- const thumb = s.querySelector('[slot="thumb"]');
50
53
 
51
54
  s.setAttribute('value', '25');
52
55
  await tick();
53
- expect(thumb.style.left).toBe('25%');
56
+ expect(pct(s)).toBe('0.25');
54
57
  });
55
58
 
56
59
  it('updates [slot="value"] readout text when .value changes', async () => {
@@ -82,11 +82,15 @@ states:
82
82
  traits: []
83
83
  tokens:
84
84
  --slider-fill:
85
- description: Filled track / thumb color
86
- --slider-thumb-size:
87
- description: Thumb diameter
85
+ description: Filled portion / progress color (was mixed accent, now primary)
86
+ --slider-thumb-width:
87
+ description: Thumb pill width (2× thumb-height, driven by track-height)
88
+ --slider-thumb-height:
89
+ description: Thumb pill height (track-height − 2× inset)
88
90
  --slider-track:
89
- description: Track background color
91
+ description: Unfilled track background color
92
+ --slider-track-height:
93
+ description: Full track height (scales via universal [size] attribute)
90
94
  a2ui:
91
95
  rules: []
92
96
  anti_patterns: []
@@ -218,9 +218,28 @@ export class UISwatch extends UIElement {
218
218
  // survive stamping as direct host siblings, NOT funnel into #labelEl where
219
219
  // absolute-positioning anchors against the label text bbox. Pull them out
220
220
  // FIRST so the innerHTML='' wipe below doesn't lose them.
221
+ //
222
+ // §331 (v0.5.16, FB-39): also walk `display: contents` wrapper spans
223
+ // inserted by AdiaUI's `html\`\`` template engine (`core/template.js`
224
+ // `wrap()` at line 212). When the parent template interpolates child
225
+ // fragments via `${badgeFrag}` / `${dotFrag}`, each fragment lands
226
+ // INSIDE a wrapper span that's a direct child of <swatch-ui>; the
227
+ // chrome elements end up as GRANDCHILDREN, not direct children. The
228
+ // pre-§331 direct-children-only filter found `chromeSlot = []` →
229
+ // innerHTML='' wiped the wrapper spans + their chrome content →
230
+ // `#absorbChromeSlot()` in `#syncCore()` had nothing left to absorb.
231
+ // Deep-walk via querySelectorAll catches both shapes:
232
+ // - static-HTML / pre-template: direct-child <span slot="chrome">
233
+ // - template-render: <span slot="chrome"> nested in wrapper span
221
234
  const chromeSlot = Array.from(this.children).filter(
222
235
  n => n.getAttribute && n.getAttribute('slot') === 'chrome'
223
236
  );
237
+ // Also collect chrome elements nested inside wrapper-spans (template-render path).
238
+ for (const wrapper of Array.from(this.children)) {
239
+ if (wrapper.getAttribute?.('slot') === 'chrome') continue;
240
+ const nested = wrapper.querySelectorAll?.('[slot="chrome"]');
241
+ if (nested) for (const n of nested) chromeSlot.push(n);
242
+ }
224
243
  for (const n of chromeSlot) n.remove();
225
244
 
226
245
  // Capture pre-existing default-slot content so consumer-authored
@@ -331,6 +350,23 @@ export class UISwatch extends UIElement {
331
350
  this.insertBefore(child, this.#badgeEl);
332
351
  }
333
352
  }
353
+ // §331 (v0.5.16, FB-39): also handle wrapper-spanned chrome children
354
+ // that arrive AFTER #stamp() (parent template's late update() pass).
355
+ // Hoist them OUT of any non-internal wrapper + into the canonical slot.
356
+ for (const child of Array.from(this.children)) {
357
+ if (
358
+ child === this.#tileEl ||
359
+ child === this.#badgeEl ||
360
+ child === this.#labelEl ||
361
+ child === this.#detailEl ||
362
+ child === this.#copyEl
363
+ ) continue;
364
+ const nested = child.querySelectorAll?.('[slot="chrome"]');
365
+ if (!nested || !nested.length) continue;
366
+ for (const n of nested) {
367
+ this.insertBefore(n, this.#badgeEl);
368
+ }
369
+ }
334
370
  }
335
371
 
336
372
  #syncCore() {
@@ -161,3 +161,109 @@ describe('<swatch-ui> late-arriving [slot="chrome"] children (FB-37 / §326)', (
161
161
  expect(found.nextElementSibling).toBe(badge);
162
162
  });
163
163
  });
164
+
165
+ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 / §331)', () => {
166
+ let host;
167
+
168
+ beforeEach(() => {
169
+ host = document.createElement('div');
170
+ document.body.appendChild(host);
171
+ });
172
+
173
+ it('hoists chrome child from inside wrapper span at #stamp() time (template-interpolation path)', async () => {
174
+ // Simulates AdiaUI's html`` template wrapping interpolated content in
175
+ // <span style="display: contents"> (per core/template.js:212 `wrap()`).
176
+ // When consumer writes `html`<swatch-ui>${chromeFrag}</swatch-ui>``,
177
+ // the chromeFrag's content lands NESTED inside a wrapper span — not
178
+ // as direct children of <swatch-ui>. Pre-§331 the chrome was wiped.
179
+ const swatch = document.createElement('swatch-ui');
180
+ swatch.setAttribute('shape', 'block');
181
+ swatch.setAttribute('label-position', 'overlay');
182
+ swatch.setAttribute('color', '#3b82f6');
183
+ swatch.setAttribute('label', '500');
184
+
185
+ const wrapper = document.createElement('span');
186
+ wrapper.style.display = 'contents';
187
+ const chrome = document.createElement('span');
188
+ chrome.setAttribute('slot', 'chrome');
189
+ chrome.id = 'wrapped-chrome';
190
+ chrome.textContent = 'P3';
191
+ wrapper.appendChild(chrome);
192
+ swatch.appendChild(wrapper);
193
+
194
+ host.appendChild(swatch);
195
+ await new Promise((r) => setTimeout(r, 80));
196
+
197
+ const found = swatch.querySelector('#wrapped-chrome');
198
+ expect(found).not.toBeNull();
199
+ // Chrome child should be a direct host sibling positioned between tile + badge.
200
+ const tile = swatch.querySelector('[data-tile]');
201
+ const badge = swatch.querySelector('[data-badge]');
202
+ expect(found.parentElement).toBe(swatch);
203
+ expect(found.previousElementSibling).toBe(tile);
204
+ expect(found.nextElementSibling).toBe(badge);
205
+ });
206
+
207
+ it('handles MULTIPLE chrome children nested in separate wrapper spans', async () => {
208
+ // Each interpolated fragment in html`` gets its own wrapper span,
209
+ // so multi-chrome usage (`${badge}${dot1}${dot2}`) produces multiple
210
+ // wrapper spans, each containing one chrome child.
211
+ const swatch = document.createElement('swatch-ui');
212
+ swatch.setAttribute('shape', 'block');
213
+ swatch.setAttribute('label-position', 'overlay');
214
+ swatch.setAttribute('color', '#3b82f6');
215
+
216
+ const ids = ['c1', 'c2', 'c3'];
217
+ for (const id of ids) {
218
+ const w = document.createElement('span');
219
+ w.style.display = 'contents';
220
+ const c = document.createElement('span');
221
+ c.setAttribute('slot', 'chrome');
222
+ c.id = id;
223
+ c.textContent = id;
224
+ w.appendChild(c);
225
+ swatch.appendChild(w);
226
+ }
227
+ host.appendChild(swatch);
228
+ await new Promise((r) => setTimeout(r, 80));
229
+
230
+ for (const id of ids) {
231
+ const found = swatch.querySelector(`#${id}`);
232
+ expect(found).not.toBeNull();
233
+ expect(found.parentElement).toBe(swatch);
234
+ }
235
+ });
236
+
237
+ it('hoists chrome from late-arriving wrapper span (post-stamp template re-render)', async () => {
238
+ // Parent template re-render after the swatch is already stamped:
239
+ // a new wrapper span carrying chrome content arrives after #stamp().
240
+ // #absorbChromeSlot() should pick it up on next render pass.
241
+ const swatch = document.createElement('swatch-ui');
242
+ swatch.setAttribute('shape', 'block');
243
+ swatch.setAttribute('color', '#3b82f6');
244
+ swatch.setAttribute('label', '500');
245
+ host.appendChild(swatch);
246
+ await new Promise((r) => setTimeout(r, 80));
247
+
248
+ // Late: append a wrapper-span carrying chrome
249
+ const wrapper = document.createElement('span');
250
+ wrapper.style.display = 'contents';
251
+ const chrome = document.createElement('span');
252
+ chrome.setAttribute('slot', 'chrome');
253
+ chrome.id = 'late-wrapped';
254
+ wrapper.appendChild(chrome);
255
+ swatch.appendChild(wrapper);
256
+
257
+ // Nudge a render via a property change
258
+ swatch.label = '600';
259
+ await new Promise((r) => setTimeout(r, 80));
260
+
261
+ const found = swatch.querySelector('#late-wrapped');
262
+ expect(found).not.toBeNull();
263
+ expect(found.parentElement).toBe(swatch);
264
+ const tile = swatch.querySelector('[data-tile]');
265
+ const badge = swatch.querySelector('[data-badge]');
266
+ expect(found.previousElementSibling).toBe(tile);
267
+ expect(found.nextElementSibling).toBe(badge);
268
+ });
269
+ });