@r2digisolutions/components 0.8.4 → 0.8.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.
@@ -262,7 +262,7 @@
262
262
  const isToday = iso === todayIso;
263
263
 
264
264
  return [
265
- 'relative flex aspect-square min-h-9 items-center justify-center rounded-lg text-sm transition-colors',
265
+ 'relative flex size-9 items-center justify-center rounded-lg text-sm transition-colors',
266
266
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
267
267
  disabled && 'cursor-not-allowed opacity-30',
268
268
  !disabled && !selected && !mid && 'text-primary hover:bg-surface-overlay',
@@ -303,7 +303,7 @@
303
303
  'max-w-full bg-surface-elevated',
304
304
  framed && 'rounded-2xl border border-border p-3 shadow-sm',
305
305
  !framed && 'p-0.5',
306
- dual && panel === 'days' ? 'w-fit' : framed ? 'w-80' : 'w-full',
306
+ dual && panel === 'days' ? 'w-fit' : 'w-80',
307
307
  className
308
308
  ]}
309
309
  >
@@ -369,7 +369,7 @@
369
369
  <div class="grid grid-cols-7 gap-1.5">
370
370
  {#each grid.cells as cell, i (cell.date || `e-${grid.offset}-${i}`)}
371
371
  {#if !cell.inMonth}
372
- <span class="aspect-square min-h-9"></span>
372
+ <span class="size-9"></span>
373
373
  {:else}
374
374
  {@const color = dotMap.get(cell.date)}
375
375
  <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 rootEl = $state<HTMLDivElement | null>(null);
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 resolved = $state<{ side: 'top' | 'bottom'; align: 'start' | 'center' | 'end' | 'stretch' }>({
94
- side: 'bottom',
95
- align: months === 1 ? 'stretch' : 'center'
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 updatePlacement() {
135
- if (!rootEl || !panelEl) return;
131
+ function positionPanel() {
132
+ if (!triggerEl || !panelEl) return;
133
+ if (!panelEl.matches(':popover-open')) return;
136
134
 
137
- const fixed = parsePlacement(placement);
138
- if (fixed) {
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 trigger = rootEl.getBoundingClientRect();
147
- const panel = panelEl.getBoundingClientRect();
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 vw = window.innerWidth;
150
- const vh = window.innerHeight;
151
-
152
- const spaceBelow = vh - trigger.bottom - gap;
153
- const spaceAbove = trigger.top - gap;
154
- const side: 'top' | 'bottom' =
155
- spaceBelow < panel.height && spaceAbove > spaceBelow ? 'top' : 'bottom';
156
-
157
- let align: 'start' | 'center' | 'end' | 'stretch' = months === 1 ? 'stretch' : 'center';
158
-
159
- if (months === 2 || align !== 'stretch') {
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
- resolved = { side, align };
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
- $effect(() => {
185
- if (!open) return;
186
- const id = requestAnimationFrame(() => updatePlacement());
187
- const onWin = () => updatePlacement();
188
- window.addEventListener('resize', onWin);
189
- window.addEventListener('scroll', onWin, true);
190
- return () => {
191
- cancelAnimationFrame(id);
192
- window.removeEventListener('resize', onWin);
193
- window.removeEventListener('scroll', onWin, true);
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 openField(field: 'start' | 'end' | 'value') {
203
- if (disabled) return;
204
- activeField = field;
205
- open = true;
207
+ function handleBeforeToggle(event: ToggleEvent) {
208
+ if (event.newState === 'open' && disabled) {
209
+ event.preventDefault();
210
+ }
206
211
  }
207
212
 
208
- function onDocPointerDown(e: PointerEvent) {
209
- if (!open || !rootEl) return;
210
- const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
211
- if (path.includes(rootEl) || rootEl.contains(e.target as Node)) return;
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
- function onKey(e: KeyboardEvent) {
216
- if (e.key === 'Escape' && open) setOpen(false);
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 panelClass = $derived.by(() => {
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
- onclick={() => openField('start')}
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 uppercase tracking-wide text-muted">{startLabel}</span>
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
- onclick={() => openField('end')}
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 uppercase tracking-wide text-muted">{endLabel}</span>
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
- onclick={() => (open ? setOpen(false) : openField('value'))}
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 class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
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,58 +418,83 @@
401
418
  </div>
402
419
  {/if}
403
420
 
404
- {#if open}
405
- <div
406
- bind:this={panelEl}
407
- role="dialog"
408
- aria-label="Choose date"
409
- class={panelClass}
410
- onpointerdown={(e) => e.stopPropagation()}
411
- >
412
- {#if mode === 'single'}
413
- <Calendar
414
- mode="single"
415
- {months}
416
- bind:value
417
- {min}
418
- {max}
419
- {disabledDates}
420
- {enabledDates}
421
- {dots}
422
- framed={false}
423
- locale={dateLocale}
424
- onchange={handleChange}
425
- />
426
- {:else if mode === 'multiple'}
427
- <Calendar
428
- mode="multiple"
429
- {months}
430
- bind:values
431
- {min}
432
- {max}
433
- {disabledDates}
434
- {enabledDates}
435
- {dots}
436
- framed={false}
437
- locale={dateLocale}
438
- onchange={handleChange}
439
- />
440
- {:else}
441
- <Calendar
442
- mode="range"
443
- {months}
444
- bind:start
445
- bind:end
446
- {min}
447
- {max}
448
- {disabledDates}
449
- {enabledDates}
450
- {dots}
451
- framed={false}
452
- locale={dateLocale}
453
- onchange={handleChange}
454
- />
455
- {/if}
456
- </div>
457
- {/if}
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
+ {#if mode === 'single'}
437
+ <Calendar
438
+ mode="single"
439
+ {months}
440
+ bind:value
441
+ {min}
442
+ {max}
443
+ {disabledDates}
444
+ {enabledDates}
445
+ {dots}
446
+ framed={false}
447
+ locale={dateLocale}
448
+ onchange={handleChange}
449
+ />
450
+ {:else if mode === 'multiple'}
451
+ <Calendar
452
+ mode="multiple"
453
+ {months}
454
+ bind:values
455
+ {min}
456
+ {max}
457
+ {disabledDates}
458
+ {enabledDates}
459
+ {dots}
460
+ framed={false}
461
+ locale={dateLocale}
462
+ onchange={handleChange}
463
+ />
464
+ {:else}
465
+ <Calendar
466
+ mode="range"
467
+ {months}
468
+ bind:start
469
+ bind:end
470
+ {min}
471
+ {max}
472
+ {disabledDates}
473
+ {enabledDates}
474
+ {dots}
475
+ framed={false}
476
+ locale={dateLocale}
477
+ onchange={handleChange}
478
+ />
479
+ {/if}
480
+ </div>
458
481
  </div>
482
+
483
+ <style>
484
+ /* UA popover is inset:0 + margin:auto (viewport-centered and stretched). */
485
+ .datepicker-popover {
486
+ position: fixed;
487
+ inset: unset;
488
+ margin: 0;
489
+ width: max-content;
490
+ height: max-content;
491
+ }
492
+
493
+ .datepicker-popover:popover-open {
494
+ display: block;
495
+ }
496
+
497
+ .datepicker-popover:not(:popover-open) {
498
+ display: none;
499
+ }
500
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",