@adia-ai/web-components 0.6.36 → 0.6.37

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 (115) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/components/badge/badge.a2ui.json +10 -0
  3. package/components/badge/badge.css +70 -0
  4. package/components/badge/badge.yaml +20 -0
  5. package/components/blockquote/blockquote.a2ui.json +121 -0
  6. package/components/blockquote/blockquote.class.js +68 -0
  7. package/components/blockquote/blockquote.css +46 -0
  8. package/components/blockquote/blockquote.d.ts +31 -0
  9. package/components/blockquote/blockquote.js +17 -0
  10. package/components/blockquote/blockquote.yaml +124 -0
  11. package/components/button/button.css +11 -3
  12. package/components/calendar-picker/calendar-picker.a2ui.json +15 -0
  13. package/components/calendar-picker/calendar-picker.class.js +7 -1
  14. package/components/calendar-picker/calendar-picker.yaml +14 -0
  15. package/components/color-input/color-input.a2ui.json +2 -2
  16. package/components/color-input/color-input.class.js +9 -2
  17. package/components/color-input/color-input.yaml +2 -2
  18. package/components/combobox/combobox.class.js +4 -0
  19. package/components/context-menu/context-menu.a2ui.json +159 -0
  20. package/components/context-menu/context-menu.class.js +275 -0
  21. package/components/context-menu/context-menu.css +56 -0
  22. package/components/context-menu/context-menu.d.ts +70 -0
  23. package/components/context-menu/context-menu.js +17 -0
  24. package/components/context-menu/context-menu.yaml +136 -0
  25. package/components/date-range-picker/date-range-picker.a2ui.json +15 -0
  26. package/components/date-range-picker/date-range-picker.class.js +2 -0
  27. package/components/date-range-picker/date-range-picker.yaml +14 -0
  28. package/components/datetime-picker/datetime-picker.a2ui.json +15 -0
  29. package/components/datetime-picker/datetime-picker.class.js +3 -1
  30. package/components/datetime-picker/datetime-picker.d.ts +2 -0
  31. package/components/datetime-picker/datetime-picker.yaml +14 -0
  32. package/components/empty-state/empty-state.class.js +2 -0
  33. package/components/feed/feed.class.js +13 -5
  34. package/components/feed/feed.css +14 -0
  35. package/components/index.js +9 -0
  36. package/components/integration-card/integration-card.class.js +9 -0
  37. package/components/integration-card/integration-card.test.js +4 -3
  38. package/components/nav-group/nav-group.css +7 -1
  39. package/components/number-format/number-format.a2ui.json +180 -0
  40. package/components/number-format/number-format.class.js +96 -0
  41. package/components/number-format/number-format.css +18 -0
  42. package/components/number-format/number-format.d.ts +68 -0
  43. package/components/number-format/number-format.js +17 -0
  44. package/components/number-format/number-format.yaml +204 -0
  45. package/components/pagination/pagination.a2ui.json +19 -2
  46. package/components/pagination/pagination.class.js +90 -37
  47. package/components/pagination/pagination.css +32 -127
  48. package/components/pagination/pagination.d.ts +8 -2
  49. package/components/pagination/pagination.test.js +195 -0
  50. package/components/pagination/pagination.yaml +22 -1
  51. package/components/password-strength/password-strength.a2ui.json +152 -0
  52. package/components/password-strength/password-strength.class.js +157 -0
  53. package/components/password-strength/password-strength.css +80 -0
  54. package/components/password-strength/password-strength.d.ts +59 -0
  55. package/components/password-strength/password-strength.js +17 -0
  56. package/components/password-strength/password-strength.yaml +153 -0
  57. package/components/popover/popover.css +43 -23
  58. package/components/popover/popover.yaml +8 -4
  59. package/components/qr-code/QR-TEST.svg +4 -0
  60. package/components/qr-code/qr-code.a2ui.json +154 -0
  61. package/components/qr-code/qr-code.class.js +129 -0
  62. package/components/qr-code/qr-code.css +41 -0
  63. package/components/qr-code/qr-code.d.ts +83 -0
  64. package/components/qr-code/qr-code.js +17 -0
  65. package/components/qr-code/qr-code.yaml +203 -0
  66. package/components/qr-code/qr-encoder.js +633 -0
  67. package/components/relative-time/relative-time.a2ui.json +120 -0
  68. package/components/relative-time/relative-time.class.js +136 -0
  69. package/components/relative-time/relative-time.css +22 -0
  70. package/components/relative-time/relative-time.d.ts +51 -0
  71. package/components/relative-time/relative-time.js +17 -0
  72. package/components/relative-time/relative-time.yaml +133 -0
  73. package/components/segmented/segmented.class.js +5 -1
  74. package/components/select/select.class.js +4 -0
  75. package/components/skip-nav/skip-nav.a2ui.json +92 -0
  76. package/components/skip-nav/skip-nav.class.js +45 -0
  77. package/components/skip-nav/skip-nav.css +54 -0
  78. package/components/skip-nav/skip-nav.d.ts +27 -0
  79. package/components/skip-nav/skip-nav.js +12 -0
  80. package/components/skip-nav/skip-nav.yaml +68 -0
  81. package/components/slider/slider.a2ui.json +16 -1
  82. package/components/slider/slider.class.js +264 -122
  83. package/components/slider/slider.css +82 -2
  84. package/components/slider/slider.d.ts +19 -3
  85. package/components/slider/slider.test.js +55 -0
  86. package/components/slider/slider.yaml +28 -6
  87. package/components/table/table.class.js +29 -6
  88. package/components/table/table.css +31 -4
  89. package/components/table-toolbar/table-toolbar.class.js +3 -1
  90. package/components/tag/tag.a2ui.json +3 -2
  91. package/components/tag/tag.css +35 -11
  92. package/components/tag/tag.d.ts +14 -0
  93. package/components/tag/tag.test.js +35 -11
  94. package/components/tag/tag.yaml +13 -7
  95. package/components/toast/toast.class.js +12 -4
  96. package/components/toc/toc.a2ui.json +159 -0
  97. package/components/toc/toc.class.js +222 -0
  98. package/components/toc/toc.css +92 -0
  99. package/components/toc/toc.d.ts +61 -0
  100. package/components/toc/toc.js +17 -0
  101. package/components/toc/toc.yaml +180 -0
  102. package/components/toolbar/toolbar.class.js +3 -0
  103. package/components/visually-hidden/visually-hidden.a2ui.json +71 -0
  104. package/components/visually-hidden/visually-hidden.class.js +14 -0
  105. package/components/visually-hidden/visually-hidden.css +25 -0
  106. package/components/visually-hidden/visually-hidden.d.ts +26 -0
  107. package/components/visually-hidden/visually-hidden.js +12 -0
  108. package/components/visually-hidden/visually-hidden.yaml +54 -0
  109. package/core/anchor.js +19 -3
  110. package/dist/web-components.min.css +1 -1
  111. package/dist/web-components.min.js +100 -89
  112. package/package.json +1 -1
  113. package/styles/colors/semantics.css +11 -2
  114. package/styles/components.css +9 -0
  115. package/styles/resets.css +10 -0
