@r2digisolutions/components 0.14.14 → 0.14.16

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.
@@ -25,11 +25,11 @@ declare const meta: {
25
25
  format: "24h";
26
26
  minuteStep: number;
27
27
  label: string;
28
- closeOnSelect: true;
28
+ closeOnSelect: false;
29
29
  };
30
30
  };
31
31
  export default meta;
32
32
  type Story = StoryObj<typeof meta>;
33
33
  export declare const Default: Story;
34
34
  export declare const TwelveHour: Story;
35
- export declare const KeepOpen: Story;
35
+ export declare const InstantClose: Story;
@@ -12,13 +12,13 @@ const meta = {
12
12
  format: '24h',
13
13
  minuteStep: 5,
14
14
  label: 'Date & time',
15
- closeOnSelect: true
15
+ closeOnSelect: false
16
16
  }
17
17
  };
18
18
  export default meta;
19
19
  export const Default = {};
20
20
  export const TwelveHour = { args: { format: '12h' } };
21
- export const KeepOpen = {
22
- name: 'Keep open after select',
23
- args: { closeOnSelect: false }
21
+ export const InstantClose = {
22
+ name: 'Instant commit (legacy)',
23
+ args: { closeOnSelect: true }
24
24
  };
@@ -1,7 +1,11 @@
1
1
  <script lang="ts">
2
+ import { on } from 'svelte/events';
3
+ import Button from '../../atoms/Button/Button.svelte';
2
4
  import Calendar from '../Calendar/Calendar.svelte';
3
5
  import type { CalendarDot } from '../Calendar/Calendar.svelte';
4
6
  import type { TimeFormat } from '../TimePicker/TimePicker.svelte';
7
+ import { i18n } from '../../../utils/i18n.svelte.js';
8
+ import { createId } from '../../../utils/id.js';
5
9
 
6
10
  interface DateTimePickerProps {
7
11
  /** Date part `YYYY-MM-DD`. */
@@ -20,7 +24,13 @@
20
24
  dots?: CalendarDot[];
21
25
  format?: TimeFormat;
22
26
  minuteStep?: number;
27
+ /**
28
+ * When false (default), edits stay in the panel until Save; Cancel discards.
29
+ * When true, each pick commits immediately and may close the panel.
30
+ */
23
31
  closeOnSelect?: boolean;
32
+ cancelLabel?: string;
33
+ saveLabel?: string;
24
34
  class?: string;
25
35
  onchange?: (detail: { date: string; time: string; value: string }) => void;
26
36
  }
@@ -39,14 +49,31 @@
39
49
  dots = [],
40
50
  format = '24h',
41
51
  minuteStep = 5,
42
- closeOnSelect = true,
52
+ closeOnSelect = false,
53
+ cancelLabel,
54
+ saveLabel,
43
55
  class: className = '',
44
56
  onchange
45
57
  }: DateTimePickerProps = $props();
46
58
 
47
- let rootEl = $state<HTMLDivElement | null>(null);
59
+ let triggerEl = $state<HTMLElement | null>(null);
60
+ let panelEl = $state<HTMLDivElement | null>(null);
48
61
  let hourListEl = $state<HTMLDivElement | null>(null);
49
62
  let minuteListEl = $state<HTMLDivElement | null>(null);
63
+ let placed = $state(false);
64
+ let panelId = $state('');
65
+ let draftDate = $state('');
66
+ let draftTime = $state('');
67
+
68
+ const resolvedCancelLabel = $derived(cancelLabel ?? i18n.t('cancel'));
69
+ const resolvedSaveLabel = $derived(saveLabel ?? i18n.t('save'));
70
+ const confirmMode = $derived(!closeOnSelect);
71
+ const panelDate = $derived(confirmMode ? draftDate : date);
72
+ const panelTime = $derived(confirmMode ? draftTime : time);
73
+
74
+ $effect(() => {
75
+ panelId ||= createId('datetimepicker');
76
+ });
50
77
 
51
78
  const hours24 = Array.from({ length: 24 }, (_, i) => i);
52
79
  const minutes = $derived(
@@ -82,7 +109,6 @@
82
109
  onchange?.({ date, time, value });
83
110
  }
84
111
 
85
- // Keep parts in sync when `value` is set externally
86
112
  $effect(() => {
87
113
  if (!value) return;
88
114
  const [d, t] = value.includes('T') ? value.split('T') : [value, time];
@@ -93,7 +119,7 @@
93
119
  }
94
120
  });
