lutra 0.1.32 → 0.1.33

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Lutra
2
2
 
3
- UI library for Svelte 5.
3
+ UI library for Svelte 5 currently under active development.
4
4
 
5
5
  ## Installation
6
6
 
@@ -122,6 +122,10 @@
122
122
  }
123
123
  }
124
124
 
125
+ function handleSelect(item: Item, index: number) {
126
+ _open = false;
127
+ }
128
+
125
129
  </script>
126
130
 
127
131
 
@@ -150,7 +154,7 @@
150
154
  >
151
155
  <ul>
152
156
  {#each items as item, index}
153
- <MenuItem {keyboardHasFocus} onmouseover={mouseover} item={item} {index} />
157
+ <MenuItem {keyboardHasFocus} onmouseover={mouseover} onselect={handleSelect} item={item} {index} />
154
158
  {/each}
155
159
  </ul>
156
160
  </div>
@@ -7,12 +7,14 @@
7
7
  item,
8
8
  index,
9
9
  onmouseover,
10
+ onselect,
10
11
  keyboardHasFocus,
11
12
  shape = 'default',
12
13
  }: {
13
14
  item: Item;
14
15
  index: number;
15
16
  onmouseover?: (e: MouseEvent, item: Item, index: number) => void;
17
+ onselect?: (item: Item, index: number) => void;
16
18
  keyboardHasFocus?: boolean;
17
19
  shape?: 'default' | 'rounded' | 'pill';
18
20
  } = $props();
@@ -25,6 +27,13 @@
25
27
  el?.focus();
26
28
  }
27
29
  }
30
+
31
+ function handleClick(e: MouseEvent | KeyboardEvent) {
32
+ if(item.type === 'item' && item.onclick) {
33
+ item.onclick(e as MouseEvent, item);
34
+ onselect?.(item, index);
35
+ }
36
+ }
28
37
  </script>
29
38
 
30
39
  <!-- svelte-ignore a11y_mouse_events_have_key_events -->
@@ -59,7 +68,7 @@
59
68
  {/if}
60
69
  </a>
61
70
  {:else if item.onclick}
62
- <button type="button" onclick={(e) => item.type === 'item' ? item.onclick!(e, item) : undefined} class="Item" bind:this={el}>
71
+ <button type="button" onclick={handleClick} class="Item" bind:this={el}>
63
72
  <span class="Content">
64
73
  <MenuItemContent {...item} />
65
74
  </span>
@@ -67,7 +76,7 @@
67
76
  <span class="Shortcut">{item.shortcut}</span>
68
77
  {/if}
69
78
  </button>
70
- {:else if item.component}
79
+ {:else if item.component || item.render || item.snippet}
71
80
  <div class="Item Custom">
72
81
  <MenuItemContent {...item} />
73
82
  </div>
@@ -3,6 +3,7 @@ type $$ComponentProps = {
3
3
  item: Item;
4
4
  index: number;
5
5
  onmouseover?: (e: MouseEvent, item: Item, index: number) => void;
6
+ onselect?: (item: Item, index: number) => void;
6
7
  keyboardHasFocus?: boolean;
7
8
  shape?: 'default' | 'rounded' | 'pill';
8
9
  };
@@ -1,16 +1,19 @@
1
1
  <script lang="ts">
2
2
  import type { Component, Snippet } from "svelte";
3
+ import type { RenderFn } from "../types.js";
3
4
  import Icon from "./Icon.svelte";
4
5
 
