@r2digisolutions/components 0.9.3 → 0.9.4

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,4 +1,7 @@
1
1
  <script lang="ts">
2
+ import { on } from 'svelte/events';
3
+ import { createId } from '../../../utils/id.js';
4
+
2
5
  export type TimeFormat = '24h' | '12h';
3
6
 
4
7
  interface TimePickerProps {
@@ -33,9 +36,17 @@
33
36
  onchange
34
37
  }: TimePickerProps = $props();
35
38
 
36
- let rootEl = $state<HTMLDivElement | null>(null);
39
+ let triggerEl = $state<HTMLElement | null>(null);
40
+ let panelEl = $state<HTMLDivElement | null>(null);
37
41
  let hourListEl = $state<HTMLDivElement | null>(null);
38
42
  let minuteListEl = $state<HTMLDivElement | null>(null);
43
+ /** Hide the native popover until top/left are applied (UA default is 0,0). */
44
+ let placed = $state(false);
45
+ let panelId = $state('');
46
+
47
+ $effect(() => {
48
+ panelId ||= createId('timepicker');
49
+ });
39
50
 
40
51
  const hours24 = $derived(Array.from({ length: 24 }, (_, i) => i));
41
52
  const minutes = $derived(
@@ -87,11 +98,122 @@
87
98
  return false;
88
99
  }
89
100
 
101
+ function positionPanel(opts: { measure?: boolean; show?: boolean } = {}) {
102
+ if (!triggerEl) return;
103
+ if (opts.show) placed = true;
104
+
105
+ const trigger = triggerEl.getBoundingClientRect();
106
+ if (trigger.width < 2 && trigger.height < 2) return;
107
+
108
+ const vv = window.visualViewport;
109
+ const viewW = vv?.width ?? window.innerWidth;
110
+ const viewH = vv?.height ?? window.innerHeight;
111
+ const viewLeft = vv?.offsetLeft ?? 0;
112
+ const viewTop = vv?.offsetTop ?? 0;
113
+ const gap = 8;
114
+ const pad = 8;
115
+ const estimated = { panelW: Math.max(trigger.width, 192), panelH: 280 };
116
+ const canMeasure = opts.measure !== false && !!panelEl?.matches(':popover-open');
117
+ const panelW = Math.min(
118
+ canMeasure && panelEl?.offsetWidth ? panelEl.offsetWidth : estimated.panelW,
119
+ viewW - pad * 2
120
+ );
121
+ const panelH = Math.min(
122
+ canMeasure && panelEl?.offsetHeight ? panelEl.offsetHeight : estimated.panelH,
123
+ 320
124
+ );
125
+
126
+ const spaceBelow = viewTop + viewH - trigger.bottom - gap - pad;
127
+ const spaceAbove = trigger.top - viewTop - gap - pad;
128
+ const side: 'top' | 'bottom' =
129
+ spaceBelow < panelH && spaceAbove > spaceBelow ? 'top' : 'bottom';
130
+
131
+ let top = side === 'bottom' ? trigger.bottom + gap : trigger.top - panelH - gap;
132
+ let left = trigger.left;
133
+
134
+ left = Math.min(Math.max(viewLeft + pad, left), viewLeft + viewW - panelW - pad);
135
+ top = Math.min(Math.max(viewTop + pad, top), viewTop + viewH - panelH - pad);
136
+
137
+ // Imperative styles in `beforetoggle`: a Svelte `style={}` binding
138
+ // flushes too late and the UA popover paints at 0,0 first.
139
+ if (panelEl) {
140
+ const s = panelEl.style;
141
+ s.setProperty('margin', '0');
142
+ s.setProperty('inset', 'auto');
143
+ s.setProperty('top', `${Math.round(top)}px`);
144
+ s.setProperty('left', `${Math.round(left)}px`);
145
+ s.setProperty('right', 'auto');
146
+ s.setProperty('bottom', 'auto');
147
+ s.setProperty('width', `${Math.round(panelW)}px`);
148
+ s.setProperty('visibility', placed ? 'visible' : 'hidden');
149
+ }
150
+ }
151
+
152
+ function schedulePosition() {
153
+ queueMicrotask(() => {
154
+ positionPanel();
155
+ requestAnimationFrame(() => {
156
+ positionPanel();
157
+ requestAnimationFrame(() => positionPanel({ show: true }));
158
+ });
159
+ });
160
+ }
161
+
162
+ function syncNative() {
163
+ if (!panelEl) return;
164
+ const isOpen = panelEl.matches(':popover-open');
165
+ try {
166
+ if (open && !isOpen) panelEl.showPopover();
167
+ else if (!open && isOpen) panelEl.hidePopover();
168
+ } catch {
169
+ /* ignore */
170
+ }
171
+ }
172
+
90
173
  function setOpen(next: boolean) {
91
- if (disabled) return;
174
+ if (disabled && next) return;
175
+ open = next;
176
+ }
177
+
178
+ function handleBeforeToggle(event: ToggleEvent) {
179
+ if (event.newState === 'open') {
180
+ if (disabled) {
181
+ event.preventDefault();
182
+ return;
183
+ }
184
+ placed = false;
185
+ // Set top/left before the first paint — UA popover defaults to 0,0.
186
+ positionPanel({ measure: false });
187
+ } else {
188
+ placed = false;
189
+ }
190
+ }
191
+
192
+ function handleToggle(event: ToggleEvent) {
193
+ const next = event.newState === 'open';
92
194
  open = next;
195
+ if (next) schedulePosition();
196
+ else placed = false;
93
197
  }