95
121
 
96
- const parsedTime = $derived(parseTime(time));
122
+ const parsedTime = $derived(parseTime(panelTime));
97
123
  const selectedH = $derived(parsedTime?.h ?? null);
98
124
  const selectedM = $derived(parsedTime?.m ?? null);
99
125
 
@@ -122,40 +148,171 @@
122
148
  return formatTimeDisplay(time);
123
149
  });
124
150
 
151
+ function estimatePanelSize() {
152
+ return { panelW: 672, panelH: confirmMode ? 432 : 380 };
153
+ }
154
+
155
+ function beginDraft() {
156
+ draftDate = date;
157
+ draftTime = time || '09:00';
158
+ }
159
+
160
+ function setPanelDate(next: string) {
161
+ if (confirmMode) draftDate = next;
162
+ else date = next;
163
+ }
164
+
165
+ function setPanelTime(next: string) {
166
+ if (confirmMode) draftTime = next;
167
+ else time = next;
168
+ }
169
+
170
+ function commitDraft() {
171
+ date = draftDate;
172
+ time = draftTime || '09:00';
173
+ emit();
174
+ setOpen(false);
175
+ }
176
+
177
+ function cancelDraft() {
178
+ setOpen(false);
179
+ }
180
+
181
+ function positionPanel(opts: { measure?: boolean; show?: boolean } = {}) {
182
+ if (!triggerEl) return;
183
+ if (opts.show) placed = true;
184
+
185
+ const trigger = triggerEl.getBoundingClientRect();
186
+ if (trigger.width < 2 && trigger.height < 2) return;
187
+
188
+ const vv = window.visualViewport;
189
+ const viewW = vv?.width ?? window.innerWidth;
190
+ const viewH = vv?.height ?? window.innerHeight;
191
+ const viewLeft = vv?.offsetLeft ?? 0;
192
+ const viewTop = vv?.offsetTop ?? 0;
193
+ const gap = 8;
194
+ const pad = 8;
195
+ const estimated = estimatePanelSize();
196
+ const canMeasure = opts.measure !== false && !!panelEl?.matches(':popover-open');
197
+ const panelW = Math.min(
198
+ canMeasure && panelEl?.offsetWidth ? panelEl.offsetWidth : estimated.panelW,
199
+ viewW - pad * 2
200
+ );
201
+ const panelH = Math.min(
202
+ canMeasure && panelEl?.offsetHeight ? panelEl.offsetHeight : estimated.panelH,
203
+ viewH - pad * 2
204
+ );
205
+
206
+ const spaceBelow = viewTop + viewH - trigger.bottom - gap - pad;
207
+ const spaceAbove = trigger.top - viewTop - gap - pad;
208
+ const side: 'top' | 'bottom' = spaceBelow < panelH && spaceAbove > spaceBelow ? 'top' : 'bottom';
209
+
210
+ let top = side === 'bottom' ? trigger.bottom + gap : trigger.top - panelH - gap;
211
+ let left = trigger.left;
212
+
213
+ left = Math.min(Math.max(viewLeft + pad, left), viewLeft + viewW - panelW - pad);
214
+ top = Math.min(Math.max(viewTop + pad, top), viewTop + viewH - panelH - pad);
215
+
216
+ if (panelEl) {
217
+ const s = panelEl.style;
218
+ s.setProperty('margin', '0');
219
+ s.setProperty('inset', 'auto');
220
+ s.setProperty('top', `${Math.round(top)}px`);
221
+ s.setProperty('left', `${Math.round(left)}px`);
222
+ s.setProperty('right', 'auto');
223
+ s.setProperty('bottom', 'auto');
224
+ s.setProperty('width', 'max-content');
225
+ s.setProperty('visibility', placed ? 'visible' : 'hidden');
226
+ }
227
+ }
228
+
229
+ function schedulePosition() {
230
+ queueMicrotask(() => {
231
+ positionPanel();
232
+ requestAnimationFrame(() => {
233
+ positionPanel();
234
+ requestAnimationFrame(() => positionPanel({ show: true }));
235
+ });
236
+ });
237
+ }
238
+
239
+ function syncNative() {
240
+ if (!panelEl) return;
241
+ const isOpen = panelEl.matches(':popover-open');
242
+ try {
243
+ if (open && !isOpen) panelEl.showPopover();
244
+ else if (!open && isOpen) panelEl.hidePopover();
245
+ } catch {
246
+ /* ignore */
247
+ }
248
+ }
249
+
125
250
  function setOpen(next: boolean) {
126
- if (disabled) return;
251
+ if (disabled && next) return;
127
252
  open = next;
128
253
  }
