@r2digisolutions/components 0.14.13 → 0.14.15

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.
@@ -179,8 +179,17 @@
179
179
  });
180
180
  }
181
181
 
182
+ function commitPendingQuery() {
183
+ const trimmed = query.trim();
184
+ if (!creatable || !trimmed || trimmed === value) return;
185
+ value = trimmed;
186
+ onchange?.(trimmed);
187
+ }
188
+
182
189
  function onPopoverClose() {
190
+ commitPendingQuery();
183
191
  if (selected) query = selected.label;
192
+ else if (value) query = value;
184
193
  else if (!creatable) query = '';
185
194
  }
186
195
 
@@ -300,8 +309,11 @@
300
309
  }
301
310
 
302
311
  $effect(() => {
303
- if (selected && !open && query !== selected.label) {
312
+ if (open) return;
313
+ if (selected && query !== selected.label) {
304
314
  query = selected.label;
315
+ } else if (value && query !== value) {
316
+ query = value;
305
317
  }
306
318
  });
307
319
 
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">
2
+ import { on } from 'svelte/events';
2
3
  import Calendar from '../Calendar/Calendar.svelte';
3
4
  import type { CalendarDot } from '../Calendar/Calendar.svelte';
4
5
  import type { TimeFormat } from '../TimePicker/TimePicker.svelte';
6
+ import { createId } from '../../../utils/id.js';
5
7
 
6
8
  interface DateTimePickerProps {
7
9
  /** Date part `YYYY-MM-DD`. */
@@ -44,9 +46,16 @@
44
46
  onchange
45
47
  }: DateTimePickerProps = $props();
46
48
 
47
- let rootEl = $state<HTMLDivElement | null>(null);
49
+ let triggerEl = $state<HTMLElement | null>(null);
50
+ let panelEl = $state<HTMLDivElement | null>(null);
48
51
  let hourListEl = $state<HTMLDivElement | null>(null);
49
52
  let minuteListEl = $state<HTMLDivElement | null>(null);
53
+ let placed = $state(false);
54
+ let panelId = $state('');
55
+
56
+ $effect(() => {
57
+ panelId ||= createId('datetimepicker');
58
+ });
50
59
 
51
60
  const hours24 = Array.from({ length: 24 }, (_, i) => i);
52
61
  const minutes = $derived(
@@ -82,7 +91,6 @@
82
91
  onchange?.({ date, time, value });
83
92
  }
84
93
 
85
- // Keep parts in sync when `value` is set externally
86
94
  $effect(() => {
87
95
  if (!value) return;
88
96
  const [d, t] = value.includes('T') ? value.split('T') : [value, time];
@@ -122,22 +130,121 @@
122
130
  return formatTimeDisplay(time);
123
131
  });
124
132
 
133
+ function estimatePanelSize() {
134
+ return { panelW: 672, panelH: 380 };
135
+ }
136
+
137
+ function positionPanel(opts: { measure?: boolean; show?: boolean } = {}) {
138
+ if (!triggerEl) return;
139
+ if (opts.show) placed = true;
140
+
141
+ const trigger = triggerEl.getBoundingClientRect();
142
+ if (trigger.width < 2 && trigger.height < 2) return;
143
+
144
+ const vv = window.visualViewport;
145
+ const viewW = vv?.width ?? window.innerWidth;
146
+ const viewH = vv?.height ?? window.innerHeight;
147
+ const viewLeft = vv?.offsetLeft ?? 0;
148
+ const viewTop = vv?.offsetTop ?? 0;
149
+ const gap = 8;
150
+ const pad = 8;
151
+ const estimated = estimatePanelSize();
152
+ const canMeasure = opts.measure !== false && !!panelEl?.matches(':popover-open');
153
+ const panelW = Math.min(
154
+ canMeasure && panelEl?.offsetWidth ? panelEl.offsetWidth : estimated.panelW,
155
+ viewW - pad * 2
156
+ );
157
+ const panelH = Math.min(
158
+ canMeasure && panelEl?.offsetHeight ? panelEl.offsetHeight : estimated.panelH,
159
+ viewH - pad * 2
160
+ );
161
+
162
+ const spaceBelow = viewTop + viewH - trigger.bottom - gap - pad;
163
+ const spaceAbove = trigger.top - viewTop - gap - pad;
164
+ const side: 'top' | 'bottom' = spaceBelow < panelH && spaceAbove > spaceBelow ? 'top' : 'bottom';
165
+
166
+ let top = side === 'bottom' ? trigger.bottom + gap : trigger.top - panelH - gap;
167
+ let left = trigger.left;
168
+
169
+ left = Math.min(Math.max(viewLeft + pad, left), viewLeft + viewW - panelW - pad);
170
+ top = Math.min(Math.max(viewTop + pad, top), viewTop + viewH - panelH - pad);
171
+
172
+ if (panelEl) {
173
+ const s = panelEl.style;
174
+ s.setProperty('margin', '0');
175
+ s.setProperty('inset', 'auto');
176
+ s.setProperty('top', `${Math.round(top)}px`);
177
+ s.setProperty('left', `${Math.round(left)}px`);
178
+ s.setProperty('right', 'auto');
179
+ s.setProperty('bottom', 'auto');
180
+ s.setProperty('width', 'max-content');
181
+ s.setProperty('visibility', placed ? 'visible' : 'hidden');
182
+ }
183
+ }
184
+
185
+ function schedulePosition() {
186
+ queueMicrotask(() => {
187
+ positionPanel();
188
+ requestAnimationFrame(() => {
189
+ positionPanel();
190
+ requestAnimationFrame(() => positionPanel({ show: true }));
191
+ });
192
+ });
193
+ }
194
+
195
+ function syncNative() {
196
+ if (!panelEl) return;
197
+ const isOpen = panelEl.matches(':popover-open');
198
+ try {
199
+ if (open && !isOpen) panelEl.showPopover();
200
+ else if (!open && isOpen) panelEl.hidePopover();
201
+ } catch {
202
+ /* ignore */
203
+ }
204
+ }
205
+
125
206
  function setOpen(next: boolean) {
126
- if (disabled) return;
207
+ if (disabled && next) return;
127
208
  open = next;
128
209
  }
129
210
 
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);
211
+ function handleBeforeToggle(event: ToggleEvent) {
212
+ if (event.newState === 'open') {
213
+ if (disabled) {
214
+ event.preventDefault();
215
+ return;
216
+ }
217
+ placed = false;
218
+ positionPanel({ measure: false });
219
+ } else {
220
+ placed = false;
221
+ }
135
222
  }