94
198
 
199
+ $effect(() => {
200
+ open;
201
+ queueMicrotask(() => {
202
+ syncNative();
203
+ if (open) positionPanel();
204
+ });
205
+ });
206
+
207
+ $effect(() => {
208
+ if (!open) return;
209
+ const offResize = on(window, 'resize', () => positionPanel());
210
+ const offScroll = on(window, 'scroll', () => positionPanel(), { capture: true });
211
+ return () => {
212
+ offResize();
213
+ offScroll();
214
+ };
215
+ });
216
+
95
217
  function emit(next: string) {
96
218
  value = next;
97
219
  onchange?.(next);
@@ -117,17 +239,6 @@
117
239
  onchange?.('');
118
240
  }
119
241
 
120
- function onDocPointerDown(e: PointerEvent) {
121
- if (!open || !rootEl) return;
122
- const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
123
- if (path.includes(rootEl) || rootEl.contains(e.target as Node)) return;
124
- setOpen(false);
125
- }
126
-
127
- function onKey(e: KeyboardEvent) {
128
- if (e.key === 'Escape' && open) setOpen(false);
129
- }
130
-
131
242
  function hourLabel(h: number) {
132
243
  if (format === '24h') return pad(h);
133
244
  const period = h >= 12 ? 'PM' : 'AM';
@@ -138,22 +249,21 @@
138
249
  $effect(() => {
139
250
  if (!open) return;
140
251
  requestAnimationFrame(() => {
141
- const hEl = hourListEl?.querySelector('[aria-pressed="true"]') as HTMLElement | null;
142
- const mEl = minuteListEl?.querySelector('[aria-pressed="true"]') as HTMLElement | null;
252
+ const hEl = hourListEl?.querySelector('[aria-selected="true"]') as HTMLElement | null;
253
+ const mEl = minuteListEl?.querySelector('[aria-selected="true"]') as HTMLElement | null;
143
254
  hEl?.scrollIntoView({ block: 'center' });
144
255
  mEl?.scrollIntoView({ block: 'center' });
145
256
  });
146
257
  });
147
258
  </script>
148
259
 
149
- <svelte:document onpointerdown={onDocPointerDown} onkeydown={onKey} />
150
-
151
- <div class={['relative w-full min-w-[12rem] max-w-[16rem]', className]} bind:this={rootEl}>
260
+ <div class={['w-full min-w-[12rem] max-w-[16rem]', className]}>
152
261
  {#if label}
153
262
  <span class="mb-1.5 block text-sm font-medium text-primary">{label}</span>
154
263
  {/if}
155
264
 
156
265
  <div
266
+ bind:this={triggerEl}
157
267
  class={[
158
268
  'flex h-10 w-full items-center overflow-hidden rounded-xl border border-border bg-surface-elevated transition-colors',
159
269
  open && 'border-brand-500 ring-2 ring-brand-500/20',
@@ -163,8 +273,11 @@
163
273
  <button
164
274
  type="button"
165
275
  {disabled}
166
- onclick={() => setOpen(!open)}
276
+ popovertarget={panelId}
277
+ popovertargetaction="toggle"
167
278
  aria-expanded={open}
279
+ aria-haspopup="dialog"
280
+ aria-controls={panelId}
168
281
  class={[
169
282
  'flex h-full min-w-0 flex-1 items-center gap-2 px-3.5 text-left text-sm',
170
283
  'hover:bg-surface-overlay focus-visible:outline-none',
@@ -203,76 +316,111 @@
203
316
  {/if}
204
317
  </div>
205
318
 
206
- {#if open}
207
- <div
208
- role="dialog"
209
- aria-label="Choose time"
210
- class="absolute left-0 right-0 z-50 mt-2 overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl"
211
- onpointerdown={(e) => e.stopPropagation()}
212
- >
213
- <div class="grid grid-cols-2 divide-x divide-border">
214
- <div class="flex flex-col">
215
- <span class="border-b border-border px-3 py-2 text-center text-[11px] font-medium uppercase tracking-wide text-muted">
216
- Hour
217
- </span>
218
- <div bind:this={hourListEl} class="max-h-52 overflow-y-auto p-1.5" role="listbox" aria-label="Hours">
219
- {#each hours24 as h (h)}
220
- {@const disabledHour = minutes.every((m) => isDisabled(h, m))}
221
- <button
222
- type="button"
223
- role="option"
224
- disabled={disabledHour}
225
- aria-pressed={selectedH === h}
226
- aria-selected={selectedH === h}
227
- onclick={() => pickHour(h)}
228
- class={[
229
- 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
230
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
231
- disabledHour && 'cursor-not-allowed opacity-30',
232
- selectedH === h
233
- ? 'bg-brand-500 font-semibold text-white'
234
- : 'text-primary hover:bg-surface-overlay'
235
- ]}
236
- >
237
- {hourLabel(h)}
238
- </button>
239
- {/each}
240
- </div>
319
+ <!-- Native popover (top layer + light dismiss). Not <dialog>: time picker is non-modal. -->
320
+ <div
321
+ bind:this={panelEl}
322
+ id={panelId}
323
+ popover="auto"
324
+ role="dialog"
325
+ aria-label="Choose time"
326
+ onbeforetoggle={handleBeforeToggle}
327
+ ontoggle={handleToggle}
328
+ data-placed={placed ? true : undefined}
329
+ class="timepicker-popover m-0 overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl outline-none"
330
+ >
331
+ <div class="grid grid-cols-2 divide-x divide-border">
332
+ <div class="flex flex-col">
333
+ <span
334
+ class="border-b border-border px-3 py-2 text-center text-[11px] font-medium tracking-wide text-muted uppercase"
335
+ >
336
+ Hour
337
+ </span>
338
+ <div
339
+ bind:this={hourListEl}
340
+ class="max-h-52 overflow-y-auto p-1.5"
341
+ role="listbox"
342
+ aria-label="Hours"
343
+ >
344
+ {#each hours24 as h (h)}
345
+ {@const disabledHour = minutes.every((m) => isDisabled(h, m))}
346
+ <button
347
+ type="button"
348
+ role="option"
349
+ disabled={disabledHour}
350
+ aria-selected={selectedH === h}
351
+ onclick={() => pickHour(h)}
352
+ class={[
353
+ 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
354
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
355
+ disabledHour && 'cursor-not-allowed opacity-30',
356
+ selectedH === h
357
+ ? 'bg-brand-500 font-semibold text-white'
358
+ : 'text-primary hover:bg-surface-overlay'
359
+ ]}
360
+ >
361
+ {hourLabel(h)}
362
+ </button>
363
+ {/each}
241
364
  </div>
242
- <div class="flex flex-col">
243
- <span class="border-b border-border px-3 py-2 text-center text-[11px] font-medium uppercase tracking-wide text-muted">
244
- Min
245
- </span>
246
- <div
247
- bind:this={minuteListEl}
248
- class="max-h-52 overflow-y-auto p-1.5"
249
- role="listbox"
250
- aria-label="Minutes"
251
- >
252
- {#each minutes as m (m)}
253
- {@const disabledMin = selectedH === null ? false : isDisabled(selectedH, m)}
254
- <button
255
- type="button"
256
- role="option"
257
- disabled={disabledMin}
258
- aria-pressed={selectedM === m}
259
- aria-selected={selectedM === m}
260
- onclick={() => pickMinute(m)}
261
- class={[
262
- 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
263
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
264
- disabledMin && 'cursor-not-allowed opacity-30',
265
- selectedM === m
266
- ? 'bg-brand-500 font-semibold text-white'
267
- : 'text-primary hover:bg-surface-overlay'
268
- ]}
269
- >
270
- {pad(m)}
271
- </button>
272
- {/each}
273
- </div>
365
+ </div>
366
+ <div class="flex flex-col">
367
+ <span
368
+ class="border-b border-border px-3 py-2 text-center text-[11px] font-medium tracking-wide text-muted uppercase"
369
+ >
370
+ Min
371
+ </span>
372
+ <div
373
+ bind:this={minuteListEl}
374
+ class="max-h-52 overflow-y-auto p-1.5"
375
+ role="listbox"
376
+ aria-label="Minutes"
377
+ >
378
+ {#each minutes as m (m)}
379
+ {@const disabledMin = selectedH === null ? false : isDisabled(selectedH, m)}
380
+ <button
381
+ type="button"
382
+ role="option"
383
+ disabled={disabledMin}
384
+ aria-selected={selectedM === m}
385
+ onclick={() => pickMinute(m)}
386
+ class={[
387
+ 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
388
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
389
+ disabledMin && 'cursor-not-allowed opacity-30',
390
+ selectedM === m
391
+ ? 'bg-brand-500 font-semibold text-white'
392
+ : 'text-primary hover:bg-surface-overlay'
393
+ ]}
394
+ >
395
+ {pad(m)}
396
+ </button>
397
+ {/each}
274
398
  </div>
275
399
  </div>
276
400
  </div>
277
- {/if}
401
+ </div>
278
402
  </div>
403
+
404
+ <style>
405
+ /* UA popover is inset:0 + margin:auto (viewport-centered and stretched). */
406
+ .timepicker-popover {
407
+ position: fixed;
408
+ inset: unset;
409
+ margin: 0;
410
+ width: max-content;
411
+ height: max-content;
412
+ }
413
+
414
+ .timepicker-popover:popover-open {
415
+ display: block;
416
+ }
417
+
418
+ .timepicker-popover:popover-open:not([data-placed]) {
419
+ visibility: hidden;
420
+ pointer-events: none;
421
+ }
422
+
423
+ .timepicker-popover:not(:popover-open) {
424
+ display: none;
425
+ }
426
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",