@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
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|