136
223
 
137
- function onKey(e: KeyboardEvent) {
138
- if (e.key === 'Escape' && open) setOpen(false);
224
+ function handleToggle(event: ToggleEvent) {
225
+ open = event.newState === 'open';
226
+ if (open) schedulePosition();
227
+ else placed = false;
139
228
  }
140
229
 
230
+ $effect(() => {
231
+ open;
232
+ queueMicrotask(() => {
233
+ syncNative();
234
+ if (open) positionPanel();
235
+ });
236
+ });
237
+
238
+ $effect(() => {
239
+ if (!open) return;
240
+ const offResize = on(window, 'resize', () => positionPanel());
241
+ const offScroll = on(window, 'scroll', () => positionPanel(), { capture: true });
242
+ return () => {
243
+ offResize();
244
+ offScroll();
245
+ };
246
+ });
247
+
141
248
  function handleDateChange(detail: { value: string }) {
142
249
  date = detail.value;
143
250
  if (!time) time = '09:00';
@@ -184,14 +291,13 @@
184
291
  });
185
292
  </script>
186
293
 
187
- <svelte:document onpointerdown={onDocPointerDown} onkeydown={onKey} />
188
-
189
- <div class={['relative w-full min-w-[20rem] max-w-[22rem]', className]} bind:this={rootEl}>
294
+ <div class={['w-full min-w-[20rem] max-w-[22rem]', className]}>
190
295
  {#if label}
191
296
  <span class="mb-1.5 block text-sm font-medium text-primary">{label}</span>
192
297
  {/if}
193
298
 
194
299
  <div
300
+ bind:this={triggerEl}
195
301
  class={[
196
302
  'flex h-10 w-full items-center overflow-hidden rounded-xl border border-border bg-surface-elevated transition-colors',
197
303
  open && 'border-brand-500 ring-2 ring-brand-500/20',
@@ -201,8 +307,11 @@
201
307
  <button
202
308
  type="button"
203
309
  {disabled}
204
- onclick={() => setOpen(!open)}
310
+ popovertarget={panelId}
311
+ popovertargetaction="toggle"
205
312
  aria-expanded={open}
313
+ aria-haspopup="dialog"
314
+ aria-controls={panelId}
206
315
  class={[
207
316
  'flex h-full min-w-0 flex-1 items-center gap-2 px-3.5 text-left text-sm',
208
317
  'hover:bg-surface-overlay focus-visible:outline-none',
@@ -241,14 +350,19 @@
241
350
  {/if}
242
351
  </div>
243
352
 
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">
353
+ <div
354
+ bind:this={panelEl}
355
+ id={panelId}
356
+ popover="auto"
357
+ role="dialog"
358
+ aria-label="Choose date and time"
359
+ onbeforetoggle={handleBeforeToggle}
360
+ ontoggle={handleToggle}
361
+ data-placed={placed ? true : undefined}
362
+ 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 p-2 shadow-xl outline-none sm:flex-row sm:items-stretch"
363
+ >
364
+ {#key open}
365
+ <div class="w-[22rem] shrink-0 p-1.5">
252
366
  <Calendar
253
367
  mode="single"
254
368
  bind:value={date}
@@ -320,6 +434,29 @@
320
434
  </div>
321
435
  </div>
322
436
  </div>
323
- </div>
324
- {/if}
437
+ {/key}
438
+ </div>
325
439
  </div>
440
+
441
+ <style>
442
+ .datetime-picker-popover {
443
+ position: fixed;
444
+ inset: unset;
445
+ margin: 0;
446
+ width: max-content;
447
+ height: max-content;
448
+ }
449
+
450
+ .datetime-picker-popover:popover-open {
451
+ display: flex;
452
+ }
453
+
454
+ .datetime-picker-popover:popover-open:not([data-placed]) {
455
+ visibility: hidden;
456
+ pointer-events: none;
457
+ }
458
+
459
+ .datetime-picker-popover:not(:popover-open) {
460
+ display: none;
461
+ }
462
+ </style>
@@ -75,8 +75,9 @@
75
75
  }
76
76
  </script>
77
77
 
78
- <div class={['w-full space-y-1', className]}>
78
+ <div class="w-full space-y-1">
79
79
  <Combobox
80
+ class={className}
80
81
  {label}
81
82
  {placeholder}
82
83
  {options}
@@ -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.13",
3
+ "version": "0.14.15",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",