@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
|
|
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-
|
|
142
|
-
const mEl = minuteListEl?.querySelector('[aria-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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>
|