@r2digisolutions/components 0.14.18 → 0.14.20

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.
@@ -81,6 +81,10 @@
81
81
  let panel = $state<Panel>('days');
82
82
  /** Which month column owns the month/year picker when months=2. */
83
83
  let pickerOffset = $state(0);
84
+ /** Range drag: anchor day and live preview end (YYYY-MM-DD). */
85
+ let dragAnchor = $state<string | null>(null);
86
+ let dragPreviewEnd = $state<string | null>(null);
87
+ let isDragging = $state(false);
84
88
 
85
89
  const weekDays = $derived(
86
90
  Array.from({ length: 7 }, (_, i) =>
@@ -214,22 +218,105 @@
214
218
  return false;
215
219
  }
216
220
 
221
+ function rangeDisplayStart() {
222
+ if (mode !== 'range') return start;
223
+ if (isDragging && dragAnchor) {
224
+ if (!dragPreviewEnd) return dragAnchor;
225
+ return dragAnchor <= dragPreviewEnd ? dragAnchor : dragPreviewEnd;
226
+ }
227
+ return start;
228
+ }
229
+
230
+ function rangeDisplayEnd() {
231
+ if (mode !== 'range') return end;
232
+ if (isDragging && dragAnchor && dragPreviewEnd) {
233
+ return dragAnchor <= dragPreviewEnd ? dragPreviewEnd : dragAnchor;
234
+ }
235
+ return end;
236
+ }
237
+
217
238
  function isSelected(iso: string) {
218
239
  if (mode === 'single') return value === iso;
219
240
  if (mode === 'multiple') return values.includes(iso);
220
- if (!start) return false;
221
- if (!end) return start === iso;
222
- return iso >= start && iso <= end;
241
+ const rangeStart = rangeDisplayStart();
242
+ const rangeEnd = rangeDisplayEnd();
243
+ if (!rangeStart) return false;
244
+ if (!rangeEnd) return rangeStart === iso;
245
+ return iso >= rangeStart && iso <= rangeEnd;
223
246
  }
224
247
 
225
248
  function isRangeEdge(iso: string) {
226
- return mode === 'range' && (iso === start || iso === end);
249
+ if (mode !== 'range') return false;
250
+ const rangeStart = rangeDisplayStart();
251
+ const rangeEnd = rangeDisplayEnd();
252
+ return iso === rangeStart || iso === rangeEnd;
227
253
  }
228
254
 
229
255
  function isInRange(iso: string) {
230
- return mode === 'range' && !!start && !!end && iso > start && iso < end;
256
+ if (mode !== 'range') return false;
257
+ const rangeStart = rangeDisplayStart();
258
+ const rangeEnd = rangeDisplayEnd();
259
+ return !!rangeStart && !!rangeEnd && iso > rangeStart && iso < rangeEnd;
260
+ }
261
+
262
+ function dayFromTarget(target: EventTarget | null) {
263
+ if (!(target instanceof Element)) return null;
264
+ const button = target.closest('[data-calendar-day]');
265
+ if (!(button instanceof HTMLElement)) return null;
266
+ const iso = button.dataset.calendarDay;
267
+ return iso && !isDisabled(iso) ? iso : null;
268
+ }
269
+
270
+ function beginRangeDrag(iso: string, event: PointerEvent) {
271
+ if (mode !== 'range' || isDisabled(iso)) return;
272
+ event.preventDefault();
273
+ isDragging = true;
274
+ dragAnchor = iso;
275
+ dragPreviewEnd = null;
231
276
  }
232
277
 
278
+ function finishRangeDrag(iso: string | null) {
279
+ if (!dragAnchor) return;
280
+
281
+ if (dragPreviewEnd && dragPreviewEnd !== dragAnchor) {
282
+ const rangeStart = dragAnchor <= dragPreviewEnd ? dragAnchor : dragPreviewEnd;
283
+ const rangeEnd = dragAnchor <= dragPreviewEnd ? dragPreviewEnd : dragAnchor;
284
+ start = rangeStart;
285
+ end = rangeEnd;
286
+ emit();
287
+ } else {
288
+ pick(iso ?? dragAnchor);
289
+ }
290
+
291
+ isDragging = false;
292
+ dragAnchor = null;
293
+ dragPreviewEnd = null;
294
+ }
295
+
296
+ $effect(() => {
297
+ if (!isDragging) return;
298
+
299
+ const onMove = (event: PointerEvent) => {
300
+ const iso = dayFromTarget(document.elementFromPoint(event.clientX, event.clientY));
301
+ if (iso) dragPreviewEnd = iso;
302
+ };
303
+
304
+ const onUp = (event: PointerEvent) => {
305
+ const iso =
306
+ dayFromTarget(document.elementFromPoint(event.clientX, event.clientY)) ?? dragAnchor;
307
+ finishRangeDrag(iso);
308
+ };
309
+
310
+ window.addEventListener('pointermove', onMove);
311
+ window.addEventListener('pointerup', onUp);
312
+ window.addEventListener('pointercancel', onUp);
313
+ return () => {
314
+ window.removeEventListener('pointermove', onMove);
315
+ window.removeEventListener('pointerup', onUp);
316
+ window.removeEventListener('pointercancel', onUp);
317
+ };
318
+ });
319
+
233
320
  function emit() {
234
321
  onchange?.({ mode, value, values, start, end });
235
322
  }
@@ -275,10 +362,13 @@
275
362
  const mid = isInRange(iso);
276
363
  const disabled = isDisabled(iso);
277
364
  const isToday = iso === todayIso;
365
+ const rangeStart = rangeDisplayStart();
366
+ const rangeEnd = rangeDisplayEnd();
278
367
 
279
368
  return [
280
369
  'relative flex size-9 items-center justify-center rounded-lg text-sm transition-colors',
281
370
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
371
+ mode === 'range' && !disabled && 'touch-none select-none',
282
372
  disabled && 'cursor-not-allowed opacity-30',
283
373
  !disabled && !selected && !mid && 'text-primary hover:bg-surface-overlay',
284
374
  mid && 'rounded-none bg-brand-500/15 text-primary',
@@ -286,17 +376,17 @@
286
376
  edge && 'bg-brand-500 font-semibold text-white',
287
377
  selected &&
288
378
  mode === 'range' &&
289
- start &&
290
- end &&
291
- iso === start &&
292
- end !== start &&
379
+ rangeStart &&
380
+ rangeEnd &&
381
+ iso === rangeStart &&
382
+ rangeEnd !== rangeStart &&
293
383
  'rounded-r-none',
294
384
  selected &&
295
385
  mode === 'range' &&
296
- start &&
297
- end &&
298
- iso === end &&
299
- end !== start &&
386
+ rangeStart &&
387
+ rangeEnd &&
388
+ iso === rangeEnd &&
389
+ rangeEnd !== rangeStart &&
300
390
  'rounded-l-none',
301
391
  isToday && !selected && !mid && 'ring-1 ring-brand-500/40'
302
392
  ];
@@ -319,8 +409,12 @@
319
409
  framed && 'rounded-2xl border border-border p-3 shadow-sm',
320
410
  !framed && 'p-0.5',
321
411
  dual && panel === 'days' ? 'w-fit' : 'w-80',
412
+ isDragging && 'select-none',
322
413
  className
323
414
  ]}