@@ -14,10 +14,14 @@
14
14
  /**
15
15
  * <slider-ui label="Width" value="63" min="0" max="200" step="1" suffix="rem"></slider-ui>
16
16
  *
17
- * Layout:
17
+ * Single-thumb layout:
18
18
  * [label] [value] [suffix]
19
19
  * [=====fill=====●─────────────────track───────────────────]
20
20
  *
21
+ * Dual-thumb layout (set [dual] + [lower-value] / [upper-value]):
22
+ * [label] [lower–upper] [suffix]
23
+ * [───────●═════════════════●──────────────────────────────]
24
+ *
21
25
  * The `label` attribute renders as a first-class in-component caption in
22
26
  * the slider header and is mirrored to `aria-label` on the host for
23
27
  * screen-reader announcement. Wrap in `<field-ui>` only when you need
@@ -35,24 +39,20 @@ export class UISlider extends UIFormElement {
35
39
 
36
40
  static properties = {
37
41
  ...UIFormElement.properties,
38
- /** value: Number — overrides UIFormElement.value (String); syncs as string on form submit. */
39
- value: { type: Number, default: 50, reflect: true },
42
+ /** value: Number — single-thumb mode; ignored when [dual]. */
43
+ value: { type: Number, default: 50, reflect: true },
44
+ /** dual: when true, two-thumb mode; lowerValue / upperValue authoritative. */
45
+ dual: { type: Boolean, default: false, reflect: true },
46
+ /** lowerValue: dual mode; clamped to ≤ upperValue. */
47
+ lowerValue: { type: Number, default: 0, reflect: true, attribute: 'lower-value' },
48
+ /** upperValue: dual mode; clamped to ≥ lowerValue. */
49
+ upperValue: { type: Number, default: 100, reflect: true, attribute: 'upper-value' },
40
50
  min: { type: Number, default: 0, reflect: true },
41
51
  max: { type: Number, default: 100, reflect: true },
42
52
  step: { type: Number, default: 1, reflect: true },
43
53
  label: { type: String, default: '', reflect: true },
44
54
  suffix: { type: String, default: '', reflect: true },
45
- // §184 (v0.5.5, FEEDBACK-08 §4) → §220 (v0.5.9, FEEDBACK-14 §3):
46
- // declarative trailing-debounce on `input` for expensive consumers
47
- // (palette regen, shader compile, large list reflow). When > 0,
48
- // value updates + visual feedback are immediate but `input` is
49
- // collapsed across the window. `change` fires unthrottled on
50
- // pointerup / track click / keyboard; any pending `input` flushes
51
- // BEFORE `change` so consumers always see
52
- // input→input→…→input→change ordering. throttle="0" (default)
53
- // preserves the pre-§184 every-pointer-move-fires-input behavior.
54
- // The mechanism graduated to UIFormElement at v0.5.9 §220 — slider
55
- // delegates via scheduleThrottledInput() + flushPendingInput().
55
+ // §184/§220 throttle inherited from UIFormElement.
56
56
  };
57
57
 
58
58
  static template = () => null;
@@ -62,12 +62,19 @@ export class UISlider extends UIFormElement {
62
62
 
63
63
  #trackEl = null;
64
64
  #thumbEl = null;
65
+ #thumbLowerEl = null;
66
+ #thumbUpperEl = null;
65
67
  #dragging = false;
68
+ #draggingThumb = null; // 'lower' | 'upper' | null (dual only)
66
69
  #dragOffset = 0;
70
+ // Previous-render values used by the dual-mode constraint to decide
71
+ // which thumb to clamp when lower > upper. NaN sentinel = first render.
72
+ #prevLowerValue = NaN;
73
+ #prevUpperValue = NaN;
67
74
 
68
- get #pct() {
75
+ #pctOf(v) {
69
76
  const range = this.max - this.min;
70
- return range > 0 ? ((this.value - this.min) / range) * 100 : 0;
77
+ return range > 0 ? (v - this.min) / range : 0;
71
78
  }
