@rkosafo/cai.components 0.0.79 → 0.0.81

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.
@@ -1,3 +1,7 @@
1
+ <script lang="ts" module>
2
+ let openModalCount = 0;
3
+ </script>
4
+
1
5
  <script lang="ts">
2
6
  import clsx, { type ClassValue } from 'clsx';
3
7
  import { sineIn } from 'svelte/easing';
@@ -64,57 +68,52 @@
64
68
  close: closeCls
65
69
  } = $derived(modalStyle({ placement, size }));
66
70
 
67
- const close = (dlg: HTMLDialogElement) => (open = false);
68
- // @ts-expect-error: dlg.requestClose may not be supported
69
- const cancel = (dlg: HTMLDialogElement) =>
70
- typeof dlg.requestClose === 'function' ? dlg.requestClose() : close();
71
-
72
- function _oncancel(ev: Event & { currentTarget: HTMLDialogElement }) {
73
- if (ev.target !== ev.currentTarget) {
74
- return; // ignore if not on dialog
75
- }
71
+ const close = () => (open = false);
76
72
 
77
- // this event gets called when user canceled the dialog:
78
- // pressesed ESC key, clicked outside, pressed submit button with no 'value' like close button
73
+ function _oncancel(ev: Event) {
79
74
  oncancel?.(ev);
80
75
  if (ev.defaultPrevented) return;
81
-
82
- ev.preventDefault(); // prevent anyway, we need clean close
83
- if (!permanent) close(ev.currentTarget);
76
+ ev.preventDefault();
77
+ if (!permanent) close();
84
78
  }
85
79
 
86
- function _onclick(ev: Event & { currentTarget: HTMLDialogElement }) {
87
- const dlg: HTMLDialogElement = ev.currentTarget;
88
- if (outsideclose && ev.target === dlg) {
89
- return cancel(dlg);
80
+ function _onclick(ev: MouseEvent) {
81
+ const target = ev.target as HTMLElement;
82
+ if (outsideclose && target.dataset.modalBackdrop === 'true') {
83
+ const cancelEvent = new Event('cancel', { cancelable: true });
84
+ oncancel?.(cancelEvent);
85
+ if (!cancelEvent.defaultPrevented && !permanent) {
86
+ close();
87
+ }
88
+ return;
90
89
  }
91
90
 
92
- if (autoclose && ev.target instanceof HTMLButtonElement && !permanent) {
93
- return close(dlg);
91
+ if (autoclose && target instanceof HTMLButtonElement && !permanent) {
92
+ close();
94
93
  }
95
94
  }
96
95
 
97
- function _onsubmit(ev: SubmitEvent & { currentTarget: HTMLDialogElement }) {
96
+ function _onsubmit(ev: SubmitEvent & { currentTarget: HTMLFormElement }) {
98
97
  // When dialog contains the <form method="dialog"> and when child with type="submit" was pressed
99
98
 
100
99
  onsubmit?.(ev);
101
100
  if (ev.defaultPrevented) return;
102
101
 
103
- ev.preventDefault(); // stop dialog.close()
104
-
105
- const dlg: HTMLDialogElement = ev.currentTarget;
102
+ ev.preventDefault();
103
+ const panel: HTMLElement = ref as HTMLElement;
106
104
 
107
105
  if (ev.submitter instanceof HTMLButtonElement || ev.submitter instanceof HTMLInputElement) {
108
- dlg.returnValue = ev.submitter.value;
106
+ panel.dataset.returnValue = ev.submitter.value;
109
107
  }
110
108
 
111
- if (!dlg.returnValue) {
112
- return cancel(dlg); // if no action - treat that as cancel
109
+ const returnValue = panel.dataset.returnValue ?? '';
110
+ if (!returnValue) {
111
+ return _oncancel(new Event('cancel', { cancelable: true }));
113
112
  }
114
113
 
115
114
  // MAIN APPROACH: Use only the first nested form's data
116
115
  let formData: FormData;
117
- const nestedForms = dlg.querySelectorAll('form');
116
+ const nestedForms = panel.querySelectorAll('form');
118
117
 
119
118
  if (nestedForms.length > 0) {
120
119
  // Use the first nested form (assuming it contains the input fields)
@@ -127,7 +126,7 @@
127
126
  // ALTERNATIVE APPROACH: Combine data from all nested forms (uncomment to use)
128
127
 
129
128
  const combinedFormData = new FormData();
130
- const allForms = dlg.querySelectorAll('form');
129
+ const allForms = panel.querySelectorAll('form');
131
130
 
132
131
  allForms.forEach((form) => {
133
132
  new FormData(form).forEach((value, key) => {
@@ -141,26 +140,40 @@
141
140
  // explicit false from onaction blocks the form closing
142
141
  if (
143
142
  typeof onaction === 'function' &&
144
- onaction({ action: dlg.returnValue, data: formData }) === false
143
+ onaction({ action: returnValue, data: formData }) === false
145
144
  )
146
145
  return;
147
146
 
148
- close(dlg);
149
- }
150
-
151
- function _ontoggle(ev: ToggleEvent & { currentTarget: HTMLDialogElement }) {
152
- ontoggle?.(ev);
153
- open = ev.newState === 'open'; // for cases when toggle by other means
154
- }
155
-
156
- function init(dlg: HTMLDialogElement) {
157
- modal ? dlg.showModal() : dlg.show();
158
- return () => dlg.close();
147
+ close();
159
148
  }
160
149
 
161
150
  const focusTrap = (node: HTMLElement) => (focustrap ? trapFocus(node) : undefined);
162
151
 
163
- let ref: HTMLDialogElement | undefined = $state(undefined);
152
+ let ref: HTMLDivElement | undefined = $state(undefined);
153
+
154
+ let previousOpen = false;
155
+ $effect(() => {
156
+ if (open && !previousOpen) {
157
+ openModalCount += 1;
158
+ if (modal) document.body.style.overflow = 'hidden';
159
+ ontoggle?.({ newState: 'open', oldState: 'closed' } as ToggleEvent);
160
+ }
161
+ if (!open && previousOpen) {
162
+ openModalCount = Math.max(0, openModalCount - 1);
163
+ if (modal && openModalCount === 0) document.body.style.overflow = '';
164
+ ontoggle?.({ newState: 'closed', oldState: 'open' } as ToggleEvent);
165
+ }
166
+ previousOpen = open;
167
+ });
168
+
169
+ $effect(() => {
170
+ return () => {
171
+ if (previousOpen) {
172
+ openModalCount = Math.max(0, openModalCount - 1);
173
+ if (modal && openModalCount === 0) document.body.style.overflow = '';
174
+ }
175
+ };
176
+ });
164
177
 
165
178
  function close_handler(ev: MouseEvent) {
166
179
  if (form) {
@@ -168,7 +181,9 @@
168
181
  return;
169
182
  }
170
183
 
171
- ref?.dispatchEvent(new Event('cancel', { bubbles: true, cancelable: true }));
184
+ const cancelEvent = new Event('cancel', { cancelable: true });
185
+ oncancel?.(cancelEvent);
186
+ if (!cancelEvent.defaultPrevented && !permanent) close();
172
187
  }
173
188
 
174
189
  createDismissableContext(close_handler);
@@ -180,7 +195,14 @@
180
195
  {#if title}
181
196
  <h3>{title}</h3>
182
197
  {#if dismissable && !permanent}
183
- <CloseButton type="submit" formnovalidate class={clsx(styling.close)} />
198
+ <CloseButton
199
+ type={form ? 'submit' : 'button'}
200
+ formnovalidate
201
+ class={clsx(styling.close)}
202
+ onclick={() => {
203
+ if (!form) close();
204
+ }}
205
+ />
184
206
  {/if}
185
207
  {:else if header}
186
208
  {@render header()}
@@ -197,24 +219,46 @@
197
219
  {/if}
198
220
  {#if dismissable && !permanent && !title}
199
221
  <CloseButton
200
- type="submit"
222
+ type={form ? 'submit' : 'button'}
201
223
  formnovalidate
202
224
  class={closeCls({ class: clsx(theme?.close, styling.close) })}
225
+ onclick={() => {
226
+ if (!form) close();
227
+ }}
203
228
  />
204
229
  {/if}
205
230
  {/snippet}
206
231
 
207
232
  {#if open}
208
- <dialog
209
- {@attach init}
233
+ <div
210
234
  bind:this={ref}
211
235
  use:focusTrap
212
- class={base({ fullscreen, class: clsx(theme?.base, className) })}
236
+ class="fixed inset-0 z-40 flex items-center justify-center p-4"
237
+ onclick={_onclick}
238
+ onkeydown={(ev) => {
239
+ if (ev.key === 'Escape' && !permanent) {
240
+ _oncancel(new Event('cancel', { cancelable: true }));
241
+ }
242
+ }}
243
+ tabindex="-1"
244
+ aria-modal={modal ? 'true' : undefined}
245
+ role="dialog"
246
+ >
247
+ {#if modal}
248
+ <div class="absolute inset-0 bg-black/80" data-modal-backdrop="true"></div>
249
+ {/if}
250
+ <div
251
+ class={base({
252
+ fullscreen,
253
+ class: clsx(
254
+ theme?.base,
255
+ className,
256
+ 'relative z-10',
257
+ !modal && 'shadow-xl'
258
+ )
259
+ })}
213
260
  tabindex="-1"
214
261
  onsubmit={_onsubmit}
215
- oncancel={_oncancel}
216
- onclick={_onclick}
217
- ontoggle={_ontoggle}
218
262
  transition:transition|global={paramsOptions as ParamsType}
219
263
  {...restProps}
220
264
  >
@@ -225,7 +269,8 @@
225
269
  {:else}
226
270
  {@render content()}
227
271
  {/if}
228
- </dialog>
272
+ </div>
273
+ </div>
229
274
  {/if}
230
275
 
231
276
  <!--
@@ -0,0 +1,99 @@
1
+ <script lang="ts">
2
+ import clsx from 'clsx';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ interface PageLoader2Props extends HTMLAttributes<HTMLDivElement> {
6
+ /** Optional message below the spinner */
7
+ message?: string;
8
+ /** Use full-screen overlay (fixed, centered, dimmed background) */
9
+ fullScreen?: boolean;
10
+ /** Loader size in pixels */
11
+ size?: number;
12
+ /** Overlay background classes when fullScreen is true */
13
+ overlayColor?: string;
14
+ /** Static outer ring border color class */
15
+ ringBaseColor?: string;
16
+ /** Rotating outer ring accent classes */
17
+ ringOuterColor?: string;
18
+ /** Rotating inner ring accent classes */
19
+ ringInnerColor?: string;
20
+ /** Center dot and bounce dots color class */
21
+ dotColor?: string;
22
+ /** Message text color class */
23
+ textColor?: string;
24
+ }
25
+
26
+ let {
27
+ message,
28
+ fullScreen = true,
29
+ size = 56,
30
+ overlayColor = 'bg-background/80',
31
+ ringBaseColor = 'border-muted',
32
+ ringOuterColor = 'border-t-accent border-r-accent/60',
33
+ ringInnerColor = 'border-b-primary border-l-primary/40',
34
+ dotColor = 'bg-accent',
35
+ textColor = 'text-muted-foreground',
36
+ class: className,
37
+ ...restProps
38
+ }: PageLoader2Props = $props();
39
+ </script>
40
+
41
+ <div
42
+ {...restProps}
43
+ class={clsx(
44
+ 'flex flex-col items-center justify-center gap-6',
45
+ fullScreen && ['fixed inset-0 z-50 backdrop-blur-sm', overlayColor],
46
+ className
47
+ )}
48
+ role="status"
49
+ aria-live="polite"
50
+ aria-label={message ?? 'Loading'}
51
+ >
52
+ <div class="relative" style={`width: ${size}px; height: ${size}px;`} aria-hidden="true">
53
+ <div class={clsx('absolute inset-0 rounded-full border-2', ringBaseColor)}></div>
54
+ <div
55
+ class={clsx(
56
+ 'absolute inset-0 rounded-full border-2 border-transparent animate-[spin_0.8s_linear_infinite]',
57
+ ringOuterColor
58
+ )}
59
+ ></div>
60
+ <div
61
+ class={clsx(
62
+ 'absolute inset-1 rounded-full border-2 border-transparent animate-[spin_1.2s_linear_infinite_reverse]',
63
+ ringInnerColor
64
+ )}
65
+ ></div>
66
+ <div class="absolute inset-0 flex items-center justify-center">
67
+ <div class={clsx('h-2 w-2 rounded-full animate-pulse', dotColor)}></div>
68
+ </div>
69
+ </div>
70
+
71
+ {#if message}
72
+ <div class="flex flex-col items-center gap-2">
73
+ <p class={clsx('text-sm font-medium', textColor)}>{message}</p>
74
+ <div class="flex gap-1.5" aria-hidden="true">
75
+ <span
76
+ class={clsx(
77
+ 'h-1.5 w-1.5 rounded-full animate-[page-loader-bounce_1.4s_ease-in-out_infinite_both]',
78
+ dotColor
79
+ )}
80
+ style="animation-delay: 0ms;"
81
+ ></span>
82
+ <span
83
+ class={clsx(
84
+ 'h-1.5 w-1.5 rounded-full animate-[page-loader-bounce_1.4s_ease-in-out_infinite_both]',
85
+ dotColor
86
+ )}
87
+ style="animation-delay: 160ms;"
88
+ ></span>
89
+ <span
90
+ class={clsx(
91
+ 'h-1.5 w-1.5 rounded-full animate-[page-loader-bounce_1.4s_ease-in-out_infinite_both]',
92
+ dotColor
93
+ )}
94
+ style="animation-delay: 320ms;"
95
+ ></span>
96
+ </div>
97
+ </div>
98
+ {/if}
99
+ </div>
@@ -0,0 +1,24 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ interface PageLoader2Props extends HTMLAttributes<HTMLDivElement> {
3
+ /** Optional message below the spinner */
4
+ message?: string;
5
+ /** Use full-screen overlay (fixed, centered, dimmed background) */
6
+ fullScreen?: boolean;
7
+ /** Loader size in pixels */
8
+ size?: number;
9
+ /** Overlay background classes when fullScreen is true */
10
+ overlayColor?: string;
11
+ /** Static outer ring border color class */
12
+ ringBaseColor?: string;
13
+ /** Rotating outer ring accent classes */
14
+ ringOuterColor?: string;
15
+ /** Rotating inner ring accent classes */
16
+ ringInnerColor?: string;
17
+ /** Center dot and bounce dots color class */
18
+ dotColor?: string;
19
+ /** Message text color class */
20
+ textColor?: string;
21
+ }
22
+ declare const PageLoader2: import("svelte").Component<PageLoader2Props, {}, "">;
23
+ type PageLoader2 = ReturnType<typeof PageLoader2>;
24
+ export default PageLoader2;
@@ -1 +1,2 @@
1
- export { default as PageLoader } from "./PageLoader.svelte";
1
+ export { default as PageLoader } from './PageLoader.svelte';
2
+ export { default as PageLoader2 } from './PageLoader2.svelte';
@@ -1 +1,2 @@
1
- export { default as PageLoader } from "./PageLoader.svelte";
1
+ export { default as PageLoader } from './PageLoader.svelte';
2
+ export { default as PageLoader2 } from './PageLoader2.svelte';