415
+ onpointerleave={() => {
416
+ if (isDragging) dragPreviewEnd = null;
417
+ }}
324
418
  >
325
419
  {#if panel === 'days'}
326
420
  <div class={['flex', dual ? 'flex-col gap-4 sm:flex-row sm:gap-2' : 'flex-col']}>
@@ -391,7 +485,22 @@
391
485
  type="button"
392
486
  disabled={isDisabled(cell.date)}
393
487
  aria-pressed={isSelected(cell.date)}
394
- onclick={() => pick(cell.date)}
488
+ data-calendar-day={cell.date}
489
+ onclick={() => {
490
+ if (mode !== 'range') pick(cell.date);
491
+ }}
492
+ onpointerdown={
493
+ mode === 'range'
494
+ ? (event) => beginRangeDrag(cell.date, event)
495
+ : undefined
496
+ }
497
+ onpointerenter={
498
+ mode === 'range' && isDragging && !isDisabled(cell.date)
499
+ ? () => {
500
+ dragPreviewEnd = cell.date;
501
+ }
502
+ : undefined
503
+ }
395
504
  class={dayClass(cell.date)}
396
505
  >
397
506
  <span class="relative z-10">{cell.day}</span>
@@ -79,9 +79,11 @@
79
79
  'focus-visible:ring-brand-500/30 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset',
80
80
  disabled && 'cursor-not-allowed opacity-40',
81
81
  !disabled &&
82
- (item.isHighlighted || item.isSelected
82
+ (item.isHighlighted
83
83
  ? 'bg-brand-500 text-white'
84
- : 'text-primary hover:bg-surface-overlay'),
84
+ : item.isSelected
85
+ ? 'bg-brand-500/10 font-medium text-primary'
86
+ : 'text-primary hover:bg-surface-overlay'),
85
87
  className
86
88
  ]}