72
79
 
73
80
  #format(v) {
@@ -77,27 +84,42 @@ export class UISlider extends UIFormElement {
77
84
 
78
85
  connected() {
79
86
  super.connected();
80
- this.setAttribute('role', 'slider');
87
+ const isDual = this.dual;
88
+ this.setAttribute('role', isDual ? 'group' : 'slider');
81
89
  this.setAttribute('tabindex', '0');
82
- this.setAttribute('aria-valuemin', this.min);
83
- this.setAttribute('aria-valuemax', this.max);
90
+ if (!isDual) {
91
+ this.setAttribute('aria-valuemin', this.min);
92
+ this.setAttribute('aria-valuemax', this.max);
93
+ } else {
94
+ // Group host should not carry valuemin/max — the per-thumb sliders do.
95
+ this.removeAttribute('aria-valuemin');
96
+ this.removeAttribute('aria-valuemax');
97
+ }
84
98
  if (this.label) this.setAttribute('aria-label', this.label);
85
99
 
86
100
  if (!this.querySelector('[slot="track"]')) {
87
101
  // §184 (v0.5.5, FEEDBACK-08 §7): hint slot stamped underneath
88
102
  // the track when [hint] is set. Wired to aria-describedby below.
89
103
  const hintId = this.hint ? `slider-hint-${++UISlider.#hintSeq}` : '';
104
+ const lowerLabel = this.label ? `${this.label} lower bound` : 'Lower bound';
105
+ const upperLabel = this.label ? `${this.label} upper bound` : 'Upper bound';
106
+ const readout = isDual
107
+ ? `<span slot="value-lower">${this.#format(this.lowerValue)}</span><span slot="value-sep" aria-hidden="true">–</span><span slot="value-upper">${this.#format(this.upperValue)}</span>`
108
+ : `<span slot="value">${this.#format(this.value)}</span>`;
109
+ const thumbs = isDual
110
+ ? `<div slot="thumb" data-thumb="lower" tabindex="0" role="slider" aria-label="${lowerLabel}" aria-valuemin="${this.min}" aria-valuemax="${this.max}"></div><div slot="thumb" data-thumb="upper" tabindex="0" role="slider" aria-label="${upperLabel}" aria-valuemin="${this.min}" aria-valuemax="${this.max}"></div>`
111
+ : `<div slot="thumb" tabindex="0"></div>`;
90
112
  this.innerHTML = `
91
113
  <div slot="header">
92
114
  ${this.label ? `<span slot="label">${this.label}</span>` : ''}
93
115
  <span slot="readout">
94
- <span slot="value">${this.#format(this.value)}</span>
116
+ ${readout}
95
117
  ${this.suffix ? `<span slot="suffix">${this.suffix}</span>` : ''}
96
118
  </span>
97
119
  </div>
98
120
  <div slot="track">
99
121
  <div slot="fill"></div>
100
- <div slot="thumb" tabindex="0"></div>
122
+ ${thumbs}
101
123
  </div>
102
124
  ${this.hint ? `<span slot="hint" id="${hintId}">${this.hint}</span>` : ''}
103
125
  `;
@@ -105,32 +127,28 @@ export class UISlider extends UIFormElement {
105
127
  }
106
128
 
107
129
  this.#trackEl = this.querySelector('[slot="track"]');
108
- this.#thumbEl = this.querySelector('[slot="thumb"]');
130
+ if (isDual) {
131
+ this.#thumbLowerEl = this.querySelector('[slot="thumb"][data-thumb="lower"]');
132
+ this.#thumbUpperEl = this.querySelector('[slot="thumb"][data-thumb="upper"]');
133
+ this.#thumbLowerEl?.addEventListener('pointerdown', this.#onPointerDown);
134
+ this.#thumbUpperEl?.addEventListener('pointerdown', this.#onPointerDown);
135
+ } else {
136
+ this.#thumbEl = this.querySelector('[slot="thumb"]');
137
+ this.#thumbEl?.addEventListener('pointerdown', this.#onPointerDown);
138
+ }
109
139
 
110
- if (this.#thumbEl) this.#thumbEl.addEventListener('pointerdown', this.#onPointerDown);
111
- if (this.#trackEl) this.#trackEl.addEventListener('click', this.#onTrackClick);
140
+ this.#trackEl?.addEventListener('click', this.#onTrackClick);
112
141
  this.addEventListener('keydown', this.#onKey);
113
142
  }
114
143
 
115
144
  render() {
116
145
  if (!this.#trackEl) return;
117
146
 
118
- // §153 (v0.5.3): function-typed `.value` runtime guard. Per FEEDBACK-07
119
- // §3, consumers sometimes pass `() => signal.value * 100` expecting
120
- // auto-subscribe; AdiaUI's reactive system (template.js `isFn` branch)
121
- // does wrap functions in effects + call them per dep change, but the
122
- // result of `v()` must be a number for `#pct` / `#format` to work.
123
- // When the function returns non-number (e.g. doesn't read a signal,
124
- // returns object/string) OR when consumers bypass the template engine
125
- // (manual `sliderEl.value = someFunction`), `this.value` ends up as
126
- // the function itself — `#pct` does NaN math, thumb stays at 0%,
127
- // silent fail. Warn loudly + skip render so the bug class is
128
- // diagnosable in dev. See USAGE.md "Reactive binding" for the
129
- // documented patterns.
130
- if (typeof this.value === 'function') {
147
+ // §153 function-typed value guard (extended to dual props).
148
+ if (typeof this.value === 'function' || typeof this.lowerValue === 'function' || typeof this.upperValue === 'function') {
131
149
  // eslint-disable-next-line no-console
132
150
  console.warn(
133
- '[slider-ui] .value received a function. Did you mean:\n' +
151
+ '[slider-ui] .value / .lowerValue / .upperValue received a function. Did you mean:\n' +
134
152
  ' .value=${fn()} ← call the function to get the current value\n' +
135
153
  ' .value=${signal.value} ← read the signal\'s current value\n' +
136
154
  'Functions are not auto-invoked at render time; the slider reads\n' +
@@ -140,18 +158,56 @@ export class UISlider extends UIFormElement {
140
158
  return;
141
159
  }
142
160
 
143
- const pct = this.#pct;
144
- // Write progress to CSS custom property for pure-CSS positioning.
145
- // --slider-pct is a fraction (0.0–1.0) used in calc() alongside
146
- // --slider-travel so thumb + fill stay inside the track.
147
- this.style.setProperty('--slider-pct', String(pct / 100));
161
+ if (this.dual) {
162
+ // Bidirectional constraint: lower upper, but clamp the prop that
163
+ // was just changed (so direct programmatic prop assignment behaves
164
+ // intuitively in both directions).
165
+ // - lower bumped up past upper → clamp lower DOWN to upper
166
+ // - upper bumped down past lower → clamp upper UP to lower
167
+ // - both/neither (first render) → fall back to clamp lower DOWN
168
+ if (this.lowerValue > this.upperValue) {
169
+ const lowerChanged = !Number.isNaN(this.#prevLowerValue) && this.lowerValue !== this.#prevLowerValue;
170
+ const upperChanged = !Number.isNaN(this.#prevUpperValue) && this.upperValue !== this.#prevUpperValue;
171
+ if (upperChanged && !lowerChanged) {
172
+ this.upperValue = this.lowerValue;
173
+ } else {
174
+ this.lowerValue = this.upperValue;
175
+ }
176
+ }
177
+ this.#prevLowerValue = this.lowerValue;
178
+ this.#prevUpperValue = this.upperValue;
179
+ const pctL = this.#pctOf(this.lowerValue);
180
+ const pctU = this.#pctOf(this.upperValue);
181
+ this.style.setProperty('--slider-pct-lower', String(pctL));
182
+ this.style.setProperty('--slider-pct-upper', String(pctU));
148
183
 
149
- const valueEl = this.querySelector('[slot="value"]');
150
- if (valueEl) valueEl.textContent = this.#format(this.value);
184
+ const valueLEl = this.querySelector('[slot="value-lower"]');
185
+ const valueUEl = this.querySelector('[slot="value-upper"]');
186
+ if (valueLEl) valueLEl.textContent = this.#format(this.lowerValue);
187
+ if (valueUEl) valueUEl.textContent = this.#format(this.upperValue);
188
+
189
+ if (this.#thumbLowerEl) {
190
+ this.#thumbLowerEl.setAttribute('aria-valuenow', this.lowerValue);
191
+ this.#thumbLowerEl.setAttribute('aria-valuetext', `${this.#format(this.lowerValue)}${this.suffix ? ' ' + this.suffix : ''}`);
192
+ }
193
+ if (this.#thumbUpperEl) {
194
+ this.#thumbUpperEl.setAttribute('aria-valuenow', this.upperValue);
195
+ this.#thumbUpperEl.setAttribute('aria-valuetext', `${this.#format(this.upperValue)}${this.suffix ? ' ' + this.suffix : ''}`);
196
+ }
197
+ this.syncValue(`${this.lowerValue},${this.upperValue}`);
198
+ } else {
199
+ const pct = this.#pctOf(this.value);
200
+ this.style.setProperty('--slider-pct', String(pct));
201
+
202
+ const valueEl = this.querySelector('[slot="value"]');
203
+ if (valueEl) valueEl.textContent = this.#format(this.value);
204
+
205
+ this.setAttribute('aria-valuenow', this.value);
206
+ this.setAttribute('aria-valuetext', `${this.#format(this.value)}${this.suffix ? ' ' + this.suffix : ''}`);
207
+ this.syncValue(String(this.value));
208
+ }
151
209
 
152
- // §FB-45: label and suffix are read in connected() but may be set later
153
- // by the template engine (which first writes {{p:N}} then resolves).
154
- // Re-sync both slots on every render so reactive bindings work.
210
+ // §FB-45: label + suffix late-binding re-sync (shared across modes).
155
211
  const labelEl = this.querySelector('[slot="label"]');
156
212
  if (labelEl) {
157
213
  labelEl.textContent = this.label;
@@ -163,44 +219,68 @@ export class UISlider extends UIFormElement {
163
219
 
164
220
  const suffixEl = this.querySelector('[slot="suffix"]');
165
221
  if (suffixEl) suffixEl.textContent = this.suffix;
166
-
167
- this.setAttribute('aria-valuenow', this.value);
168
- this.setAttribute('aria-valuetext', `${this.#format(this.value)}${this.suffix ? ' ' + this.suffix : ''}`);
169
- this.syncValue(String(this.value));
170
222
  }
171
223
 
172
224
  /**
173
225
  * Inverse geometry: given a *desired* thumb-center viewport-x, compute
174
- * the slider value such that the thumb center lands exactly at that
175
- * coordinate (clamped at the min/max extremes).
226
+ * the slider value such that the named thumb's visual center lands at
227
+ * that coordinate (clamped to min/max).
176
228
  *
177
- * Forward geometry (in slider.css):
178
- * thumb_center(p) = t/2 + p · (W − t)
229
+ * Forward geometry differs by mode (see slider.css):
230
+ * single → center = t/2 + p · (W − t)
231
+ * dual lower (data-thumb=lower) → center = t/2 + p · (W − 2t)
232
+ * dual upper (data-thumb=upper) → center = 1.5t + p · (W − 2t)
179
233
  *
180
- * Inverse (solve for p, clamped):
181
- * p = clamp01((target t/2) / (W t))
234
+ * The dual model reserves 2t of travel-space so the two thumbs can
235
+ * never overlap they touch edge-to-edge at equal values and span the
236
+ * full track at opposite extremes.
182
237
  *
183
- * Two call paths share this:
184
- * #onTrackClick clientX is the click position; thumb center lands
185
- * under the cursor (or snaps to the t/2 end-zone when clicked beyond).
186
- * • #onPointerMove — (clientX − dragOffset) is the *intended* thumb
187
- * center (offset preserves where the user originally grabbed the
188
- * thumb, so dragging feels relative rather than snap-to-cursor).
238
+ * @param {number} clientX viewport x of the desired thumb center
239
+ * @param {'lower'|'upper'|null} which which thumb's geometry to invert
240
+ * (null = single-thumb mode); ignored when this.dual is false
189
241
  */
190
- #valueFromX(clientX) {
191
- if (!this.#trackEl || !this.#thumbEl) return this.min;
242
+ #valueFromX(clientX, which = null) {
243
+ const refThumb = this.#thumbEl || this.#thumbLowerEl || this.#thumbUpperEl;
244
+ if (!this.#trackEl || !refThumb) return this.min;
192
245
  const trackRect = this.#trackEl.getBoundingClientRect();
193
- const thumbRect = this.#thumbEl.getBoundingClientRect();
246
+ const t = refThumb.getBoundingClientRect().width;
194
247
  const W = trackRect.width;
195
- const t = thumbRect.width;
196
- const travel = W - t;
248
+ let travel, offset;
249
+ if (this.dual) {
250
+ travel = W - 2 * t;
251
+ offset = which === 'upper' ? 1.5 * t : 0.5 * t;
252
+ } else {
253
+ travel = W - t;
254
+ offset = 0.5 * t;
255
+ }
197
256
  if (travel <= 0) return this.min;
198
- const target = clientX - trackRect.left; // desired thumb-center, track-relative
199
- const ratio = Math.max(0, Math.min(1, (target - t / 2) / travel));
257
+ const target = clientX - trackRect.left;
258
+ const ratio = Math.max(0, Math.min(1, (target - offset) / travel));
200
259
  const raw = this.min + ratio * (this.max - this.min);
201
260
  return this.#snap(raw);
202
261
  }
203
262
 
263
+ // Visual-center of the named thumb in viewport coords. Used by drag
264
+ // logic to capture the cursor offset relative to the thumb's logical
265
+ // (transition-immune) center. Mirrors the forward geometry in
266
+ // slider.css per-mode.
267
+ #centerOfThumb(which) {
268
+ if (!this.#trackEl) return 0;
269
+ const refThumb = this.#thumbEl || this.#thumbLowerEl || this.#thumbUpperEl;
270
+ if (!refThumb) return 0;
271
+ const trackRect = this.#trackEl.getBoundingClientRect();
272
+ const t = refThumb.getBoundingClientRect().width;
273
+ const W = trackRect.width;
274
+ if (this.dual) {
275
+ const travel = W - 2 * t;
276
+ const p = which === 'upper' ? this.#pctOf(this.upperValue) : this.#pctOf(this.lowerValue);
277
+ const offset = which === 'upper' ? 1.5 * t : 0.5 * t;
278
+ return trackRect.left + offset + p * travel;
279
+ } else {
280
+ return trackRect.left + 0.5 * t + this.#pctOf(this.value) * (W - t);
281
+ }
282
+ }
283
+
204
284
  #snap(raw) {
205
285
  const stepped = Math.round((raw - this.min) / this.step) * this.step + this.min;
206
286
  return Math.max(this.min, Math.min(this.max, parseFloat(stepped.toFixed(10))));
@@ -209,98 +289,160 @@ export class UISlider extends UIFormElement {
209
289
  #setValue(v) {
210
290
  if (v === this.value) return;
211
291
  this.value = v;
212
- // §220 (v0.5.9): delegate to UIFormElement's shared throttle helper.
213
- // When `this.throttle > 0`, the dispatch is trailing-debounced
214
- // (collapses pointer-move bursts to one emission). When 0, fires
215
- // synchronously.
216
292
  this.scheduleThrottledInput();
217
293
  }
218
294
 
295
+ #setLowerValue(v) {
296
+ const clamped = Math.min(Math.max(v, this.min), this.upperValue);
297
+ if (clamped === this.lowerValue) return;
298
+ this.lowerValue = clamped;
299
+ this.scheduleThrottledInput();
300
+ }
301
+
302
+ #setUpperValue(v) {
303
+ const clamped = Math.max(Math.min(v, this.max), this.lowerValue);
304
+ if (clamped === this.upperValue) return;
305
+ this.upperValue = clamped;
306
+ this.scheduleThrottledInput();
307
+ }
308
+
309
+ #emitChange() {
310
+ const detail = this.dual
311
+ ? { lower: this.lowerValue, upper: this.upperValue }
312
+ : { value: this.value };
313
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail }));
314
+ }
315
+
219
316
  #onPointerDown = (e) => {
220
317
  if (this.disabled) return;
221
318
  e.preventDefault();
222
319
  this.#dragging = true;
223
320
  this.setAttribute('data-dragging', '');
224
- // Capture the offset between the click point and the thumb's
225
- // *logical* center (derived from `this.value` via the same forward
226
- // equation the CSS uses). We deliberately do NOT read the thumb's
227
- // bounding rect here — the CSS `left` transition can leave the
228
- // physical rect mid-animation between values, producing a stale
229
- // offset that would translate into a snap on the first move.
230
- // Logical center is transition-immune and matches what #valueFromX
231
- // inverts.
232
- if (this.#trackEl && this.#thumbEl) {
233
- const trackRect = this.#trackEl.getBoundingClientRect();
234
- const thumbRect = this.#thumbEl.getBoundingClientRect();
235
- const W = trackRect.width;
236
- const t = thumbRect.width;
237
- const travel = W - t;
238
- const range = this.max - this.min;
239
- const p = range > 0 ? (this.value - this.min) / range : 0;
240
- const logicalCenter = trackRect.left + t / 2 + p * travel;
241
- this.#dragOffset = e.clientX - logicalCenter;
321
+
322
+ let thumb;
323
+ if (this.dual) {
324
+ const which = e.currentTarget?.dataset?.thumb;
325
+ this.#draggingThumb = which === 'upper' ? 'upper' : 'lower';
326
+ thumb = this.#draggingThumb === 'lower' ? this.#thumbLowerEl : this.#thumbUpperEl;
327
+ // Lift the dragged thumb above its sibling so it stays visually
328
+ // anchored even when both thumbs land at the same value.
329
+ thumb?.setAttribute('data-active', '');
330
+ } else {
331
+ thumb = this.#thumbEl;
332
+ }
333
+
334
+ if (this.#trackEl && thumb) {
335
+ // Logical center derived from the forward geometry (transition-immune),
336
+ // not from getBoundingClientRect which can be mid-animation.
337
+ const which = this.dual ? this.#draggingThumb : null;
338
+ this.#dragOffset = e.clientX - this.#centerOfThumb(which);
242
339
  } else {
243
340
  this.#dragOffset = 0;
244
341
  }
245
- this.#thumbEl.setPointerCapture(e.pointerId);
246
- this.#thumbEl.addEventListener('pointermove', this.#onPointerMove);
247
- this.#thumbEl.addEventListener('pointerup', this.#onPointerUp);
342
+
343
+ thumb.setPointerCapture(e.pointerId);
344
+ thumb.addEventListener('pointermove', this.#onPointerMove);
345
+ thumb.addEventListener('pointerup', this.#onPointerUp);
248
346
  };
249
347
 
250
348
  #onPointerMove = (e) => {
251
349
  if (!this.#dragging) return;
252
- // Subtract the captured offset so the thumb center tracks the
253
- // cursor relative to where the user originally pressed, avoiding
254
- // the initial snap.
255
- this.#setValue(this.#valueFromX(e.clientX - this.#dragOffset));
350
+ const adjustedX = e.clientX - this.#dragOffset;
351
+ if (this.dual) {
352
+ const v = this.#valueFromX(adjustedX, this.#draggingThumb);
353
+ if (this.#draggingThumb === 'lower') this.#setLowerValue(v);
354
+ else this.#setUpperValue(v);
355
+ } else {
356
+ this.#setValue(this.#valueFromX(adjustedX));
357
+ }
256
358
  };
257
359
 
258
360
  #onPointerUp = (e) => {
259
361
  this.#dragging = false;
260
362
  this.#dragOffset = 0;
261
363
  this.removeAttribute('data-dragging');
262
- this.#thumbEl.releasePointerCapture(e.pointerId);
263
- this.#thumbEl.removeEventListener('pointermove', this.#onPointerMove);
264
- this.#thumbEl.removeEventListener('pointerup', this.#onPointerUp);
364
+ const thumb = this.dual
365
+ ? (this.#draggingThumb === 'lower' ? this.#thumbLowerEl : this.#thumbUpperEl)
366
+ : this.#thumbEl;
367
+ thumb?.releasePointerCapture(e.pointerId);
368
+ thumb?.removeEventListener('pointermove', this.#onPointerMove);
369
+ thumb?.removeEventListener('pointerup', this.#onPointerUp);
370
+ // Drop the active marker — but only after emitting change, so any
371
+ // mid-drag z-index lift stays painted through the up event.
372
+ thumb?.removeAttribute('data-active');
265
373
  this.flushPendingInput(); // §220 (was §184 #flushInput): pending throttled input fires before change
266
- this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
374
+ this.#emitChange();
375
+ this.#draggingThumb = null;
267
376
  };
268
377
 
269
378
  #onTrackClick = (e) => {
270
- if (this.disabled || e.target === this.#thumbEl) return;
271
- this.#setValue(this.#valueFromX(e.clientX));
272
- this.flushPendingInput(); // §220 (was §184 #flushInput): ensure trailing input precedes change
273
- this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
379
+ if (this.disabled) return;
380
+ if (this.dual) {
381
+ if (e.target === this.#thumbLowerEl || e.target === this.#thumbUpperEl) return;
382
+ // Pick the thumb closer to the click in pixel space (using each
383
+ // thumb's own forward-geometry center), then re-invert with that
384
+ // thumb's geometry so the chosen thumb lands precisely under the
385
+ // cursor.
386
+ const distLower = Math.abs(e.clientX - this.#centerOfThumb('lower'));
387
+ const distUpper = Math.abs(e.clientX - this.#centerOfThumb('upper'));
388
+ if (distLower <= distUpper) {
389
+ this.#setLowerValue(this.#valueFromX(e.clientX, 'lower'));
390
+ } else {
391
+ this.#setUpperValue(this.#valueFromX(e.clientX, 'upper'));
392
+ }
393
+ } else {
394
+ if (e.target === this.#thumbEl) return;
395
+ this.#setValue(this.#valueFromX(e.clientX));
396
+ }
397
+ this.flushPendingInput();
398
+ this.#emitChange();
274
399
  };
275
400
 
276
401
  #onKey = (e) => {
277
402
  if (this.disabled) return;
278
- let v = this.value;
403
+ let delta = 0;
404
+ let absolute = null;
279
405
  switch (e.key) {
280
- case 'ArrowRight': case 'ArrowUp': v += this.step; break;
281
- case 'ArrowLeft': case 'ArrowDown': v -= this.step; break;
282
- case 'Home': v = this.min; break;
283
- case 'End': v = this.max; break;
284
- case 'PageUp': v += this.step * 10; break;
285
- case 'PageDown': v -= this.step * 10; break;
406
+ case 'ArrowRight': case 'ArrowUp': delta = this.step; break;
407
+ case 'ArrowLeft': case 'ArrowDown': delta = -this.step; break;
408
+ case 'Home': absolute = this.min; break;
409
+ case 'End': absolute = this.max; break;
410
+ case 'PageUp': delta = this.step * 10; break;
411
+ case 'PageDown': delta = -this.step * 10; break;
286
412
  default: return;
287
413
  }
288
414
  e.preventDefault();
289
- this.#setValue(this.#snap(v));
290
- this.flushPendingInput(); // §220 (was §184 #flushInput): trailing input fires before change
291
- this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
415
+
416
+ if (this.dual) {
417
+ // Move whichever thumb is focused. Default to lower when neither.
418
+ const active = this.ownerDocument?.activeElement;
419
+ const targetThumb = active === this.#thumbUpperEl ? 'upper' : 'lower';
420
+ const current = targetThumb === 'lower' ? this.lowerValue : this.upperValue;
421
+ let next = absolute !== null ? absolute : current + delta;
422
+ next = this.#snap(next);
423
+ if (targetThumb === 'lower') this.#setLowerValue(next);
424
+ else this.#setUpperValue(next);
425
+ } else {
426
+ let v = absolute !== null ? absolute : this.value + delta;
427
+ this.#setValue(this.#snap(v));
428
+ }
429
+ this.flushPendingInput();
430
+ this.#emitChange();
292
431
  };
293
432
 
294
433
  disconnected() {
295
434
  super.disconnected();
296
435
  this.#thumbEl?.removeEventListener('pointerdown', this.#onPointerDown);
436
+ this.#thumbLowerEl?.removeEventListener('pointerdown', this.#onPointerDown);
437
+ this.#thumbUpperEl?.removeEventListener('pointerdown', this.#onPointerDown);
297
438
  this.#trackEl?.removeEventListener('click', this.#onTrackClick);
298
- this.#thumbEl?.removeEventListener('pointermove', this.#onPointerMove);
299
- this.#thumbEl?.removeEventListener('pointerup', this.#onPointerUp);
439
+ // Pointermove/up listeners are attached transiently per-drag to the
440
+ // active thumb; #onPointerUp removes them. If disconnect happens
441
+ // mid-drag the browser tears down listeners with the element anyway.
300
442
  this.removeEventListener('keydown', this.#onKey);
301
- // §220 (v0.5.9): UIFormElement.disconnected() auto-drops the pending
302
- // throttled input dispatch via super.disconnected().
303
443
  this.#trackEl = null;
304
444
  this.#thumbEl = null;
445
+ this.#thumbLowerEl = null;
446
+ this.#thumbUpperEl = null;
305
447
  }
306
448
  }
@@ -86,13 +86,17 @@
86
86
  /* Progress fraction (0.0 → 1.0) written by JS. */
87
87
  --slider-pct-default: 0;
88
88
 
89
+ /* Dual-thumb progress fractions (0.0 → 1.0) written by JS when [dual]. */
90
+ --slider-pct-lower-default: 0;
91
+ --slider-pct-upper-default: 1;
92
+
89
93
  /* ── Colors ──
90
94
  Track: dim recessed surface | Fill: primary | Thumb: white chrome */
91
95
  --slider-track-bg-default: var(--a-bg-muted);
92
96
  --slider-fill-bg-default: var(--a-primary-bg);
93
97
  --slider-thumb-bg-default: var(--a-chrome-light);
94
- --slider-fill-bg-disabled-default: var(--a-border-subtle);
95
- --slider-thumb-bg-disabled-default: var(--a-fg-muted);
98
+ --slider-fill-bg-disabled-default: var(--a-canvas-1-scrim);
99
+ --slider-thumb-bg-disabled-default: var(--a-canvas-2-scrim);
96
100
 
97
101
  /* ── Typography ── */
98
102
  --slider-font-size-default: var(--a-ui-size);
@@ -172,6 +176,43 @@
172
176
  + var(--slider-pct, var(--slider-pct-default)) * (100% - var(--slider-thumb-width, var(--slider-thumb-width-default))));
173
177
  }
174
178
 
179
+ /* Dual-thumb geometry: each thumb has an EFFECTIVE travel of (W − 2t),
180
+ not (W − t) — the reservation of 2t guarantees the thumbs never
181
+ overlap. At equal values they touch edge-to-edge; at extremes the
182
+ gap between thumb visual-edges represents 100% of the value range.
183
+
184
+ Forward equations (track-relative pixel positions):
185
+ lower_visual_left (p_l) = p_l · (W − 2t)
186
+ lower_visual_right (p_l) = t + p_l · (W − 2t)
187
+ upper_visual_left (p_u) = t + p_u · (W − 2t)
188
+ upper_visual_right (p_u) = 2t + p_u · (W − 2t)
189
+
190
+ CSS thumb `left` (visual center, since transform: translate(-50%)):
191
+ lower center = t/2 + p_l · (W − 2t)
192
+ upper center = 1.5t + p_u · (W − 2t)
193
+
194
+ Fill (lower-edge → upper-edge):
195
+ fill_left = p_l · (W − 2t)
196
+ fill_width = 2t + (p_u − p_l) · (W − 2t)
197
+
198
+ Sanity:
199
+ p_l = p_u → fill_width = 2t (both thumbs touching)
200
+ p_l = 0, p_u = 1 → fill_width = W (full track filled)
201
+ p_l = 0.5, p_u = 0.5 → pair centered at W/2 (thumbs touch at midpoint)
202
+ p_l = 0.2, p_u = 0.8, W=716, t=36 →
203
+ travel = W − 2t = 644
204
+ fill_left = 0.2·644 = 128.8
205
+ fill_width = 72 + 0.6·644 = 458.4
206
+ fill_right = 587.2 (= upper visual right) */
207
+ :scope[dual] [slot="fill"] {
208
+ left: calc(var(--slider-pct-lower, var(--slider-pct-lower-default))
209
+ * (100% - 2 * var(--slider-thumb-width, var(--slider-thumb-width-default))));
210
+ width: calc(2 * var(--slider-thumb-width, var(--slider-thumb-width-default))
211
+ + (var(--slider-pct-upper, var(--slider-pct-upper-default))
212
+ - var(--slider-pct-lower, var(--slider-pct-lower-default)))
213
+ * (100% - 2 * var(--slider-thumb-width, var(--slider-thumb-width-default))));
214
+ }
215
+
175
216
  /* Thumb CONTAINER: full track height, geometry width.
176
217
  Transparent background; the white pill is rendered by ::before.
177
218
  This element provides a generous vertical grab area. */
@@ -211,6 +252,45 @@
211
252
  transition: transform var(--slider-duration, var(--slider-duration-default)) var(--slider-easing, var(--slider-easing-default));
212
253
  }
213
254
 
255
+ /* Dual-mode thumb positions: each thumb travels along (W − 2t) so they
256
+ can never overlap. Lower's CSS center starts at t/2 (visual left
257
+ edge at 0); upper's CSS center starts at 1.5t (visual left edge at
258
+ t, leaving room for lower). At equal values the two thumbs touch
259
+ edge-to-edge — the 2t reservation between them is exactly the
260
+ combined thumb-width.
261
+
262
+ z-index nudges keep both thumbs paintable at boundary positions.
263
+ The "active" (currently-dragging) thumb lifts above its sibling. */
264
+ :scope[dual] [slot="thumb"][data-thumb="lower"] {
265
+ left: calc(var(--slider-thumb-width, var(--slider-thumb-width-default)) / 2
266
+ + var(--slider-pct-lower, var(--slider-pct-lower-default))
267
+ * (100% - 2 * var(--slider-thumb-width, var(--slider-thumb-width-default))));
268
+ z-index: 2;
269
+ }
270
+ :scope[dual] [slot="thumb"][data-thumb="upper"] {
271
+ left: calc(var(--slider-thumb-width, var(--slider-thumb-width-default)) * 1.5
272
+ + var(--slider-pct-upper, var(--slider-pct-upper-default))
273
+ * (100% - 2 * var(--slider-thumb-width, var(--slider-thumb-width-default))));
274
+ z-index: 3;
275
+ }
276
+ /* When the user is dragging a thumb, lift it above its sibling so the
277
+ dragged one stays visually anchored even if they cross. */
278
+ :scope[dual] [slot="thumb"][data-active] {
279
+ z-index: 4;
280
+ }
281
+
282
+ /* Dual-mode readout: separator + numeric pair */
283
+ :scope[dual] [slot="value-sep"] {
284
+ color: var(--a-fg-muted);
285
+ padding: 0 var(--a-space-0-5);
286
+ }
287
+ :scope[dual] [slot="value-lower"],
288
+ :scope[dual] [slot="value-upper"] {
289
+ color: var(--a-fg);
290
+ font-weight: var(--slider-value-weight, var(--slider-value-weight-default));
291
+ font-variant-numeric: tabular-nums;
292
+ }
293
+
214
294
  [slot="thumb"]:hover {
215
295
  transform: translate(-50%, -50%) scale(1.05);
216
296
  }