@r2digisolutions/components 0.9.3 → 0.9.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.
@@ -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,123 @@
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', 'max-content');
148
+ s.setProperty('min-width', `${Math.round(Math.min(panelW, trigger.width))}px`);
149
+ s.setProperty('visibility', placed ? 'visible' : 'hidden');
150
+ }
151
+ }
152
+
153
+ function schedulePosition() {
154
+ queueMicrotask(() => {
155
+ positionPanel();
156
+ requestAnimationFrame(() => {
157
+ positionPanel();
158
+ requestAnimationFrame(() => positionPanel({ show: true }));
159
+ });
160
+ });
161
+ }
162
+
163
+ function syncNative() {
164
+ if (!panelEl) return;
165
+ const isOpen = panelEl.matches(':popover-open');
166
+ try {
167
+ if (open && !isOpen) panelEl.showPopover();
168
+ else if (!open && isOpen) panelEl.hidePopover();
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+
90
174
  function setOpen(next: boolean) {
91
- if (disabled) return;
175
+ if (disabled && next) return;
92
176
  open = next;
93
177
  }
94
178
 
179
+ function handleBeforeToggle(event: ToggleEvent) {
180
+ if (event.newState === 'open') {
181
+ if (disabled) {
182
+ event.preventDefault();
183
+ return;
184
+ }
185
+ placed = false;
186
+ // Set top/left before the first paint — UA popover defaults to 0,0.
187
+ positionPanel({ measure: false });
188
+ } else {
189
+ placed = false;
190
+ }
191
+ }
192
+
193
+ function handleToggle(event: ToggleEvent) {
194
+ const next = event.newState === 'open';
195
+ open = next;
196
+ if (next) schedulePosition();
197
+ else placed = false;
198
+ }
199
+
200
+ $effect(() => {
201
+ open;
202
+ queueMicrotask(() => {
203
+ syncNative();
204
+ if (open) positionPanel();
205
+ });
206
+ });
207
+
208
+ $effect(() => {
209
+ if (!open) return;
210
+ const offResize = on(window, 'resize', () => positionPanel());
211
+ const offScroll = on(window, 'scroll', () => positionPanel(), { capture: true });
212
+ return () => {
213
+ offResize();
214
+ offScroll();
215
+ };
216
+ });
217
+
95
218
  function emit(next: string) {
96
219
  value = next;
97
220
  onchange?.(next);
@@ -117,17 +240,6 @@
117
240
  onchange?.('');
118
241
  }
119
242
 
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
243
  function hourLabel(h: number) {
132
244
  if (format === '24h') return pad(h);
133
245
  const period = h >= 12 ? 'PM' : 'AM';
@@ -135,25 +247,29 @@
135
247
  return `${h12} ${period}`;
136
248
  }
137
249
 
250
+ function scrollSelected(listEl: HTMLDivElement | null) {
251
+ const selected = listEl?.querySelector('[aria-selected="true"]') as HTMLElement | null;
252
+ if (!listEl || !selected) return;
253
+ // Don't use scrollIntoView — it also scrolls the dialog/modal ancestors.
254
+ listEl.scrollTop = selected.offsetTop - listEl.clientHeight / 2 + selected.clientHeight / 2;
255
+ }
256
+
138
257
  $effect(() => {
139
258
  if (!open) return;
140
259
  requestAnimationFrame(() => {
141
- const hEl = hourListEl?.querySelector('[aria-pressed="true"]') as HTMLElement | null;
142
- const mEl = minuteListEl?.querySelector('[aria-pressed="true"]') as HTMLElement | null;
143
- hEl?.scrollIntoView({ block: 'center' });
144
- mEl?.scrollIntoView({ block: 'center' });
260
+ scrollSelected(hourListEl);
261
+ scrollSelected(minuteListEl);
145
262
  });
146
263
  });
147
264
  </script>
148
265
 
149
- <svelte:document onpointerdown={onDocPointerDown} onkeydown={onKey} />
150
-
151
- <div class={['relative w-full min-w-[12rem] max-w-[16rem]', className]} bind:this={rootEl}>
266
+ <div class={['w-full min-w-[12rem] max-w-[16rem]', className]}>
152
267
  {#if label}
153
268
  <span class="mb-1.5 block text-sm font-medium text-primary">{label}</span>
154
269
  {/if}
155
270
 
156
271
  <div
272
+ bind:this={triggerEl}
157
273
  class={[
158
274
  'flex h-10 w-full items-center overflow-hidden rounded-xl border border-border bg-surface-elevated transition-colors',
159
275
  open && 'border-brand-500 ring-2 ring-brand-500/20',
@@ -163,8 +279,11 @@
163
279
  <button
164
280
  type="button"
165
281
  {disabled}
166
- onclick={() => setOpen(!open)}
282
+ popovertarget={panelId}
283
+ popovertargetaction="toggle"
167
284
  aria-expanded={open}
285
+ aria-haspopup="dialog"
286
+ aria-controls={panelId}
168
287
  class={[
169
288
  'flex h-full min-w-0 flex-1 items-center gap-2 px-3.5 text-left text-sm',
170
289
  'hover:bg-surface-overlay focus-visible:outline-none',
@@ -203,76 +322,111 @@
203
322
  {/if}
204
323
  </div>
205
324
 
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>
325
+ <!-- Native popover (top layer + light dismiss). Not <dialog>: time picker is non-modal. -->
326
+ <div
327
+ bind:this={panelEl}
328
+ id={panelId}
329
+ popover="auto"
330
+ role="dialog"
331
+ aria-label="Choose time"
332
+ onbeforetoggle={handleBeforeToggle}
333
+ ontoggle={handleToggle}
334
+ data-placed={placed ? true : undefined}
335
+ class="timepicker-popover m-0 overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl outline-none"
336
+ >
337
+ <div class="grid grid-cols-2 divide-x divide-border">
338
+ <div class="flex flex-col">
339
+ <span
340
+ class="border-b border-border px-3 py-2 text-center text-[11px] font-medium tracking-wide text-muted uppercase"
341
+ >
342
+ Hour
343
+ </span>
344
+ <div
345
+ bind:this={hourListEl}
346
+ class="max-h-52 overflow-y-auto p-1.5"
347
+ role="listbox"
348
+ aria-label="Hours"
349
+ >
350
+ {#each hours24 as h (h)}
351
+ {@const disabledHour = minutes.every((m) => isDisabled(h, m))}
352
+ <button
353
+ type="button"
354
+ role="option"
355
+ disabled={disabledHour}
356
+ aria-selected={selectedH === h}
357
+ onclick={() => pickHour(h)}
358
+ class={[
359
+ 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
360
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
361
+ disabledHour && 'cursor-not-allowed opacity-30',
362
+ selectedH === h
363
+ ? 'bg-brand-500 font-semibold text-white'
364
+ : 'text-primary hover:bg-surface-overlay'
365
+ ]}
366
+ >
367
+ {hourLabel(h)}
368
+ </button>
369
+ {/each}
241
370
  </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>
371
+ </div>
372
+ <div class="flex flex-col">
373
+ <span
374
+ class="border-b border-border px-3 py-2 text-center text-[11px] font-medium tracking-wide text-muted uppercase"
375
+ >
376
+ Min
377
+ </span>
378
+ <div
379
+ bind:this={minuteListEl}
380
+ class="max-h-52 overflow-y-auto p-1.5"
381
+ role="listbox"
382
+ aria-label="Minutes"
383
+ >
384
+ {#each minutes as m (m)}
385
+ {@const disabledMin = selectedH === null ? false : isDisabled(selectedH, m)}
386
+ <button
387
+ type="button"
388
+ role="option"
389
+ disabled={disabledMin}
390
+ aria-selected={selectedM === m}
391
+ onclick={() => pickMinute(m)}
392
+ class={[
393
+ 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
394
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
395
+ disabledMin && 'cursor-not-allowed opacity-30',
396
+ selectedM === m
397
+ ? 'bg-brand-500 font-semibold text-white'
398
+ : 'text-primary hover:bg-surface-overlay'
399
+ ]}
400
+ >
401
+ {pad(m)}
402
+ </button>
403
+ {/each}
274
404
  </div>
275
405
  </div>
276
406
  </div>
277
- {/if}
407
+ </div>
278
408
  </div>
409
+
410
+ <style>
411
+ /* UA popover is inset:0 + margin:auto (viewport-centered and stretched). */
412
+ .timepicker-popover {
413
+ position: fixed;
414
+ inset: unset;
415
+ margin: 0;
416
+ width: max-content;
417
+ height: max-content;
418
+ }
419
+
420
+ .timepicker-popover:popover-open {
421
+ display: block;
422
+ }
423
+
424
+ .timepicker-popover:popover-open:not([data-placed]) {
425
+ visibility: hidden;
426
+ pointer-events: none;
427
+ }
428
+
429
+ .timepicker-popover:not(:popover-open) {
430
+ display: none;
431
+ }
432
+ </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.5",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",