87
89
  >
@@ -196,12 +196,19 @@
196
196
  const spaceAbove = rect.top - gap;
197
197
  const openUp = spaceBelow < Math.min(maxHeight, 160) && spaceAbove > spaceBelow;
198
198
  const available = Math.max(120, openUp ? spaceAbove : spaceBelow);
199
+ const maxWidth = Math.min(320, window.innerWidth - 16);
200
+ let left = rect.left;
201
+ if (left + maxWidth > window.innerWidth - 8) {
202
+ left = Math.max(8, window.innerWidth - maxWidth - 8);
203
+ }
199
204
 
200
205
  listboxStyle = [
201
206
  `top: ${openUp ? 'auto' : `${rect.bottom + gap}px`}`,
202
207
  `bottom: ${openUp ? `${window.innerHeight - rect.top + gap}px` : 'auto'}`,
203
- `left: ${rect.left}px`,
204
- `width: ${rect.width}px`,
208
+ `left: ${left}px`,
209
+ `min-width: ${rect.width}px`,
210
+ `width: max-content`,
211
+ `max-width: ${maxWidth}px`,
205
212
  `max-height: ${Math.min(maxHeight, available)}px`
206
213
  ].join('; ');
207
214
  }
@@ -577,7 +584,7 @@
577
584
  >
578
585
  <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
579
586
  </svg>
580
- <span class="truncate">{currentLevel.title}</span>
587
+ <span class="whitespace-nowrap">{currentLevel.title}</span>
581
588
  </button>
582
589
  <div class="border-border mx-1.5 mb-0.5 h-px border-b" role="separator"></div>
583
590
  {/if}
@@ -605,33 +612,41 @@
605
612
  onpointerdown={(e) => handleOptionPointerDown(e, option)}
606
613
  onclick={() => selectOption(option)}
607
614
  onpointerenter={() => {
608
- if (ignoreHover || option.disabled) return;
615
+ if (option.disabled) return;
616
+ ignoreHover = false;
609
617
  highlightedIndex = index;
610
618
  }}
611
619
  class={[
612
620
  'group gap-2.5 rounded-lg px-2.5 py-2 text-sm relative flex w-full items-center text-left outline-none select-none',
613
621
  'transition-[background-color,color,box-shadow] duration-75',
614
622
  option.disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
615
- (isHighlighted || isSelected) && !option.disabled
623
+ isHighlighted && !option.disabled
616
624
  ? 'bg-brand-500 text-white shadow-sm'
617
- : 'text-primary'
625
+ : isSelected && !option.disabled
626
+ ? 'bg-brand-500/10 text-primary font-medium'
627
+ : 'text-primary hover:bg-surface-overlay'
618
628
  ]}