5
6
  let {
6
7
  text,
7
8
  snippet,
9
+ render,
8
10
  component: Comp,
9
11
  props,
10
12
  icon
11
13
  }: {
12
14
  text?: string;
13
15
  snippet?: Snippet;
16
+ render?: RenderFn;
14
17
  component?: Component;
15
18
  props?: any;
16
19
  icon?: string | Component;
@@ -25,6 +28,8 @@
25
28
  {/if}
26
29
  {#if snippet}
27
30
  {@render snippet()}
31
+ {:else if render}
32
+ {@render render()}
28
33
  {/if}
29
34
  {#if Comp}
30
35
  <Comp {...props} />
@@ -1,7 +1,9 @@
1
1
  import type { Component, Snippet } from "svelte";
2
+ import type { RenderFn } from "../types.js";
2
3
  type $$ComponentProps = {
3
4
  text?: string;
4
5
  snippet?: Snippet;
6
+ render?: RenderFn;
5
7
  component?: Component;
6
8
  props?: any;
7
9
  icon?: string | Component;
@@ -1,5 +1,6 @@
1
1
  import type { StatusColorOrString } from "../util/color.js";
2
2
  import type { Component, Snippet } from "svelte";
3
+ import type { RenderFn } from "../types.js";
3
4
  export type MenuItem = {
4
5
  /** Type of menu item to render. */
5
6
  type: 'divider';
@@ -8,8 +9,10 @@ export type MenuItem = {
8
9
  type: 'header';
9
10
  /** Text label of the item to display to the user. */
10
11
  text?: string;
11
- /** Snippet to display. */
12
+ /** Snippet to display (for direct props). */
12
13
  snippet?: Snippet;
14
+ /** Render function to display (for object literals - avoids Svelte 5 snippet typing issues). */
15
+ render?: RenderFn;
13
16
  /** Component to display. */
14
17
  component?: Component;
15
18
  /** Icon to display. Pass either a Svelte Component or an URL. Use the Icon component to recolor your icons according to the user theme. */
@@ -28,8 +31,10 @@ export type MenuItem = {
28
31
  type: 'item';
29
32
  /** Text label of the item to display to the user. */
30
33
  text?: string;
31
- /** Snippet to display. */
34
+ /** Snippet to display (for direct props). */
32
35
  snippet?: Snippet;
36
+ /** Render function to display (for object literals - avoids Svelte 5 snippet typing issues). */
37
+ render?: RenderFn;
33
38
  /** Component to display. */
34
39
  component?: Component;
35
40
  /** Keyboard shortcut to display next to the item. */
@@ -1,27 +1,41 @@
1
1
  <script lang="ts">
2
- import UiContent from "./UIContent.svelte";
3
- import ModalContent from "./ModalContent.svelte";
2
+ import ModalContent from "./ModalContent.svelte";
3
+ import Overlay from "./Overlay.svelte";
4
4
  import { getContext, type Snippet } from "svelte";
5
5
  import { attr } from "../util/attr.js";
6
+ import type { RenderFn } from "../types.js";
6
7
 
7
8
  /**
8
- * @description
9
- * A modal component that uses the popover api. Both trigger and content elements are snippets.
10
- * For the trigger element, you get an `attrs` function that applies the necessary attributes to your trigger with the `use:attrs` directive.
11
- * @example
12
- * <div>
13
- * {#snippet trigger(attrs)}
14
- * <button use:attrs>foo</button>
15
- * {/snippet}
16
- * {#snippet content(close)}
17
- * <div>bar</div>
18
- * {/snippet}
19
- * <Modal trigger={trigger} content={content} />
20
- * </div>
9
+ * A flexible modal component supporting multiple usage patterns:
10
+ *
11
+ * **Pattern A: Trigger-based**
12
+ * ```svelte
13
+ * <Modal {trigger} {content} />
14
+ * ```
15
+ *
16
+ * **Pattern B: Controlled state**
17
+ * ```svelte
18
+ * <Modal bind:open={showModal}>
19
+ * {#snippet content(close)}
20
+ * <p>Modal content</p>
21
+ * {/snippet}
22
+ * </Modal>
23
+ * ```
24
+ *
25
+ * **Pattern C: Auto-show (no trigger)**
26
+ * ```svelte
27
+ * {#if shouldShow}
28
+ * <Modal open onclose={() => shouldShow = false}>
29
+ * {#snippet content(close)}...{/snippet}
30
+ * </Modal>
31
+ * {/if}
32
+ * ```
21
33
  */
22
34
  let {
35
+ open = $bindable(false),
23
36
  contained,
24
37
  content,
38
+ render,
25
39
  buttons,
26
40
  trigger,
27
41
  title,
@@ -31,15 +45,22 @@
31
45
  closeOnScrim = true,
32
46
  trapFocus = true,
33
47
  dismissOnEsc = true,
48
+ width,
34
49
  maxWidth,
35
50
  maxHeight,
51
+ onclose,
52
+ onopen,
36
53
  }: {
54
+ /** Whether the modal is open (bindable for controlled usage) */
55
+ open?: boolean;
37
56
  /** Whether the modal should be contained with a border */
38
57
  contained?: boolean;
39
- /** The content of the modal */
40
- content: Snippet<[close: () => void]>;
41
- /** Snippet containing the trigger element */
42
- trigger: Snippet<[attrs: (node: Element) => void]>;
58
+ /** Snippet for the modal content */
59
+ content?: Snippet<[close: () => void]>;
60
+ /** Render function for modal content (for object literals) */
61
+ render?: RenderFn<[close: () => void]>;
62
+ /** Optional snippet containing the trigger element */
63
+ trigger?: Snippet<[attrs: (node: Element) => void]>;
43
64
  /** Buttons to be displayed in the modal */
44
65
  buttons?: Snippet<[close: () => void]>;
45
66
  /** Optional title for the modal (improves a11y) */
@@ -56,97 +77,89 @@
56
77
  trapFocus?: boolean;
57
78
  /** Whether pressing Escape closes the modal */
58
79
  dismissOnEsc?: boolean;
80
+ /** Width of the modal */
81
+ width?: string;
59
82
  /** Maximum width of the modal */
60
83
  maxWidth?: string;
61
84
  /** Maximum height of the modal */
62
85
  maxHeight?: string;
86
+ /** Callback when the modal closes */
87
+ onclose?: () => void;
88
+ /** Callback when the modal opens */
89
+ onopen?: () => void;
63
90
  } = $props();
64
91
 
65
- if(contained === undefined) { contained = getContext('lutra.modal.contained') ?? getContext('lutra.contained') ?? false; }
92
+ if (contained === undefined) {
93
+ contained = getContext('lutra.modal.contained') ?? getContext('lutra.contained') ?? false;
94
+ }
66
95
 
67
- const id = `po-${Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)}`;
68
- let isOpen = $state(false);
96
+ const id = `modal-${crypto.randomUUID()}`;
97
+ let wasOpen = false;
69
98
 
70
- function closeModal() {
71
- document.getElementById(id)?.hidePopover();
72
- isOpen = false;
99
+ function closeModal() {
100
+ open = false;
101
+ onclose?.();
73
102
  }
74
-
75
- function toggleModal() {
76
- isOpen = !isOpen;
103
+
104
+ function toggleModal() {
105
+ open = !open;
77
106
  }
78
107
 
79
108
  $effect(() => {
80
- if(isOpen) {
109
+ if (open && !wasOpen) {
110
+ onopen?.();
81
111
  document.documentElement.style.overflow = "hidden";
82
- } else {
112
+ } else if (!open && wasOpen) {
83
113
  document.documentElement.style.overflow = "";
84
114
  }
115
+ wasOpen = open;
85
116
  });
86
117
 
87
118
  let attrs = $derived.by(() => {
88
119
  return attr({
89
120
  id: `trigger-${id}`,
90
- popovertarget: id,
91
121
  onclick: toggleModal,
92
- })
122
+ "aria-haspopup": "dialog",
123
+ "aria-expanded": open,
124
+ "aria-controls": id,
125
+ });
93
126
  });
94
-
95
127
  </script>
96
128
 
97
- <div class="Modal">
98
- <div class="Trigger">
99
- {@render trigger(attrs)}
129
+ {#if trigger}
130
+ <div class="Modal">
131
+ <div class="Trigger">
132
+ {@render trigger(attrs)}
133
+ </div>
100
134
  </div>
101
- {#if isOpen}
102
- <UiContent>
103
- <div {id} popover="auto" class="ModalContainer">
104
- <ModalContent
105
- {shape}
106
- {contained}
107
- {unstyled}
108
- {showScrim}
109
- {closeOnScrim}
110
- {trapFocus}
111
- {dismissOnEsc}
112
- {maxWidth}
113
- {maxHeight}
114
- {title}
115
- {buttons}
116
- children={content}
117
- close={closeModal}
118
- />
119
- </div>
120
- </UiContent>
121
- {/if}
122
- </div>
135
+ {/if}
136
+
137
+ {#if open}
138
+ <Overlay position="center" {id} z={200} layer="modals">
139
+ <ModalContent
140
+ {shape}
141
+ {contained}
142
+ {unstyled}
143
+ {showScrim}
144
+ {closeOnScrim}
145
+ {trapFocus}
146
+ {dismissOnEsc}
147
+ {width}
148
+ {maxWidth}
149
+ {maxHeight}
150
+ {title}
151
+ {buttons}
152
+ snippet={content}
153
+ {render}
154
+ close={closeModal}
155
+ />
156
+ </Overlay>
157
+ {/if}
123
158
 
124
159
  <style>
125
- .Modal, .Trigger {
160
+ .Modal,
161
+ .Trigger {
126
162
  position: relative;
127
163
  display: inline-block;
128
164
  }
129
-
130
- .ModalContainer {
131
- border: 0;
132
- width: 100svw;
133
- height: 100svh;
134
- overflow-y: auto;
135
- display: flex;
136
- align-items: center;
137
- justify-content: center;
138
- }
139
-
140
- [popover] {
141
- animation: fadeIn 0.2s;
142
- }
143
-
144
- @keyframes fadeIn {
145
- from {
146
- opacity: 0;
147
- }
148
- to {
149
- opacity: 1;
150
- }
151
- }
152
- </style>
165
+ </style>
@@ -1,11 +1,16 @@
1
1
  import { type Snippet } from "svelte";
2
+ import type { RenderFn } from "../types.js";
2
3
  type $$ComponentProps = {
4
+ /** Whether the modal is open (bindable for controlled usage) */
5
+ open?: boolean;
3
6
  /** Whether the modal should be contained with a border */
4
7
  contained?: boolean;
5
- /** The content of the modal */
6
- content: Snippet<[close: () => void]>;
7
- /** Snippet containing the trigger element */
8
- trigger: Snippet<[attrs: (node: Element) => void]>;
8
+ /** Snippet for the modal content */
9
+ content?: Snippet<[close: () => void]>;
10
+ /** Render function for modal content (for object literals) */
11
+ render?: RenderFn<[close: () => void]>;
12
+ /** Optional snippet containing the trigger element */
13
+ trigger?: Snippet<[attrs: (node: Element) => void]>;
9
14
  /** Buttons to be displayed in the modal */
10
15
  buttons?: Snippet<[close: () => void]>;
11
16
  /** Optional title for the modal (improves a11y) */
@@ -22,11 +27,17 @@ type $$ComponentProps = {
22
27
  trapFocus?: boolean;
23
28
  /** Whether pressing Escape closes the modal */
24
29
  dismissOnEsc?: boolean;
30
+ /** Width of the modal */
31
+ width?: string;
25
32
  /** Maximum width of the modal */
26
33
  maxWidth?: string;
27
34
  /** Maximum height of the modal */
28
35
  maxHeight?: string;
36
+ /** Callback when the modal closes */
37
+ onclose?: () => void;
38
+ /** Callback when the modal opens */
39
+ onopen?: () => void;
29
40
  };
30
- declare const Modal: import("svelte").Component<$$ComponentProps, {}, "">;
41
+ declare const Modal: import("svelte").Component<$$ComponentProps, {}, "open">;
31
42
  type Modal = ReturnType<typeof Modal>;
32
43
  export default Modal;
@@ -1,14 +1,18 @@
1
1
  <script lang="ts">
2
2
  import type { Component, Snippet } from 'svelte';
3
3
  import { onMount, onDestroy } from 'svelte';
4
+ import type { ModalButton } from './ModalTypes.js';
5
+ import type { RenderFn } from '../types.js';
4
6
 
5
7
  let {
6
8
  children,
7
9
  text,
8
10
  snippet,
11
+ render,
9
12
  component: Comp,
10
13
  props,
11
14
  buttons,
15
+ buttonsConfig,
12
16
  close,
13
17
  title,
14
18
  shape = 'rounded',
@@ -18,15 +22,18 @@
18
22
  closeOnScrim = true,
19
23
  trapFocus = true,
20
24
  dismissOnEsc = true,
25
+ width,
21
26
  maxWidth,
22
27
  maxHeight,
23
28
  }: {
24
29
  children?: Snippet<[close: () => void]>;
25
30
  text?: string;
26
31
  snippet?: Snippet<[close: () => void]>;
32
+ render?: RenderFn<[close: () => void]>;
27
33
  component?: Component;
28
34
  props?: any;
29
35
  buttons?: Snippet<[close: () => void]>;
36
+ buttonsConfig?: ModalButton[];
30
37
  close: () => void;
31
38
  title?: string;
32
39
  shape?: 'rounded' | 'sharp';
@@ -36,6 +43,7 @@
36
43
  closeOnScrim?: boolean;
37
44
  trapFocus?: boolean;
38
45
  dismissOnEsc?: boolean;
46
+ width?: string;
39
47
  maxWidth?: string;
40
48
  maxHeight?: string;
41
49
  } = $props();
@@ -43,6 +51,20 @@
43
51
  let dialogEl: HTMLDivElement | null = $state(null);
44
52
  let previousActiveElement: HTMLElement | null = null;
45
53
  const titleId = title ? `modal-title-${crypto.randomUUID()}` : undefined;
54
+ let loading: Record<number, boolean> = $state({});
55
+
56
+ async function handleButtonClick(btn: ModalButton, index: number) {
57
+ if (!btn.onclick) return;
58
+ const result = btn.onclick(close);
59
+ if (result instanceof Promise) {
60
+ loading[index] = true;
61
+ try {
62
+ await result;
63
+ } finally {
64
+ loading[index] = false;
65
+ }
66
+ }
67
+ }
46
68
 
47
69
  onMount(() => {
48
70
  previousActiveElement = document.activeElement as HTMLElement;
@@ -53,11 +75,14 @@
53
75
  focusableElements[0].focus();
54
76
  }
55
77
  }
78
+ dialogEl?.focus();
79
+ window.addEventListener('keydown', handleKeydown);
56
80
  });
57
81
 
58
82
  onDestroy(() => {
59
83
  // Restore focus on unmount
60
84
  previousActiveElement?.focus();
85
+ window.removeEventListener('keydown', handleKeydown);
61
86
  });
62
87
 
63
88
  function getFocusableElements(): HTMLElement[] {
@@ -120,7 +145,7 @@
120
145
  tabindex="-1"
121
146
  bind:this={dialogEl}
122
147
  onkeydown={handleKeydown}
123
- style="--modal-max-width: {maxWidth}; --modal-max-height: {maxHeight};"
148
+ style="--modal-width: {width}; --modal-max-width: {maxWidth}; --modal-max-height: {maxHeight};"
124
149
  >
125
150
  <div class="ModalContentArea">
126
151
  {#if title}
@@ -131,6 +156,8 @@
131
156
  {/if}
132
157
  {#if snippet}
133
158
  {@render snippet(close)}
159
+ {:else if render}
160
+ {@render render(close)}
134
161
  {/if}
135
162
  {#if Comp}
136
163
  <Comp {...props} {close} />
@@ -139,7 +166,17 @@
139
166
  {@render children(close)}
140
167
  {/if}
141
168
  </div>
142
- {#if buttons}
169
+ {#if buttonsConfig}
170
+ <div class="ModalActions">
171
+ {#each buttonsConfig as btn, i}
172
+ <button
173
+ class="button {btn.variant || 'default'}"
174
+ disabled={btn.disabled || loading[i]}
175
+ onclick={() => handleButtonClick(btn, i)}
176
+ >{btn.text}</button>
177
+ {/each}
178
+ </div>
179
+ {:else if buttons}
143
180
  <div class="ModalActions">
144
181
  {@render buttons(close)}
145
182
  </div>
@@ -150,23 +187,25 @@
150
187
  .ModalScrim {
151
188
  background: var(--scrim-background);
152
189
  backdrop-filter: var(--scrim-backdrop-filter);
153
- position: fixed;
190
+ position: absolute;
154
191
  inset: 0;
155
- z-index: -1;
192
+ z-index: 0;
156
193
  }
157
194
 
158
195
  .ModalContent {
159
196
  display: grid;
160
197
  grid-template-rows: 1fr auto;
161
198
  gap: var(--modal-gap);
199
+ width: var(--modal-width, fit-content);
162
200
  max-width: min(var(--modal-max-width, 40rem), calc(100svw - 2rem));
163
201
  max-height: min(var(--modal-max-height, 80svh), calc(100svh - 2rem));
164
202
  background: var(--modal-background);
165
- border: var(--modal-border);
203
+ border: var(--modal-border-size) var(--modal-border-style) var(--modal-border-color);
166
204
  border-radius: var(--modal-border-radius);
167
205
  box-shadow: 0 0.5rem 1rem var(--modal-shadow-color);
168
206
  overflow: hidden;
169
207
  position: relative;
208
+ z-index: 1;
170
209
  }
171
210
 
172
211
  .ModalContent.sharp {
@@ -190,7 +229,8 @@
190
229
  overflow: auto;
191
230
  scrollbar-gutter: stable;
192
231
  scrollbar-width: thin;
193
- padding: var(--modal-padding);
232
+ padding-block: var(--modal-padding-block);
233
+ padding-inline: var(--modal-padding-inline);
194
234
  text-wrap: pretty;
195
235
  }
196
236
 
@@ -215,7 +255,8 @@
215
255
  .ModalActions {
216
256
  background: var(--modal-actions-background);
217
257
  border-top: var(--modal-border-size) var(--modal-border-style) var(--modal-actions-border-color);
218
- padding: var(--modal-actions-padding);
258
+ padding-block: var(--modal-actions-padding-block);
259
+ padding-inline: var(--modal-actions-padding-inline);
219
260
  display: flex;
220
261
  gap: var(--space-sm);
221
262
  justify-content: flex-end;
@@ -1,11 +1,15 @@
1
1
  import type { Component, Snippet } from 'svelte';
2
+ import type { ModalButton } from './ModalTypes.js';
3
+ import type { RenderFn } from '../types.js';
2
4
  type $$ComponentProps = {
3
5
  children?: Snippet<[close: () => void]>;
4
6
  text?: string;
5
7
  snippet?: Snippet<[close: () => void]>;
8
+ render?: RenderFn<[close: () => void]>;
6
9
  component?: Component;
7
10
  props?: any;
8
11
  buttons?: Snippet<[close: () => void]>;
12
+ buttonsConfig?: ModalButton[];
9
13
  close: () => void;
10
14
  title?: string;
11
15
  shape?: 'rounded' | 'sharp';
@@ -15,6 +19,7 @@ type $$ComponentProps = {
15
19
  closeOnScrim?: boolean;
16
20
  trapFocus?: boolean;
17
21
  dismissOnEsc?: boolean;
22
+ width?: string;
18
23
  maxWidth?: string;
19
24
  maxHeight?: string;
20
25
  };
@@ -1,5 +1,6 @@
1
1
  import type { Component, Snippet } from 'svelte';
2
2
  import type { OverlayPosition } from './overlays.svelte.js';
3
+ import type { RenderFn } from '../types.js';
3
4
  export type ModalButton = {
4
5
  text: string;
5
6
  variant?: 'action' | 'success' | 'danger' | 'ghost' | 'outline' | 'default';
@@ -10,8 +11,10 @@ export type ModalButton = {
10
11
  export type ModalOptions = {
11
12
  /** Text content to display */
12
13
  text?: string;
13
- /** Snippet to render */
14
+ /** Snippet to render (for direct props) */
14
15
  snippet?: Snippet<[close: () => void]>;
16
+ /** Render function (for object literals - avoids Svelte 5 snippet typing issues) */
17
+ render?: RenderFn<[close: () => void]>;
15
18
  /** Component to render */
16
19
  component?: Component;
17
20
  /** Props to pass to component */
@@ -25,7 +25,7 @@
25
25
  left: 0;
26
26
  right: 0;
27
27
  bottom: 0;
28
- z-index: 1000;
28
+ z-index: var(--overlay-z-index);
29
29
  pointer-events: none;
30
30
  }
31
31
  </style>
@@ -2,6 +2,7 @@
2
2
  import { type OverlayItem, type OverlayPosition } from "./overlays.svelte.js";
3
3
  import { slidefade } from "../util/transitions.js";
4
4
  import { BROWSER } from "esm-env";
5
+ import RenderContent from "../util/RenderContent.svelte";
5
6
  import { untrack } from "svelte";
6
7
 
7
8
  let {
@@ -113,11 +114,13 @@
113
114
  transition:slidefade|global={{ duration: 150, origin: originCache[item.id] || origins[index], noMargin: !!!item.anchor }}
114
115
  style="--index: {index}; --z: {item.z}; --left: {positions[index].left}px; --top: {positions[index].top}px;"
115
116
  >
116
- {#if item.component}
117
- <item.component {...item.props} />
118
- {:else if item.snippet}
119
- {@render item.snippet()}
120
- {/if}
117
+ <RenderContent
118
+ text={item.text}
119
+ snippet={item.snippet}
120
+ render={item.render}
121
+ component={item.component}
122
+ props={item.props}
123
+ />
121
124
  </div>
122
125
  {/each}
123
126
  </div>
@@ -128,7 +131,7 @@
128
131
  position: absolute;
129
132
  display: flex;
130
133
  flex-direction: column-reverse;
131
- gap: 0.75rem;
134
+ gap: var(--overlay-gap);
132
135
  }
133
136
  .Layer.center {
134
137
  left: 50%;
@@ -136,19 +139,19 @@
136
139
  transform: translateX(-50%);
137
140
  }
138
141
  .Layer.top {
139
- top: calc(1rem + env(safe-area-inset-top));
142
+ top: calc(var(--overlay-offset) + env(safe-area-inset-top));
140
143
  bottom: unset;
141
144
  }
142
145
  .Layer.bottom {
143
146
  top: unset;
144
- bottom: calc(1rem + env(safe-area-inset-bottom));
147
+ bottom: calc(var(--overlay-offset) + env(safe-area-inset-bottom));
145
148
  }
146
149
  .Layer.right {
147
150
  left: unset;
148
- right: calc(1rem + env(safe-area-inset-right));
151
+ right: calc(var(--overlay-offset) + env(safe-area-inset-right));
149
152
  }
150
153
  .Layer.left {
151
- left: calc(1rem + env(safe-area-inset-left));
154
+ left: calc(var(--overlay-offset) + env(safe-area-inset-left));
152
155
  right: unset;
153
156
  }
154
157
  .Layer.center:not(.top):not(.bottom):not(.anchor) {
@@ -1,4 +1,24 @@
1
1
  import type { ModalOptions } from './ModalTypes.js';
2
+ /**
3
+ * Opens a modal programmatically.
4
+ * @param opts - Modal options or a simple string for text content
5
+ * @returns An object with the modal id and a close function
6
+ * @example
7
+ * // Simple text modal
8
+ * const { close } = openModal('Hello world!');
9
+ *
10
+ * // Modal with options
11
+ * const { close } = openModal({
12
+ * title: 'Confirm',
13
+ * text: 'Are you sure?',
14
+ * buttons: 'ok-cancel',
15
+ * });
16
+ *
17
+ * // Modal with render function (for object literals)
18
+ * const { close } = openModal({
19
+ * render: (close) => renderMyContent(close),
20
+ * });
21
+ */
2
22
  export declare function openModal(opts: ModalOptions | string): {
3
23
  id: string;
4
24
  close: () => void;
@@ -18,6 +18,26 @@ function unlockScroll() {
18
18
  activeElement = null;
19
19
  }
20
20
  }
21
+ /**
22
+ * Opens a modal programmatically.
23
+ * @param opts - Modal options or a simple string for text content
24
+ * @returns An object with the modal id and a close function
25
+ * @example
26
+ * // Simple text modal
27
+ * const { close } = openModal('Hello world!');
28
+ *
29
+ * // Modal with options
30
+ * const { close } = openModal({
31
+ * title: 'Confirm',
32
+ * text: 'Are you sure?',
33
+ * buttons: 'ok-cancel',
34
+ * });
35
+ *
36
+ * // Modal with render function (for object literals)
37
+ * const { close } = openModal({
38
+ * render: (close) => renderMyContent(close),
39
+ * });
40
+ */
21
41
  export function openModal(opts) {
22
42
  const id = crypto.randomUUID();
23
43
  if (typeof opts === 'string') {
@@ -31,17 +51,16 @@ export function openModal(opts) {
31
51
  lockScroll();
32
52
  opts.onOpen?.();
33
53
  // Handle defaultButtons shortcut
34
- let buttons;
54
+ let buttonsConfig;
35
55
  if (opts.buttons === 'ok-cancel') {
36
- buttons = [cancelButton, okButton];
56
+ buttonsConfig = [cancelButton, okButton];
37
57
  }
38
58
  else if (opts.buttons === 'none' || opts.buttons === undefined) {
39
- buttons = undefined;
59
+ buttonsConfig = undefined;
40
60
  }
41
61
  else {
42
- buttons = opts.buttons;
62
+ buttonsConfig = opts.buttons;
43
63
  }
44
- const buttonsSnippet = buttons ? createButtonsSnippet(buttons, close) : undefined;
45
64
  addOverlay({
46
65
  id,
47
66
  z: 200,
@@ -50,9 +69,10 @@ export function openModal(opts) {
50
69
  close,
51
70
  text: opts.text,
52
71
  snippet: opts.snippet,
72
+ render: opts.render,
53
73
  component: opts.component,
54
74
  props: opts.props,
55
- buttons: buttonsSnippet,
75
+ buttonsConfig,
56
76
  title: opts.title,
57
77
  showScrim: opts.showScrim ?? true,
58
78
  closeOnScrim: opts.closeOnScrim ?? true,
@@ -61,6 +81,7 @@ export function openModal(opts) {
61
81
  unstyled: opts.unstyled ?? false,
62
82
  shape: opts.shape ?? 'rounded',
63
83
  contained: opts.contained ?? true,
84
+ width: opts.width,
64
85
  maxWidth: opts.maxWidth,
65
86
  maxHeight: opts.maxHeight,
66
87
  },
@@ -69,35 +90,3 @@ export function openModal(opts) {
69
90
  });
70
91
  return { id, close };
71
92
  }
72
- function createButtonsSnippet(buttons, close) {
73
- // Create a snippet that renders the buttons
74
- return (closeParam) => {
75
- // We need to return actual DOM elements, not strings
76
- // This will be rendered by Svelte's snippet system
77
- const fragment = document.createDocumentFragment();
78
- buttons.forEach((btn) => {
79
- const button = document.createElement('button');
80
- button.className = `button ${btn.variant || 'default'}`;
81
- button.textContent = btn.text;
82
- button.disabled = btn.disabled || btn.loading || false;
83
- if (btn.onclick) {
84
- button.addEventListener('click', async () => {
85
- const result = btn.onclick?.(close);
86
- if (result instanceof Promise) {
87
- button.disabled = true;
88
- button.classList.add('loading');
89
- try {
90
- await result;
91
- }
92
- finally {
93
- button.disabled = false;
94
- button.classList.remove('loading');
95
- }
96
- }
97
- });
98
- }
99
- fragment.appendChild(button);
100
- });
101
- return fragment;
102
- };
103
- }
@@ -1,4 +1,5 @@
1
1
  import type { Component, Snippet } from "svelte";
2
+ import type { RenderFn } from "../types.js";
2
3
  export type OverlayPosition = "top left" | "top center" | "top right" | "bottom left" | "bottom center" | "bottom right" | "center" | "anchor";
3
4
  export type TransitionOpts = {
4
5
  y?: number;
@@ -12,6 +13,7 @@ export type OverlayItem = {
12
13
  z?: number;
13
14
  text?: string;
14
15
  snippet?: Snippet;
16
+ render?: RenderFn;
15
17
  component?: Component;
16
18
  props?: any;
17
19
  layer?: string;
@@ -475,6 +475,7 @@
475
475
 
476
476
  @property --modal-padding-inline { syntax: "<length>"; inherits: true; initial-value: 24px; }
477
477
  @property --modal-padding-block { syntax: "<length>"; inherits: true; initial-value: 24px; }
478
+ @property --modal-width { syntax: "<length>"; inherits: true; initial-value: fit-content; }
478
479
  @property --modal-max-width { syntax: "<length>"; inherits: true; initial-value: 40rem; }
479
480
  @property --modal-max-height { syntax: "<length>"; inherits: true; initial-value: 80svh; }
480
481
  @property --modal-gap { syntax: "<length>"; inherits: true; initial-value: 0px; }
@@ -484,9 +485,17 @@
484
485
  @property --modal-actions-padding-inline { syntax: "<length>"; inherits: true; initial-value: 16px; }
485
486
  @property --modal-actions-padding-block { syntax: "<length>"; inherits: true; initial-value: 16px; }
486
487
 
488
+ /**
489
+ * Overlay System
490
+ */
491
+
492
+ @property --overlay-z-index { syntax: "<integer>"; inherits: true; initial-value: 1000; }
493
+ @property --overlay-gap { syntax: "<length>"; inherits: true; initial-value: 0.75rem; }
494
+ @property --overlay-offset { syntax: "<length>"; inherits: true; initial-value: 1rem; }
495
+
487
496
  /**
488
497
  * Scrim/Backdrop (shared across overlays)
489
498
  */
490
499
 
491
500
  @property --scrim-background { syntax: "<color>"; inherits: true; initial-value: rgba(0, 0, 0, 0.5); }
492
- @property --scrim-backdrop-filter { syntax: "<string>"; inherits: true; initial-value: blur(2px); }
501
+ @property --scrim-backdrop-filter { syntax: "*"; inherits: true; initial-value: blur(2px); }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './components/index.js';
2
2
  export * from './form/index.js';
3
3
  export * from './icons/index.js';
4
+ export * from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './components/index.js';
2
2
  export * from './form/index.js';
3
3
  export * from './icons/index.js';
4
+ export * from './types.js';
package/dist/types.d.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import { type Snippet } from "svelte";
2
+ /**
3
+ * A render function that can be used in place of a Snippet when passing content
4
+ * through object literals. This works around Svelte 5's snippet typing limitations
5
+ * where inline snippet declarations don't type-check when passed through objects.
6
+ * @template T - Tuple of argument types passed to the render function
7
+ */
8
+ export type RenderFn<T extends any[] = []> = (...args: T) => ReturnType<Snippet<T>>;
1
9
  export interface LutraConfig {
2
10
  /**
3
11
  * The default theme to use.
@@ -0,0 +1,49 @@
1
+ <script lang="ts" generics="T = undefined">
2
+ import type { Component, Snippet } from "svelte";
3
+ import type { RenderFn } from "../types.js";
4
+
5
+ /**
6
+ * A unified content rendering component that handles multiple content types.
7
+ * Supports text strings, Svelte snippets, render functions, and components.
8
+ */
9
+ let {
10
+ text,
11
+ snippet,
12
+ render,
13
+ component: Comp,
14
+ props,
15
+ arg,
16
+ }: {
17
+ /** Plain text content to display */
18
+ text?: string;
19
+ /** Svelte snippet to render */
20
+ snippet?: T extends undefined ? Snippet<[]> : Snippet<[T]>;
21
+ /** Render function (for object literals - avoids Svelte 5 snippet typing issues) */
22
+ render?: T extends undefined ? RenderFn<[]> : RenderFn<[T]>;
23
+ /** Svelte component to instantiate */
24
+ component?: Component;
25
+ /** Props to pass to the component */
26
+ props?: Record<string, any>;
27
+ /** Argument to pass to snippet or render function */
28
+ arg?: T;
29
+ } = $props();
30
+ </script>
31
+
32
+ {#if text}
33
+ {text}
34
+ {:else if snippet}
35
+ {#if arg !== undefined}
36
+ {@render snippet(arg)}
37
+ {:else}
38
+ {@render (snippet as Snippet<[]>)()}
39
+ {/if}
40
+ {:else if render}
41
+ {#if arg !== undefined}
42
+ {@render render(arg)}
43
+ {:else}
44
+ {@render (render as RenderFn<[]>)()}
45
+ {/if}
46
+ {:else if Comp}
47
+ <Comp {...props} />
48
+ {/if}
49
+
@@ -0,0 +1,32 @@
1
+ import type { Component, Snippet } from "svelte";
2
+ import type { RenderFn } from "../types.js";
3
+ declare class __sveltets_Render<T = undefined> {
4
+ props(): {
5
+ /** Plain text content to display */
6
+ text?: string;
7
+ /** Svelte snippet to render */
8
+ snippet?: (T extends undefined ? Snippet<[]> : Snippet<[T]>) | undefined;
9
+ /** Render function (for object literals - avoids Svelte 5 snippet typing issues) */
10
+ render?: (T extends undefined ? RenderFn<[]> : RenderFn<[T]>) | undefined;
11
+ /** Svelte component to instantiate */
12
+ component?: Component;
13
+ /** Props to pass to the component */
14
+ props?: Record<string, any>;
15
+ /** Argument to pass to snippet or render function */
16
+ arg?: T | undefined;
17
+ };
18
+ events(): {};
19
+ slots(): {};
20
+ bindings(): "";
21
+ exports(): {};
22
+ }
23
+ interface $$IsomorphicComponent {
24
+ new <T = undefined>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
25
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
26
+ } & ReturnType<__sveltets_Render<T>['exports']>;
27
+ <T = undefined>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
28
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
29
+ }
30
+ declare const RenderContent: $$IsomorphicComponent;
31
+ type RenderContent<T = undefined> = InstanceType<typeof RenderContent<T>>;
32
+ export default RenderContent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lutra",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "bump-and-publish:patch": "pnpm version:patch && pnpm build && npm publish",
@@ -40,23 +40,23 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@sveltejs/adapter-auto": "^7.0.0",
43
- "@sveltejs/kit": "^2.47.0",
44
- "@sveltejs/package": "^2.5.4",
43
+ "@sveltejs/kit": "^2.49.2",
44
+ "@sveltejs/package": "^2.5.7",
45
45
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
46
- "@types/node": "^24.8.0",
47
- "publint": "^0.3.14",
48
- "svelte": "^5.40.1",
49
- "svelte-check": "^4.3.3",
46
+ "@types/node": "^25.0.1",
47
+ "publint": "^0.3.16",
48
+ "svelte": "^5.45.10",
49
+ "svelte-check": "^4.3.4",
50
50
  "typescript": "^5.9.3",
51
- "vite": "^7.1.10"
51
+ "vite": "^7.2.7"
52
52
  },
53
53
  "dependencies": {
54
54
  "@auth70/bodyguard": "^1.7.1",
55
55
  "blurhash": "^2.0.5",
56
56
  "browser-image-compression": "^2.0.2",
57
57
  "esm-env": "^1.2.2",
58
- "marked": "16.4.0",
59
- "zod": "^4.1.12",
58
+ "marked": "17.0.1",
59
+ "zod": "^4.1.13",
60
60
  "zodex": "^4.0.1"
61
61
  }
62
62
  }
@@ -1,20 +0,0 @@
1
- <script lang="ts">
2
- import type { Component } from "svelte";
3
- let {
4
- content,
5
- props,
6
- }: {
7
- content: string | Component | undefined;
8
- props?: Record<string, any>;
9
- } = $props();
10
-
11
- let Content = content as Component;
12
- </script>
13
-
14
- {#if content}
15
- {#if typeof content === 'string'}
16
- {content}
17
- {:else if Content}
18
- <Content {...props} />
19
- {/if}
20
- {/if}
@@ -1,8 +0,0 @@
1
- import type { Component } from "svelte";
2
- type $$ComponentProps = {
3
- content: string | Component | undefined;
4
- props?: Record<string, any>;
5
- };
6
- declare const StringOrComponent: Component<$$ComponentProps, {}, "">;
7
- type StringOrComponent = ReturnType<typeof StringOrComponent>;
8
- export default StringOrComponent;