@makolabs/ripple 3.4.0 → 3.5.0

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.
@@ -28,7 +28,7 @@ export type CalendarProps = {
28
28
  * Day the week starts on. `0` = Sunday, `1` = Monday. @default 1
29
29
  */
30
30
  weekStartsOn?: 0 | 1;
31
- /** Hide the month/year header and navigation buttons. @default false */
31
+ /** Hide the weekday header row above the scrolling month list. @default false */
32
32
  hideHeader?: boolean;
33
33
  /** Disable all interaction. */
34
34
  disabled?: boolean;
@@ -39,6 +39,17 @@ export type CalendarProps = {
39
39
  * oversized. `2xl` aliases `xl`. @default 'md'
40
40
  */
41
41
  size?: VariantSizes;
42
+ /**
43
+ * Distance, in months (fractional allowed), of the open-time
44
+ * scroll-in flourish. The scroll animation starts this far above
45
+ * the anchor month and eases into it. Capped against `minDate`,
46
+ * so dates near the lower bound get a shorter flourish (or none).
47
+ * Set to `0` to disable. The default is shared by embedded
48
+ * Calendar and Calendars-inside-popovers (DatePicker, DateRange)
49
+ * so the open animation feels identical across all date pickers.
50
+ * @default 0.5
51
+ */
52
+ scrollFlourish?: number;
42
53
  /** Wrapper class. */
43
54
  class?: ClassValue;
44
55
  /**
@@ -53,11 +64,10 @@ export type CalendarProps = {
53
64
  /**
54
65
  * Test ID prefix. When set, the component emits these selectors:
55
66
  * - `{testId}-calendar` — root wrapper
56
- * - `{testId}-calendar-prev-month` — prev button
57
- * - `{testId}-calendar-next-month` — next button
58
- * - `{testId}-calendar-month-label` — month/year header
59
67
  * - `{testId}-calendar-day-headers` — weekday row
60
- * - `{testId}-calendar-day-{dayNumber}` — each day cell
68
+ * - `{testId}-calendar-scroll` — scrollable month list
69
+ * - `{testId}-calendar-month-label-{yearMonthKey}` — sticky month/year header per month
70
+ * - `{testId}-calendar-day-{dayNumber}` — each day cell (repeats across months)
61
71
  */
62
72
  testId?: string;
63
73
  };
@@ -38,7 +38,6 @@
38
38
 
39
39
  const display = $derived(value ? formatDate(value) : '');
40
40
  const hasErrors = $derived(errors.length > 0);
41
-
42
41
  const tokens = $derived(formSizeTokens[size]);
43
42
 
44
43
  function clear(e: MouseEvent) {
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tick } from 'svelte';
2
3
  import { cn } from '../../helper/cls.js';
3
4
  import { buildTestId } from '../../helper/testid.js';
4
5
  import { Size } from '../../variants.js';
@@ -25,6 +26,8 @@
25
26
 
26
27
  let open = $state(false);
27
28
  let listEl = $state<HTMLDivElement | undefined>();
29
+ let topSentinelEl = $state<HTMLDivElement | undefined>();
30
+ let bottomSentinelEl = $state<HTMLDivElement | undefined>();
28
31
  const tokens = $derived(formSizeTokens[size]);
29
32
  const hasErrors = $derived(errors.length > 0);
30
33
 
@@ -60,52 +63,68 @@
60
63
 
61
64
  const display = $derived(value ? `${MONTH_NAMES[value.getMonth()]} ${value.getFullYear()}` : '');
62
65
 
63
- // Build a flat list of months spanning a range of years. Default:
64
- // 5 years before → 5 years after the anchor (selected or today).
65
- const RANGE_YEARS = 5;
66
- type MonthEntry = { year: number; month: number; date: Date; label: string; disabled: boolean };
66
+ // Lazy infinite-scroll window over years. The list starts as a small
67
+ // pad around the anchor (selected month or today) and grows toward
68
+ // either edge as IntersectionObserver sentinels enter the viewport,
69
+ // stopping at minDate/maxDate when those are set.
70
+ const INITIAL_PAD = 3;
71
+ const EXTEND_BY = 5;
67
72
 
68
- const anchor = $derived(value ?? new Date());
69
- const startYear = $derived(minDate ? minDate.getFullYear() : anchor.getFullYear() - RANGE_YEARS);
70
- const endYear = $derived(maxDate ? maxDate.getFullYear() : anchor.getFullYear() + RANGE_YEARS);
73
+ const anchorYear = $derived((value ?? new Date()).getFullYear());
74
+ const minYear = $derived(minDate ? minDate.getFullYear() : null);
75
+ const maxYear = $derived(maxDate ? maxDate.getFullYear() : null);
71
76
 
72
- const months = $derived.by<MonthEntry[]>(() => {
73
- const out: MonthEntry[] = [];
74
- for (let y = startYear; y <= endYear; y++) {
75
- for (let m = 0; m < 12; m++) {
76
- const d = new Date(y, m, 1);
77
- let disabled = false;
78
- if (minDate && d < new Date(minDate.getFullYear(), minDate.getMonth(), 1)) disabled = true;
79
- if (maxDate && d > new Date(maxDate.getFullYear(), maxDate.getMonth(), 1)) disabled = true;
80
- out.push({
81
- year: y,
82
- month: m,
83
- date: d,
84
- label: MONTH_NAMES[m],
85
- disabled
86
- });
87
- }
77
+ let firstYear = $state<number>(new Date().getFullYear());
78
+ let lastYear = $state<number>(new Date().getFullYear());
79
+ // `extending` guards against re-entrant extends while a previous
80
+ // extension is still flushing through tick().
81
+ let extending = false;
82
+
83
+ // Initialize / reset the visible window whenever the anchor or bounds
84
+ // change. We deliberately do not depend on firstYear/lastYear here so
85
+ // extensions don't re-trigger this effect.
86
+ $effect(() => {
87
+ const a = anchorYear;
88
+ const lo = minYear;
89
+ const hi = maxYear;
90
+ let f = a - INITIAL_PAD;
91
+ let l = a + INITIAL_PAD;
92
+ if (lo !== null) f = Math.max(f, lo);
93
+ if (hi !== null) l = Math.min(l, hi);
94
+ if (f > l) {
95
+ f = l = a;
88
96
  }
97
+ firstYear = f;
98
+ lastYear = l;
99
+ });
100
+
101
+ const years = $derived.by<number[]>(() => {
102
+ const out: number[] = [];
103
+ for (let y = firstYear; y <= lastYear; y++) out.push(y);
89
104
  return out;
90
105
  });
91
106
 
92
- // Unique sorted years and a fast lookup by year*12+month key.
93
- const years = $derived([...new Set(months.map((e) => e.year))]);
94
- const monthMap = $derived(new Map(months.map((e) => [e.year * 12 + e.month, e])));
107
+ function isMonthDisabled(year: number, month: number): boolean {
108
+ const d = new Date(year, month, 1);
109
+ if (minDate && d < new Date(minDate.getFullYear(), minDate.getMonth(), 1)) return true;
110
+ if (maxDate && d > new Date(maxDate.getFullYear(), maxDate.getMonth(), 1)) return true;
111
+ return false;
112
+ }
95
113
 
96
- function isSelected(entry: MonthEntry): boolean {
97
- return !!value && value.getFullYear() === entry.year && value.getMonth() === entry.month;
114
+ function isSelected(year: number, month: number): boolean {
115
+ return !!value && value.getFullYear() === year && value.getMonth() === month;
98
116
  }
99
117
 
100
- function isCurrent(entry: MonthEntry): boolean {
118
+ function isCurrent(year: number, month: number): boolean {
101
119
  const now = new Date();
102
- return entry.year === now.getFullYear() && entry.month === now.getMonth();
120
+ return year === now.getFullYear() && month === now.getMonth();
103
121
  }
104
122
 
105
- function pick(entry: MonthEntry) {
106
- if (entry.disabled) return;
107
- value = entry.date;
108
- onselect?.(entry.date);
123
+ function pick(year: number, month: number) {
124
+ if (isMonthDisabled(year, month)) return;
125
+ const d = new Date(year, month, 1);
126
+ value = d;
127
+ onselect?.(d);
109
128
  open = false;
110
129
  }
111
130
 
@@ -126,6 +145,61 @@
126
145
  open = false;
127
146
  }
128
147
 
148
+ async function extendBackward() {
149
+ if (extending) return;
150
+ if (minYear !== null && firstYear <= minYear) return;
151
+ const el = listEl;
152
+ if (!el) return;
153
+ extending = true;
154
+ const prevHeight = el.scrollHeight;
155
+ const prevTop = el.scrollTop;
156
+ let next = firstYear - EXTEND_BY;
157
+ if (minYear !== null && next < minYear) next = minYear;
158
+ firstYear = next;
159
+ // Wait for the new year sections to render, then preserve the
160
+ // user's visual scroll position by adding the height delta.
161
+ await tick();
162
+ const newHeight = el.scrollHeight;
163
+ el.scrollTop = prevTop + (newHeight - prevHeight);
164
+ extending = false;
165
+ }
166
+
167
+ async function extendForward() {
168
+ if (extending) return;
169
+ if (maxYear !== null && lastYear >= maxYear) return;
170
+ extending = true;
171
+ let next = lastYear + EXTEND_BY;
172
+ if (maxYear !== null && next > maxYear) next = maxYear;
173
+ lastYear = next;
174
+ await tick();
175
+ extending = false;
176
+ }
177
+
178
+ // Wire IntersectionObserver to the sentinels while the panel is open.
179
+ // Skipped where IO isn't available (SSR / jsdom tests) — the initial
180
+ // pad still renders, just without lazy extension.
181
+ $effect(() => {
182
+ if (!open) return;
183
+ if (typeof IntersectionObserver === 'undefined') return;
184
+ const root = listEl;
185
+ const top = topSentinelEl;
186
+ const bottom = bottomSentinelEl;
187
+ if (!root || !top || !bottom) return;
188
+ const io = new IntersectionObserver(
189
+ (entries) => {
190
+ for (const entry of entries) {
191
+ if (!entry.isIntersecting) continue;
192
+ if (entry.target === top) extendBackward();
193
+ else if (entry.target === bottom) extendForward();
194
+ }
195
+ },
196
+ { root, rootMargin: '200px 0px' }
197
+ );
198
+ io.observe(top);
199
+ io.observe(bottom);
200
+ return () => io.disconnect();
201
+ });
202
+
129
203
  // Auto-scroll to the selected (or current) month when the panel opens.
130
204
  $effect(() => {
131
205
  if (!open || !listEl) return;
@@ -133,7 +207,11 @@
133
207
  const target =
134
208
  listEl?.querySelector('[data-selected="true"]') ??
135
209
  listEl?.querySelector('[data-current="true"]');
136
- target?.scrollIntoView({ block: 'center' });
210
+ // jsdom doesn't implement scrollIntoView — guard so unit tests
211
+ // don't surface unhandled errors.
212
+ if (target && typeof (target as HTMLElement).scrollIntoView === 'function') {
213
+ (target as HTMLElement).scrollIntoView({ block: 'center' });
214
+ }
137
215
  });
138
216
  });
139
217
  </script>
@@ -243,6 +321,7 @@
243
321
  bind:this={listEl}
244
322
  class="max-h-72 overflow-y-auto [scrollbar-width:none] sm:max-h-72 [&::-webkit-scrollbar]:hidden"
245
323
  >
324
+ <div bind:this={topSentinelEl} aria-hidden="true" class="h-px"></div>
246
325
  {#each years as year (year)}
247
326
  {@const yearSelected = value?.getFullYear() === year}
248
327
  <div
@@ -257,17 +336,16 @@
257
336
  </div>
258
337
  <div class="grid grid-cols-4 gap-0.5 p-1.5">
259
338
  {#each MONTH_SHORT as monthLabel, m (m)}
260
- {@const entry = monthMap.get(year * 12 + m)}
261
- {@const sel = entry ? isSelected(entry) : false}
262
- {@const cur = entry ? isCurrent(entry) : false}
263
- {@const dis = entry?.disabled ?? true}
339
+ {@const sel = isSelected(year, m)}
340
+ {@const cur = isCurrent(year, m)}
341
+ {@const dis = isMonthDisabled(year, m)}
264
342
  <button
265
343
  type="button"
266
344
  data-month={m}
267
345
  data-testid={buildTestId('month-picker', 'month', testId, `${year}-${m}`)}
268
346
  data-selected={sel || undefined}
269
347
  data-current={cur || undefined}
270
- onclick={() => entry && pick(entry)}
348
+ onclick={() => pick(year, m)}
271
349
  disabled={dis}
272
350
  class={cn(
273
351
  'cursor-pointer rounded px-1 py-2 text-xs font-medium transition-colors sm:py-1',
@@ -284,6 +362,7 @@
284
362
  {/each}
285
363
  </div>
286
364
  {/each}
365
+ <div bind:this={bottomSentinelEl} aria-hidden="true" class="h-px"></div>
287
366
  </div>
288
367
 
289
368
  <div class="border-default-200 flex items-center justify-between border-t px-3 py-2">
package/dist/index.d.ts CHANGED
@@ -46,6 +46,7 @@ export type { ProgressSegment, ProgressProps } from './elements/progress/progres
46
46
  export type { SpinnerProps } from './elements/spinner/spinner-types.js';
47
47
  export type { SkeletonProps, SkeletonVariant } from './elements/skeleton/skeleton-types.js';
48
48
  export type { EmptyStateProps, EmptyStateSize } from './elements/empty-state/empty-state-types.js';
49
+ export type { PdfViewerProps } from './elements/pdf-viewer/pdf-viewer-types.js';
49
50
  export type { TooltipProps, TooltipPlacement, TooltipSize, TooltipVariant } from './elements/tooltip/tooltip-types.js';
50
51
  export type { PopoverProps, PopoverPlacement, PopoverTrigger } from './elements/popover/popover-types.js';
51
52
  export type { CalendarProps, CalendarMode } from './forms/calendar/calendar-types.js';
@@ -99,6 +100,7 @@ export { default as Skeleton } from './elements/skeleton/Skeleton.svelte';
99
100
  export { spinner as spinnerVariants } from './elements/spinner/spinner.js';
100
101
  export { default as EmptyState } from './elements/empty-state/EmptyState.svelte';
101
102
  export { emptyState as emptyStateVariants } from './elements/empty-state/empty-state.js';
103
+ export { default as PdfViewer } from './elements/pdf-viewer/PdfViewer.svelte';
102
104
  export { default as Tooltip } from './elements/tooltip/Tooltip.svelte';
103
105
  export { default as Popover } from './elements/popover/Popover.svelte';
104
106
  export { default as ComboBox } from './elements/combobox/ComboBox.svelte';
package/dist/index.js CHANGED
@@ -76,6 +76,8 @@ export { spinner as spinnerVariants } from './elements/spinner/spinner.js';
76
76
  // Elements - EmptyState
77
77
  export { default as EmptyState } from './elements/empty-state/EmptyState.svelte';
78
78
  export { emptyState as emptyStateVariants } from './elements/empty-state/empty-state.js';
79
+ // Elements - PdfViewer
80
+ export { default as PdfViewer } from './elements/pdf-viewer/PdfViewer.svelte';
79
81
  // Elements - Tooltip
80
82
  export { default as Tooltip } from './elements/tooltip/Tooltip.svelte';
81
83
  // Elements - Popover
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makolabs/ripple",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Simple Svelte 5 powered component library ✨",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -55,8 +55,14 @@
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@sveltejs/kit": "^2.0.0",
58
+ "pdfjs-dist": "^4.0.0 || ^5.0.0",
58
59
  "svelte": "^5.0.0 || ^6.0.0"
59
60
  },
61
+ "peerDependenciesMeta": {
62
+ "pdfjs-dist": {
63
+ "optional": true
64
+ }
65
+ },
60
66
  "devDependencies": {
61
67
  "@eslint/compat": "^1.4.1",
62
68
  "@eslint/js": "^9.39.1",
@@ -81,6 +87,7 @@
81
87
  "husky": "^9.1.7",
82
88
  "jsdom": "^27.2.0",
83
89
  "lint-staged": "^16.2.6",
90
+ "pdfjs-dist": "^5.4.394",
84
91
  "prettier": "^3.4.2",
85
92
  "prettier-plugin-svelte": "^3.3.3",
86
93
  "prettier-plugin-tailwindcss": "^0.7.1",