@r2digisolutions/components 0.8.4 → 0.8.6
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.
|
@@ -62,7 +62,22 @@
|
|
|
62
62
|
const today = new Date();
|
|
63
63
|
const todayIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
function monthFromIso(iso: string | undefined) {
|
|
66
|
+
if (!iso) return null;
|
|
67
|
+
const [y, m] = iso.split('-').map(Number);
|
|
68
|
+
if (!Number.isFinite(y) || !Number.isFinite(m)) return null;
|
|
69
|
+
return new Date(y, m - 1, 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function selectionIso() {
|
|
73
|
+
if (mode === 'range') return start;
|
|
74
|
+
if (mode === 'multiple') return values[0];
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let view = $state(
|
|
79
|
+
monthFromIso(selectionIso()) ?? new Date(today.getFullYear(), today.getMonth(), 1)
|
|
80
|
+
);
|
|
66
81
|
let panel = $state<Panel>('days');
|
|
67
82
|
/** Which month column owns the month/year picker when months=2. */
|
|
68
83
|
let pickerOffset = $state(0);
|
|
@@ -262,7 +277,7 @@
|
|
|
262
277
|
const isToday = iso === todayIso;
|
|
263
278
|
|
|
264
279
|
return [
|
|
265
|
-
'relative flex
|
|
280
|
+
'relative flex size-9 items-center justify-center rounded-lg text-sm transition-colors',
|
|
266
281
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
|
|
267
282
|
disabled && 'cursor-not-allowed opacity-30',
|
|
268
283
|
!disabled && !selected && !mid && 'text-primary hover:bg-surface-overlay',
|
|
@@ -303,7 +318,7 @@
|
|
|
303
318
|
'max-w-full bg-surface-elevated',
|
|
304
319
|
framed && 'rounded-2xl border border-border p-3 shadow-sm',
|
|
305
320
|
!framed && 'p-0.5',
|
|
306
|
-
dual && panel === 'days' ? 'w-fit' :
|
|
321
|
+
dual && panel === 'days' ? 'w-fit' : 'w-80',
|
|
307
322
|
className
|
|
308
323
|
]}
|
|
309
324
|
>
|
|
@@ -369,7 +384,7 @@
|
|
|
369
384
|
<div class="grid grid-cols-7 gap-1.5">
|
|
370
385
|
{#each grid.cells as cell, i (cell.date || `e-${grid.offset}-${i}`)}
|
|
371
386
|
{#if !cell.inMonth}
|
|
372
|
-
<span class="
|
|
387
|
+
<span class="size-9"></span>
|
|
373
388
|
{:else}
|
|
374
389
|
{@const color = dotMap.get(cell.date)}
|
|
375
390
|
<button
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { on } from 'svelte/events';
|
|
2
3
|
import Calendar, {
|
|
3
4
|
type CalendarDot,
|
|
4
5
|
type CalendarMode
|
|
5
6
|
} from '../Calendar/Calendar.svelte';
|
|
6
7
|
import { i18n } from '../../../utils/i18n.svelte.js';
|
|
7
8
|
import { resolveLocaleTag } from '../../../utils/i18n.js';
|
|
9
|
+
import { createId } from '../../../utils/id.js';
|
|
8
10
|
|
|
9
11
|
export type DatePickerPlacement =
|
|
10
12
|
| 'auto'
|
|
@@ -87,12 +89,14 @@
|
|
|
87
89
|
|
|
88
90
|
const dateLocale = $derived(locale || resolveLocaleTag(i18n.locale));
|
|
89
91
|
|
|
90
|
-
let
|
|
92
|
+
let triggerEl = $state<HTMLElement | null>(null);
|
|
91
93
|
let panelEl = $state<HTMLDivElement | null>(null);
|
|
94
|
+
let panelStyle = $state('margin:0;inset:auto;');
|
|
92
95
|
let activeField = $state<'start' | 'end' | 'value'>('value');
|
|
93
|
-
let
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
let panelId = $state('');
|
|
97
|
+
|
|
98
|
+
$effect(() => {
|
|
99
|
+
panelId ||= createId('datepicker');
|
|
96
100
|
});
|
|
97
101
|
|
|
98
102
|
function formatIso(iso: string) {
|
|
@@ -121,100 +125,114 @@
|
|
|
121
125
|
function parsePlacement(p: DatePickerPlacement) {
|
|
122
126
|
if (p === 'auto') return null;
|
|
123
127
|
const [side, align] = p.split('-') as ['top' | 'bottom', 'start' | 'center' | 'end' | undefined];
|
|
124
|
-
return {
|
|
125
|
-
side,
|
|
126
|
-
align: (align ?? (months === 1 ? 'stretch' : 'center')) as
|
|
127
|
-
| 'start'
|
|
128
|
-
| 'center'
|
|
129
|
-
| 'end'
|
|
130
|
-
| 'stretch'
|
|
131
|
-
};
|
|
128
|
+
return { side, align: align ?? (months === 2 ? 'center' : 'start') };
|
|
132
129
|
}
|
|
133
130
|
|
|
134
|
-
function
|
|
135
|
-
if (!
|
|
131
|
+
function positionPanel() {
|
|
132
|
+
if (!triggerEl || !panelEl) return;
|
|
133
|
+
if (!panelEl.matches(':popover-open')) return;
|
|
136
134
|
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
resolved = {
|
|
140
|
-
side: fixed.side,
|
|
141
|
-
align: months === 1 && fixed.align === 'center' ? 'stretch' : fixed.align
|
|
142
|
-
};
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
135
|
+
const trigger = triggerEl.getBoundingClientRect();
|
|
136
|
+
if (trigger.width < 2 && trigger.height < 2) return;
|
|
145
137
|
|
|
146
|
-
const
|
|
147
|
-
const
|
|
138
|
+
const vv = window.visualViewport;
|
|
139
|
+
const viewW = vv?.width ?? window.innerWidth;
|
|
140
|
+
const viewH = vv?.height ?? window.innerHeight;
|
|
141
|
+
const viewLeft = vv?.offsetLeft ?? 0;
|
|
142
|
+
const viewTop = vv?.offsetTop ?? 0;
|
|
148
143
|
const gap = 8;
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const centerLeft = trigger.left + trigger.width / 2 - panel.width / 2;
|
|
161
|
-
const startLeft = trigger.left;
|
|
162
|
-
const endLeft = trigger.right - panel.width;
|
|
163
|
-
|
|
164
|
-
const fits = (left: number) => left >= gap && left + panel.width <= vw - gap;
|
|
165
|
-
|
|
166
|
-
if (fits(centerLeft)) align = 'center';
|
|
167
|
-
else if (fits(startLeft)) align = 'start';
|
|
168
|
-
else if (fits(endLeft)) align = 'end';
|
|
169
|
-
else {
|
|
170
|
-
// pick least overflow
|
|
171
|
-
const overflows = [
|
|
172
|
-
{ align: 'center' as const, overflow: Math.max(0, -centerLeft) + Math.max(0, centerLeft + panel.width - vw) },
|
|
173
|
-
{ align: 'start' as const, overflow: Math.max(0, -startLeft) + Math.max(0, startLeft + panel.width - vw) },
|
|
174
|
-
{ align: 'end' as const, overflow: Math.max(0, -endLeft) + Math.max(0, endLeft + panel.width - vw) }
|
|
175
|
-
];
|
|
176
|
-
overflows.sort((a, b) => a.overflow - b.overflow);
|
|
177
|
-
align = overflows[0].align;
|
|
178
|
-
}
|
|
144
|
+
const pad = 8;
|
|
145
|
+
|
|
146
|
+
const panelW = Math.min(panelEl.offsetWidth || 320, viewW - pad * 2);
|
|
147
|
+
const panelH = Math.min(panelEl.offsetHeight || 360, 420);
|
|
148
|
+
|
|
149
|
+
const fixed = parsePlacement(placement);
|
|
150
|
+
let side: 'top' | 'bottom' = fixed?.side ?? 'bottom';
|
|
151
|
+
if (!fixed) {
|
|
152
|
+
const spaceBelow = viewTop + viewH - trigger.bottom - gap - pad;
|
|
153
|
+
const spaceAbove = trigger.top - viewTop - gap - pad;
|
|
154
|
+
if (spaceBelow < panelH && spaceAbove > spaceBelow) side = 'top';
|
|
179
155
|
}
|
|
180
156
|
|
|
181
|
-
|
|
157
|
+
const align = fixed?.align ?? (months === 2 ? 'center' : 'start');
|
|
158
|
+
|
|
159
|
+
let top = side === 'bottom' ? trigger.bottom + gap : trigger.top - panelH - gap;
|
|
160
|
+
let left =
|
|
161
|
+
align === 'end'
|
|
162
|
+
? trigger.right - panelW
|
|
163
|
+
: align === 'center'
|
|
164
|
+
? trigger.left + trigger.width / 2 - panelW / 2
|
|
165
|
+
: trigger.left;
|
|
166
|
+
|
|
167
|
+
left = Math.min(Math.max(viewLeft + pad, left), viewLeft + viewW - panelW - pad);
|
|
168
|
+
top = Math.min(Math.max(viewTop + pad, top), viewTop + viewH - panelH - pad);
|
|
169
|
+
|
|
170
|
+
panelStyle = [
|
|
171
|
+
'margin:0',
|
|
172
|
+
'inset:auto',
|
|
173
|
+
`top:${Math.round(top)}px`,
|
|
174
|
+
`left:${Math.round(left)}px`,
|
|
175
|
+
'right:auto',
|
|
176
|
+
'bottom:auto',
|
|
177
|
+
'width:max-content'
|
|
178
|
+
].join(';');
|
|
182
179
|
}
|
|
183
180
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
181
|
+
function schedulePosition() {
|
|
182
|
+
queueMicrotask(() => {
|
|
183
|
+
positionPanel();
|
|
184
|
+
requestAnimationFrame(() => {
|
|
185
|
+
positionPanel();
|
|
186
|
+
requestAnimationFrame(positionPanel);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function syncNative() {
|
|
192
|
+
if (!panelEl) return;
|
|
193
|
+
const isOpen = panelEl.matches(':popover-open');
|
|
194
|
+
try {
|
|
195
|
+
if (open && !isOpen) panelEl.showPopover();
|
|
196
|
+
else if (!open && isOpen) panelEl.hidePopover();
|
|
197
|
+
} catch {
|
|
198
|
+
/* ignore */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
196
201
|
|
|
197
202
|
function setOpen(next: boolean) {
|
|
198
|
-
if (disabled) return;
|
|
203
|
+
if (disabled && next) return;
|
|
199
204
|
open = next;
|
|
200
205
|
}
|
|
201
206
|
|
|
202
|
-
function
|
|
203
|
-
if (disabled)
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
function handleBeforeToggle(event: ToggleEvent) {
|
|
208
|
+
if (event.newState === 'open' && disabled) {
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
}
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
function
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
setOpen(false);
|
|
213
|
+
function handleToggle(event: ToggleEvent) {
|
|
214
|
+
const next = event.newState === 'open';
|
|
215
|
+
open = next;
|
|
216
|
+
if (next) schedulePosition();
|
|
213
217
|
}
|
|
214
218
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
$effect(() => {
|
|
220
|
+
open;
|
|
221
|
+
queueMicrotask(() => {
|
|
222
|
+
syncNative();
|
|
223
|
+
if (open) positionPanel();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
$effect(() => {
|
|
228
|
+
if (!open) return;
|
|
229
|
+
const offResize = on(window, 'resize', () => positionPanel());
|
|
230
|
+
const offScroll = on(window, 'scroll', () => positionPanel(), { capture: true });
|
|
231
|
+
return () => {
|
|
232
|
+
offResize();
|
|
233
|
+
offScroll();
|
|
234
|
+
};
|
|
235
|
+
});
|
|
218
236
|
|
|
219
237
|
function handleChange(detail: {
|
|
220
238
|
mode: CalendarMode;
|
|
@@ -248,42 +266,14 @@
|
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
const hasValue = $derived(
|
|
251
|
-
mode === 'single'
|
|
252
|
-
? !!value
|
|
253
|
-
: mode === 'multiple'
|
|
254
|
-
? values.length > 0
|
|
255
|
-
: !!(start || end)
|
|
269
|
+
mode === 'single' ? !!value : mode === 'multiple' ? values.length > 0 : !!(start || end)
|
|
256
270
|
);
|
|
257
271
|
|
|
258
|
-
const
|
|
259
|
-
const { side, align } = resolved;
|
|
260
|
-
const vertical =
|
|
261
|
-
side === 'bottom' ? 'top-full mt-2' : 'bottom-full mb-2';
|
|
262
|
-
|
|
263
|
-
const horizontal =
|
|
264
|
-
align === 'stretch'
|
|
265
|
-
? 'left-0 right-0'
|
|
266
|
-
: align === 'center'
|
|
267
|
-
? 'left-1/2 -translate-x-1/2'
|
|
268
|
-
: align === 'end'
|
|
269
|
-
? 'right-0'
|
|
270
|
-
: 'left-0';
|
|
271
|
-
|
|
272
|
-
return [
|
|
273
|
-
'absolute z-50 rounded-2xl border border-border bg-surface-elevated p-2 shadow-xl',
|
|
274
|
-
vertical,
|
|
275
|
-
horizontal,
|
|
276
|
-
months === 2 && align !== 'stretch' ? 'w-max max-w-[min(100vw-1rem,42rem)]' : '',
|
|
277
|
-
months === 1 || align === 'stretch' ? 'w-auto' : ''
|
|
278
|
-
];
|
|
279
|
-
});
|
|
272
|
+
const triggerAction = $derived(variant === 'split' ? 'show' : 'toggle');
|
|
280
273
|
</script>
|
|
281
274
|
|
|
282
|
-
<svelte:document onpointerdown={onDocPointerDown} onkeydown={onKey} />
|
|
283
|
-
|
|
284
275
|
<div
|
|
285
276
|
class={[
|
|
286
|
-
'relative',
|
|
287
277
|
variant === 'split'
|
|
288
278
|
? 'w-full'
|
|
289
279
|
: months === 2
|
|
@@ -291,7 +281,6 @@
|
|
|
291
281
|
: 'w-full min-w-[18rem] max-w-[20rem]',
|
|
292
282
|
className
|
|
293
283
|
]}
|
|
294
|
-
bind:this={rootEl}
|
|
295
284
|
>
|
|
296
285
|
{#if label && variant === 'field'}
|
|
297
286
|
<span class="mb-1.5 block text-sm font-medium text-primary">{label}</span>
|
|
@@ -299,6 +288,7 @@
|
|
|
299
288
|
|
|
300
289
|
{#if variant === 'split' && mode === 'range'}
|
|
301
290
|
<div
|
|
291
|
+
bind:this={triggerEl}
|
|
302
292
|
class={[
|
|
303
293
|
'flex overflow-hidden rounded-xl border border-border bg-surface-elevated',
|
|
304
294
|
open && 'border-brand-500 ring-2 ring-brand-500/20',
|
|
@@ -308,14 +298,21 @@
|
|
|
308
298
|
<button
|
|
309
299
|
type="button"
|
|
310
300
|
{disabled}
|
|
311
|
-
|
|
301
|
+
popovertarget={panelId}
|
|
302
|
+
popovertargetaction={triggerAction}
|
|
303
|
+
onclick={() => {
|
|
304
|
+
activeField = 'start';
|
|
305
|
+
}}
|
|
306
|
+
aria-expanded={open}
|
|
307
|
+
aria-haspopup="dialog"
|
|
308
|
+
aria-controls={panelId}
|
|
312
309
|
class={[
|
|
313
310
|
'flex min-w-0 flex-1 flex-col gap-0.5 px-3.5 py-2.5 text-left transition-colors',
|
|
314
311
|
'hover:bg-surface-overlay focus-visible:outline-none',
|
|
315
312
|
activeField === 'start' && open && 'bg-surface-overlay'
|
|
316
313
|
]}
|
|
317
314
|
>
|
|
318
|
-
<span class="text-[11px] font-medium
|
|
315
|
+
<span class="text-[11px] font-medium tracking-wide text-muted uppercase">{startLabel}</span>
|
|
319
316
|
<span class={['truncate text-sm', start ? 'text-primary' : 'text-muted']}>
|
|
320
317
|
{start ? formatIso(start) : startPlaceholder}
|
|
321
318
|
</span>
|
|
@@ -324,14 +321,21 @@
|
|
|
324
321
|
<button
|
|
325
322
|
type="button"
|
|
326
323
|
{disabled}
|
|
327
|
-
|
|
324
|
+
popovertarget={panelId}
|
|
325
|
+
popovertargetaction={triggerAction}
|
|
326
|
+
onclick={() => {
|
|
327
|
+
activeField = 'end';
|
|
328
|
+
}}
|
|
329
|
+
aria-expanded={open}
|
|
330
|
+
aria-haspopup="dialog"
|
|
331
|
+
aria-controls={panelId}
|
|
328
332
|
class={[
|
|
329
333
|
'flex min-w-0 flex-1 flex-col gap-0.5 px-3.5 py-2.5 text-left transition-colors',
|
|
330
334
|
'hover:bg-surface-overlay focus-visible:outline-none',
|
|
331
335
|
activeField === 'end' && open && 'bg-surface-overlay'
|
|
332
336
|
]}
|
|
333
337
|
>
|
|
334
|
-
<span class="text-[11px] font-medium
|
|
338
|
+
<span class="text-[11px] font-medium tracking-wide text-muted uppercase">{endLabel}</span>
|
|
335
339
|
<span class={['truncate text-sm', end ? 'text-primary' : 'text-muted']}>
|
|
336
340
|
{end ? formatIso(end) : endPlaceholder}
|
|
337
341
|
</span>
|
|
@@ -351,6 +355,7 @@
|
|
|
351
355
|
</div>
|
|
352
356
|
{:else}
|
|
353
357
|
<div
|
|
358
|
+
bind:this={triggerEl}
|
|
354
359
|
class={[
|
|
355
360
|
'flex h-10 w-full items-center overflow-hidden rounded-xl border border-border bg-surface-elevated transition-colors',
|
|
356
361
|
open && 'border-brand-500 ring-2 ring-brand-500/20',
|
|
@@ -360,8 +365,14 @@
|
|
|
360
365
|
<button
|
|
361
366
|
type="button"
|
|
362
367
|
{disabled}
|
|
363
|
-
|
|
368
|
+
popovertarget={panelId}
|
|
369
|
+
popovertargetaction="toggle"
|
|
370
|
+
onclick={() => {
|
|
371
|
+
activeField = 'value';
|
|
372
|
+
}}
|
|
364
373
|
aria-expanded={open}
|
|
374
|
+
aria-haspopup="dialog"
|
|
375
|
+
aria-controls={panelId}
|
|
365
376
|
class={[
|
|
366
377
|
'flex h-full min-w-0 flex-1 items-center gap-2 px-3.5 text-left text-sm',
|
|
367
378
|
'hover:bg-surface-overlay focus-visible:outline-none',
|
|
@@ -393,7 +404,13 @@
|
|
|
393
404
|
class="h-full px-3 text-muted hover:bg-surface-overlay hover:text-primary"
|
|
394
405
|
aria-label="Clear"
|
|
395
406
|
>
|
|
396
|
-
<svg
|
|
407
|
+
<svg
|
|
408
|
+
class="h-3.5 w-3.5"
|
|
409
|
+
viewBox="0 0 24 24"
|
|
410
|
+
fill="none"
|
|
411
|
+
stroke="currentColor"
|
|
412
|
+
stroke-width="2"
|
|
413
|
+
>
|
|
397
414
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
398
415
|
</svg>
|
|
399
416
|
</button>
|
|
@@ -401,14 +418,22 @@
|
|
|
401
418
|
</div>
|
|
402
419
|
{/if}
|
|
403
420
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
421
|
+
<!-- Native popover (top layer + light dismiss). Not <dialog>: date picker is non-modal. -->
|
|
422
|
+
<div
|
|
423
|
+
bind:this={panelEl}
|
|
424
|
+
id={panelId}
|
|
425
|
+
popover="auto"
|
|
426
|
+
role="dialog"
|
|
427
|
+
aria-label="Choose date"
|
|
428
|
+
onbeforetoggle={handleBeforeToggle}
|
|
429
|
+
ontoggle={handleToggle}
|
|
430
|
+
style={panelStyle}
|
|
431
|
+
class={[
|
|
432
|
+
'datepicker-popover m-0 w-max rounded-2xl border border-border bg-surface-elevated p-2 shadow-xl outline-none',
|
|
433
|
+
months === 2 ? 'max-w-[min(42rem,calc(100vw-1rem))]' : 'max-w-[20rem]'
|
|
434
|
+
]}
|
|
435
|
+
>
|
|
436
|
+
{#key `${open}:${start}:${value}`}
|
|
412
437
|
{#if mode === 'single'}
|
|
413
438
|
<Calendar
|
|
414
439
|
mode="single"
|
|
@@ -453,6 +478,25 @@
|
|
|
453
478
|
onchange={handleChange}
|
|
454
479
|
/>
|
|
455
480
|
{/if}
|
|
456
|
-
|
|
457
|
-
|
|
481
|
+
{/key}
|
|
482
|
+
</div>
|
|
458
483
|
</div>
|
|
484
|
+
|
|
485
|
+
<style>
|
|
486
|
+
/* UA popover is inset:0 + margin:auto (viewport-centered and stretched). */
|
|
487
|
+
.datepicker-popover {
|
|
488
|
+
position: fixed;
|
|
489
|
+
inset: unset;
|
|
490
|
+
margin: 0;
|
|
491
|
+
width: max-content;
|
|
492
|
+
height: max-content;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.datepicker-popover:popover-open {
|
|
496
|
+
display: block;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.datepicker-popover:not(:popover-open) {
|
|
500
|
+
display: none;
|
|
501
|
+
}
|
|
502
|
+
</style>
|