619
629
  >
620
630
  {#if !hasChildren}
621
631
  <span
622
632
  class={[
623
633
  'h-4 w-4 flex shrink-0 items-center justify-center rounded-full border transition-colors duration-75',
624
- (isHighlighted || isSelected) && !option.disabled
634
+ isHighlighted && !option.disabled
625
635
  ? isSelected
626
636
  ? 'border-white bg-white'
627
637
  : 'border-white/60 bg-transparent'
628
- : 'border-border-strong bg-transparent'
638
+ : isSelected && !option.disabled
639
+ ? 'border-brand-500 bg-brand-500'
640
+ : 'border-border-strong bg-transparent'
629
641
  ]}
630
642
  aria-hidden="true"
631
643
  >
632
644
  {#if isSelected}
633
645
  <svg
634
- class="h-2.5 w-2.5 text-brand-600"
646
+ class={[
647
+ 'h-2.5 w-2.5',
648
+ isHighlighted ? 'text-brand-600' : 'text-white'
649
+ ]}
635
650
  viewBox="0 0 24 24"
636
651
  fill="none"
637
652
  stroke="currentColor"
@@ -644,14 +659,12 @@
644
659
  {/if}
645
660
 
646
661
  <span class="min-w-0 flex-1">
647
- <span class={['block truncate', isSelected && 'font-medium']}>
648
- {option.label}
649
- </span>
662
+ <span class="block whitespace-nowrap">{option.label}</span>
650
663
  {#if secondary}
651
664
  <span
652
665
  class={[
653
- 'mt-0.5 block truncate text-[11px]',
654
- (isHighlighted || isSelected) && !option.disabled
666
+ 'mt-0.5 block text-[11px] whitespace-nowrap',
667
+ isHighlighted && !option.disabled
655
668
  ? 'text-white/75'
656
669
  : 'text-secondary'
657
670
  ]}
@@ -665,7 +678,7 @@
665
678
  <svg
666
679
  class={[
667
680
  'h-4 w-4 shrink-0',
668
- (isHighlighted || isSelected) && !option.disabled
681
+ isHighlighted && !option.disabled
669
682
  ? 'text-white/80'
670
683
  : 'text-secondary'
671
684
  ]}
@@ -76,6 +76,8 @@
76
76
  }: DialogProps = $props();
77
77
 
78
78
  let dialogEl = $state<HTMLDialogElement | null>(null);
79
+ /** Ignora clics en backdrop justo tras cerrar un diálogo nativo (p. ej. selector de archivos). */
80
+ let suppressBackdropUntil = 0;
79
81
  const titleId = $derived(`${id}-title`);
80
82
  const descriptionId = $derived(`${id}-description`);
81
83
 
@@ -152,6 +154,15 @@
152
154
  };
153
155
  });
154
156
 
157
+ $effect(() => {
158
+ if (!open || typeof window === 'undefined') return;
159
+ const onWindowFocus = () => {
160
+ suppressBackdropUntil = Date.now() + 400;
161
+ };
162
+ window.addEventListener('focus', onWindowFocus);
163
+ return () => window.removeEventListener('focus', onWindowFocus);
164
+ });
165
+
155
166
  function close(reason: 'close' | 'cancel' | 'confirm' | 'backdrop' | 'escape' = 'close') {
156
167
  if (!open) return;
157
168
  open = false;
@@ -186,6 +197,7 @@
186
197
 
187
198
  function handleBackdropClick(event: MouseEvent) {
188
199
  if (!dialogEl) return;
200
+ if (Date.now() < suppressBackdropUntil) return;
189
201
  const rect = dialogEl.getBoundingClientRect();
190
202
  const clickedInDialog =
191
203
  event.clientX >= rect.left &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.14.18",
3
+ "version": "0.14.20",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",