129
254
 
130
- function onDocPointerDown(e: PointerEvent) {
131
- if (!open || !rootEl) return;
132
- const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
133
- if (path.includes(rootEl) || rootEl.contains(e.target as Node)) return;
134
- setOpen(false);
255
+ function handleBeforeToggle(event: ToggleEvent) {
256
+ if (event.newState === 'open') {
257
+ if (disabled) {
258
+ event.preventDefault();
259
+ return;
260
+ }
261
+ if (confirmMode) beginDraft();
262
+ placed = false;
263
+ positionPanel({ measure: false });
264
+ } else {
265
+ placed = false;
266
+ }
135
267
  }
136
268
 
137
- function onKey(e: KeyboardEvent) {
138
- if (e.key === 'Escape' && open) setOpen(false);
269
+ function handleToggle(event: ToggleEvent) {
270
+ open = event.newState === 'open';
271
+ if (open) schedulePosition();
272
+ else placed = false;
139
273
  }
140
274
 
275
+ $effect(() => {
276
+ open;
277
+ queueMicrotask(() => {
278
+ syncNative();
279
+ if (open) positionPanel();
280
+ });
281
+ });
282
+
283
+ $effect(() => {
284
+ if (!open) return;
285
+ const offResize = on(window, 'resize', () => positionPanel());
286
+ const offScroll = on(window, 'scroll', () => positionPanel(), { capture: true });
287
+ return () => {
288
+ offResize();
289
+ offScroll();
290
+ };
291
+ });
292
+
141
293
  function handleDateChange(detail: { value: string }) {
142
- date = detail.value;
143
- if (!time) time = '09:00';
144
- emit();
145
- if (closeOnSelect && date && time) setOpen(false);
294
+ setPanelDate(detail.value);
295
+ const currentTime = confirmMode ? draftTime : time;
296
+ if (!currentTime) setPanelTime('09:00');
297
+ if (!confirmMode) {
298
+ emit();
299
+ if (closeOnSelect && date && time) setOpen(false);
300
+ }
146
301
  }
147
302
 
148
303
  function pickHour(h: number) {
149
304
  const m = selectedM ?? 0;
150
- time = toTime(h, m);
151
- emit();
305
+ setPanelTime(toTime(h, m));
306
+ if (!confirmMode) emit();
152
307
  }
153
308
 
154
309
  function pickMinute(m: number) {
155
310
  const h = selectedH ?? 9;
156
- time = toTime(h, m);
157
- emit();
158
- if (closeOnSelect && date && time) setOpen(false);
311
+ setPanelTime(toTime(h, m));
312
+ if (!confirmMode) {
313
+ emit();
314
+ if (closeOnSelect && date && time) setOpen(false);
315
+ }
159
316
  }
160
317
 
161
318
  function clear(e: MouseEvent) {
@@ -184,14 +341,13 @@
184
341
  });
185
342
  </script>
186
343
 
187
- <svelte:document onpointerdown={onDocPointerDown} onkeydown={onKey} />
188
-
189
- <div class={['relative w-full min-w-[20rem] max-w-[22rem]', className]} bind:this={rootEl}>
344
+ <div class={['w-full min-w-[20rem] max-w-[22rem]', className]}>
190
345
  {#if label}
191
346
  <span class="mb-1.5 block text-sm font-medium text-primary">{label}</span>
192
347
  {/if}
193
348
 
194
349
  <div
350
+ bind:this={triggerEl}
195
351
  class={[
196
352
  'flex h-10 w-full items-center overflow-hidden rounded-xl border border-border bg-surface-elevated transition-colors',
197
353
  open && 'border-brand-500 ring-2 ring-brand-500/20',
@@ -201,8 +357,11 @@
201
357
  <button
202
358
  type="button"
203
359
  {disabled}
204
- onclick={() => setOpen(!open)}
360
+ popovertarget={panelId}
361
+ popovertargetaction="toggle"
205
362
  aria-expanded={open}
363
+ aria-haspopup="dialog"
364
+ aria-controls={panelId}
206
365
  class={[
207
366
  'flex h-full min-w-0 flex-1 items-center gap-2 px-3.5 text-left text-sm',
208
367
  'hover:bg-surface-overlay focus-visible:outline-none',
@@ -241,85 +400,125 @@
241
400
  {/if}
242
401
  </div>
243
402
 
244
- {#if open}
245
- <div
246
- role="dialog"
247
- aria-label="Choose date and time"
248
- class="absolute left-0 z-50 mt-2 flex w-max max-w-[min(100vw-1rem,42rem)] flex-col overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl sm:flex-row sm:items-stretch"
249
- onpointerdown={(e) => e.stopPropagation()}
250
- >
251
- <div class="w-[22rem] shrink-0 p-3.5">
252
- <Calendar
253
- mode="single"
254
- bind:value={date}
255
- {min}
256
- {max}
257
- {disabledDates}
258
- {dots}
259
- framed={false}
260
- class="w-full"
261
- onchange={handleDateChange}
262
- />
263
- </div>
264
- <div
265
- class="flex w-full shrink-0 flex-col border-t border-border sm:w-40 sm:border-l sm:border-t-0"
266
- >
267
- <span
268
- class="shrink-0 border-b border-border px-3 py-2.5 text-center text-[11px] font-medium uppercase tracking-wide text-muted"
403
+ <div
404
+ bind:this={panelEl}
405
+ id={panelId}
406
+ popover="auto"
407
+ role="dialog"
408
+ aria-label="Choose date and time"
409
+ onbeforetoggle={handleBeforeToggle}
410
+ ontoggle={handleToggle}
411
+ data-placed={placed ? true : undefined}
412
+ class="datetime-picker-popover m-0 flex w-max max-w-[min(100vw-1rem,42rem)] flex-col overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl outline-none"
413
+ >
414
+ {#key open}
415
+ <div class="flex flex-col sm:flex-row sm:items-stretch">
416
+ <div class="w-[22rem] shrink-0 p-1.5">
417
+ <Calendar
418
+ mode="single"
419
+ value={panelDate}
420
+ {min}
421
+ {max}
422
+ {disabledDates}
423
+ {dots}
424
+ framed={false}
425
+ class="w-full"
426
+ onchange={handleDateChange}
427
+ />
428
+ </div>
429
+ <div
430
+ class="flex w-full shrink-0 flex-col border-t border-border sm:w-40 sm:border-l sm:border-t-0"
269
431
  >
270
- Time
271
- </span>
272
- <div class="grid min-h-0 flex-1 grid-cols-2 divide-x divide-border">
273
- <div
274
- bind:this={hourListEl}
275
- class="h-[18.5rem] overflow-y-auto p-1.5"
276
- role="listbox"
277
- aria-label="Hours"
432
+ <span
433
+ class="shrink-0 border-b border-border px-3 py-2.5 text-center text-[11px] font-medium uppercase tracking-wide text-muted"
278
434
  >
279
- {#each hours24 as h (h)}
280
- <button
281
- type="button"
282
- role="option"
283
- aria-pressed={selectedH === h}
284
- aria-selected={selectedH === h}
285
- onclick={() => pickHour(h)}
286
- class={[
287
- 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
288
- selectedH === h
289
- ? 'bg-brand-500 font-semibold text-white'
290
- : 'text-primary hover:bg-surface-overlay'
291
- ]}
292
- >
293
- {hourLabel(h)}
294
- </button>
295
- {/each}
296
- </div>
297
- <div
298
- bind:this={minuteListEl}
299
- class="h-[18.5rem] overflow-y-auto p-1.5"
300
- role="listbox"
301
- aria-label="Minutes"
302
- >
303
- {#each minutes as m (m)}
304
- <button
305
- type="button"
306
- role="option"
307
- aria-pressed={selectedM === m}
308
- aria-selected={selectedM === m}
309
- onclick={() => pickMinute(m)}
310
- class={[
311
- 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
312
- selectedM === m
313
- ? 'bg-brand-500 font-semibold text-white'
314
- : 'text-primary hover:bg-surface-overlay'
315
- ]}
316
- >
317
- {pad(m)}
318
- </button>
319
- {/each}
435
+ Time
436
+ </span>
437
+ <div class="grid min-h-0 flex-1 grid-cols-2 divide-x divide-border">
438
+ <div
439
+ bind:this={hourListEl}
440
+ class="h-[18.5rem] overflow-y-auto p-1.5"
441
+ role="listbox"
442
+ aria-label="Hours"
443
+ >
444
+ {#each hours24 as h (h)}
445
+ <button
446
+ type="button"
447
+ role="option"
448
+ aria-pressed={selectedH === h}
449
+ aria-selected={selectedH === h}
450
+ onclick={() => pickHour(h)}
451
+ class={[
452
+ 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
453
+ selectedH === h
454
+ ? 'bg-brand-500 font-semibold text-white'
455
+ : 'text-primary hover:bg-surface-overlay'
456
+ ]}
457
+ >
458
+ {hourLabel(h)}
459
+ </button>
460
+ {/each}
461
+ </div>
462
+ <div
463
+ bind:this={minuteListEl}
464
+ class="h-[18.5rem] overflow-y-auto p-1.5"
465
+ role="listbox"
466
+ aria-label="Minutes"
467
+ >
468
+ {#each minutes as m (m)}
469
+ <button
470
+ type="button"
471
+ role="option"
472
+ aria-pressed={selectedM === m}
473
+ aria-selected={selectedM === m}
474
+ onclick={() => pickMinute(m)}
475
+ class={[
476
+ 'w-full rounded-lg px-2 py-1.5 text-sm transition-colors',
477
+ selectedM === m
478
+ ? 'bg-brand-500 font-semibold text-white'
479
+ : 'text-primary hover:bg-surface-overlay'
480
+ ]}
481
+ >
482
+ {pad(m)}
483
+ </button>
484
+ {/each}
485
+ </div>
320
486
  </div>
321
487
  </div>
322
488
  </div>
323
- </div>
324
- {/if}
489
+ {#if confirmMode}
490
+ <div class="flex items-center justify-end gap-2 border-t border-border px-3 py-2.5">
491
+ <Button type="button" variant="ghost" size="sm" onclick={cancelDraft}>
492
+ {resolvedCancelLabel}
493
+ </Button>
494
+ <Button type="button" size="sm" disabled={!draftDate} onclick={commitDraft}>
495
+ {resolvedSaveLabel}
496
+ </Button>
497
+ </div>
498
+ {/if}
499
+ {/key}
500
+ </div>
325
501
  </div>
502
+
503
+ <style>
504
+ .datetime-picker-popover {
505
+ position: fixed;
506
+ inset: unset;
507
+ margin: 0;
508
+ width: max-content;
509
+ height: max-content;
510
+ }
511
+
512
+ .datetime-picker-popover:popover-open {
513
+ display: flex;
514
+ }
515
+
516
+ .datetime-picker-popover:popover-open:not([data-placed]) {
517
+ visibility: hidden;
518
+ pointer-events: none;
519
+ }
520
+
521
+ .datetime-picker-popover:not(:popover-open) {
522
+ display: none;
523
+ }
524
+ </style>
@@ -17,7 +17,13 @@ interface DateTimePickerProps {
17
17
  dots?: CalendarDot[];
18
18
  format?: TimeFormat;
19
19
  minuteStep?: number;
20
+ /**
21
+ * When false (default), edits stay in the panel until Save; Cancel discards.
22
+ * When true, each pick commits immediately and may close the panel.
23
+ */
20
24
  closeOnSelect?: boolean;
25
+ cancelLabel?: string;
26
+ saveLabel?: string;
21
27
  class?: string;
22
28
  onchange?: (detail: {
23
29
  date: string;
@@ -6,7 +6,7 @@
6
6
  format = '24h',
7
7
  minuteStep = 5,
8
8
  label = 'Date & time',
9
- closeOnSelect = true
9
+ closeOnSelect = false
10
10
  }: {
11
11
  format?: TimeFormat;
12
12
  minuteStep?: number;
@@ -105,6 +105,9 @@
105
105
  {disabled}
106
106
  {size}
107
107
  {status}
108
+ {min}
109
+ {max}
110
+ {step}
108
111
  type="number"
109
112
  bind:value={text}
110
113
  onblur={() => commit(text)}
@@ -151,6 +154,9 @@
151
154
  {disabled}
152
155
  {size}
153
156
  {status}
157
+ {min}
158
+ {max}
159
+ {step}
154
160
  type="number"
155
161
  bind:value={text}
156
162
  onblur={() => commit(text)}
@@ -193,6 +199,9 @@
193
199
  {disabled}
194
200
  {size}
195
201
  {status}
202
+ {min}
203
+ {max}
204
+ {step}
196
205
  type="number"
197
206
  bind:value={text}
198
207
  onblur={() => commit(text)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.14.14",
3
+ "version": "0.14.16",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",