@joewinke/jatui 0.8.3 → 0.8.5

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.
@@ -32,6 +32,7 @@
32
32
  dropup = false,
33
33
  appendToBody = false,
34
34
  clearable = false,
35
+ clearLabel = 'Clear selection',
35
36
  triggerAttrs,
36
37
  embedded = false,
37
38
  onChange,
@@ -57,6 +58,8 @@
57
58
  appendToBody?: boolean;
58
59
  /** Show an × button on the trigger when a value is selected */
59
60
  clearable?: boolean;
61
+ /** Accessible label for the clear (×) button. */
62
+ clearLabel?: string;
60
63
  /** Extra attributes forwarded to the trigger button (e.g. data-rail-id) */
61
64
  triggerAttrs?: Record<string, string>;
62
65
  /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
@@ -194,6 +197,35 @@
194
197
  }
195
198
  }
196
199
 
200
+ function positionPanel() {
201
+ if (!triggerRef) return;
202
+ const rect = triggerRef.getBoundingClientRect();
203
+ const width = Math.max(rect.width, 192);
204
+ // Viewport-aware flip: if there isn't room below the trigger but there
205
+ // is above, anchor the panel to the trigger's top and grow upward. This
206
+ // keeps the panel on-screen for rows near the bottom of a scroll area
207
+ // (e.g. suggested-task rows in the completed-session overlay).
208
+ const estPanelHeight = 300;
209
+ const spaceBelow = window.innerHeight - rect.bottom;
210
+ const spaceAbove = rect.top;
211
+ const flipUp = spaceBelow < estPanelHeight && spaceAbove > spaceBelow;
212
+ // Clamp left so a right-edge trigger doesn't push the panel off-screen.
213
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
214
+ // .sd-panel's class rule always sets `top: 100%`, so each branch must
215
+ // explicitly reset the property it isn't using — otherwise the flip-up
216
+ // branch ends up with both top (100% = viewport height) and bottom set
217
+ // while height is auto, which over-constrains to a negative (clamped
218
+ // to 0) height and the whole panel silently renders invisible.
219
+ const vertical = flipUp
220
+ ? `bottom: ${window.innerHeight - rect.top + 4}px; top: auto;`
221
+ : `top: ${rect.bottom + 4}px; bottom: auto;`;
222
+ // min-width override is required: .sd-panel's class rule sets
223
+ // `min-width: max(100%, 14rem)`, and for a position:fixed element
224
+ // that 100% resolves against the viewport, not the trigger — without
225
+ // this override the panel balloons to ~100vw wide.
226
+ panelStyle = `position: fixed; z-index: 9999; ${vertical} left: ${left}px; width: ${width}px; min-width: ${width}px; margin-top: 0;`;
227
+ }
228
+
197
229
  $effect(() => {
198
230
  if (embedded) {
199
231
  tick().then(() => { searchInput?.focus(); scrollHighlightedIntoView(); });
@@ -201,31 +233,26 @@
201
233
  }
202
234
  if (open) {
203
235
  if (appendToBody && triggerRef) {
204
- const rect = triggerRef.getBoundingClientRect();
205
- const width = Math.max(rect.width, 192);
206
- // Viewport-aware flip: if there isn't room below the trigger but there
207
- // is above, anchor the panel to the trigger's top and grow upward. This
208
- // keeps the panel on-screen for rows near the bottom of a scroll area
209
- // (e.g. suggested-task rows in the completed-session overlay).
210
- const estPanelHeight = 300;
211
- const spaceBelow = window.innerHeight - rect.bottom;
212
- const spaceAbove = rect.top;
213
- const flipUp = spaceBelow < estPanelHeight && spaceAbove > spaceBelow;
214
- // Clamp left so a right-edge trigger doesn't push the panel off-screen.
215
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
216
- // .sd-panel's class rule always sets `top: 100%`, so each branch must
217
- // explicitly reset the property it isn't using — otherwise the flip-up
218
- // branch ends up with both top (100% = viewport height) and bottom set
219
- // while height is auto, which over-constrains to a negative (clamped
220
- // to 0) height and the whole panel silently renders invisible.
221
- const vertical = flipUp
222
- ? `bottom: ${window.innerHeight - rect.top + 4}px; top: auto;`
223
- : `top: ${rect.bottom + 4}px; bottom: auto;`;
224
- // min-width override is required: .sd-panel's class rule sets
225
- // `min-width: max(100%, 14rem)`, and for a position:fixed element
226
- // that 100% resolves against the viewport, not the trigger — without
227
- // this override the panel balloons to ~100vw wide.
228
- panelStyle = `position: fixed; z-index: 9999; ${vertical} left: ${left}px; width: ${width}px; min-width: ${width}px; margin-top: 0;`;
236
+ positionPanel();
237
+ // A position:fixed panel is anchored to the VIEWPORT, so without this it
238
+ // stays put while the page scrolls and appears to follow the reader down
239
+ // the page (Joe UAT 2026-09-25). Re-anchor on every scroll (capture: any
240
+ // scroll container) and resize; close once the trigger leaves the view.
241
+ const reanchor = (e?: Event) => {
242
+ if (e && e.target instanceof Node && containerRef?.contains(e.target)) return;
243
+ const r = triggerRef?.getBoundingClientRect();
244
+ if (!r || r.bottom < 0 || r.top > window.innerHeight) { open = false; searchQuery = ''; return; }
245
+ positionPanel();
246
+ };
247
+ window.addEventListener('scroll', reanchor, { capture: true, passive: true });
248
+ window.addEventListener('resize', reanchor, { passive: true });
249
+ document.addEventListener('mousedown', handleClickOutside);
250
+ tick().then(() => { searchInput?.focus(); scrollHighlightedIntoView(); });
251
+ return () => {
252
+ window.removeEventListener('scroll', reanchor, { capture: true });
253
+ window.removeEventListener('resize', reanchor);
254
+ document.removeEventListener('mousedown', handleClickOutside);
255
+ };
229
256
  }
230
257
  document.addEventListener('mousedown', handleClickOutside);
231
258
  tick().then(() => { searchInput?.focus(); scrollHighlightedIntoView(); });
@@ -349,6 +376,7 @@
349
376
  class:sd-disabled={disabled}
350
377
  class:sd-chip={variant === 'chip'}
351
378
  class:sd-sm={size === 'sm'}
379
+ class:sd-has-clear={clearable && value !== ''}
352
380
  style={activeColor
353
381
  ? variant === 'chip'
354
382
  ? `--sd-color: ${activeColor};`
@@ -371,20 +399,23 @@
371
399
  {/if}
372
400
  <span class="truncate">{triggerLabel}</span>
373
401
  </span>
374
- {#if clearable && value !== ''}
375
- <button
376
- type="button"
377
- class="sd-clear"
378
- aria-label="Clear filter"
379
- onclick={(e) => { e.stopPropagation(); onChange(''); }}
380
- >
381
- <svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
382
- </button>
383
- {/if}
384
402
  <svg class="sd-chevron" class:sd-chevron-open={open} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
385
403
  <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
386
404
  </svg>
387
405
  </button>
406
+ <!-- The clear control is a SIBLING of the trigger (a <button> inside a
407
+ <button> is invalid HTML and unreachable by keyboard), pinned to the
408
+ trigger's right edge beside the chevron (mos-hhzd0). -->
409
+ {#if clearable && value !== ''}
410
+ <button
411
+ type="button"
412
+ class="sd-clear"
413
+ aria-label={clearLabel}
414
+ onclick={(e) => { e.stopPropagation(); onChange(''); }}
415
+ >
416
+ <svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
417
+ </button>
418
+ {/if}
388
419
 
389
420
  {#if open}
390
421
  <div
@@ -494,6 +525,7 @@
494
525
  }
495
526
 
496
527
  .sd-trigger-label {
528
+ flex: 1 1 auto;
497
529
  display: flex;
498
530
  align-items: center;
499
531
  gap: 0.375rem;
@@ -520,7 +552,16 @@
520
552
  font-size: 0.75rem;
521
553
  }
522
554
 
555
+ /* Room for the absolutely-positioned × between the label and the chevron
556
+ (the chevron stays at the trigger's right edge). */
557
+ .sd-has-clear .sd-trigger-label {
558
+ padding-right: 1.25rem;
559
+ }
523
560
  .sd-clear {
561
+ position: absolute;
562
+ top: 50%;
563
+ right: 1.5rem;
564
+ transform: translateY(-50%);
524
565
  display: flex;
525
566
  align-items: center;
526
567
  justify-content: center;
@@ -29,6 +29,8 @@ type $$ComponentProps = {
29
29
  appendToBody?: boolean;
30
30
  /** Show an × button on the trigger when a value is selected */
31
31
  clearable?: boolean;
32
+ /** Accessible label for the clear (×) button. */
33
+ clearLabel?: string;
32
34
  /** Extra attributes forwarded to the trigger button (e.g. data-rail-id) */
33
35
  triggerAttrs?: Record<string, string>;
34
36
  /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",