lutra 0.1.29 → 0.1.30

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,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import UiContent from "./UIContent.svelte";
3
+ import ModalContent from "./ModalContent.svelte";
3
4
  import { getContext, type Snippet } from "svelte";
4
- import { slidefade } from "../util/transitions.js";
5
5
  import { attr } from "../util/attr.js";
6
6
 
7
7
  /**
@@ -17,7 +17,6 @@
17
17
  * <div>bar</div>
18
18
  * {/snippet}
19
19
  * <Modal trigger={trigger} content={content} />
20
- * <Modal trigger={trigger} content={content} hover />
21
20
  * </div>
22
21
  */
23
22
  let {
@@ -25,7 +24,15 @@
25
24
  content,
26
25
  buttons,
27
26
  trigger,
27
+ title,
28
28
  shape = "rounded",
29
+ unstyled = false,
30
+ showScrim = true,
31
+ closeOnScrim = true,
32
+ trapFocus = true,
33
+ dismissOnEsc = true,
34
+ maxWidth,
35
+ maxHeight,
29
36
  }: {
30
37
  /** Whether the modal should be contained with a border */
31
38
  contained?: boolean;
@@ -35,8 +42,24 @@
35
42
  trigger: Snippet<[attrs: (node: Element) => void]>;
36
43
  /** Buttons to be displayed in the modal */
37
44
  buttons?: Snippet<[close: () => void]>;
45
+ /** Optional title for the modal (improves a11y) */
46
+ title?: string;
38
47
  /** The shape of the modal */
39
48
  shape?: "rounded" | "sharp";
49
+ /** Whether to remove default styling */
50
+ unstyled?: boolean;
51
+ /** Whether to show the backdrop scrim */
52
+ showScrim?: boolean;
53
+ /** Whether clicking the scrim closes the modal */
54
+ closeOnScrim?: boolean;
55
+ /** Whether to trap focus within the modal */
56
+ trapFocus?: boolean;
57
+ /** Whether pressing Escape closes the modal */
58
+ dismissOnEsc?: boolean;
59
+ /** Maximum width of the modal */
60
+ maxWidth?: string;
61
+ /** Maximum height of the modal */
62
+ maxHeight?: string;
40
63
  } = $props();
41
64
 
42
65
  if(contained === undefined) { contained = getContext('lutra.modal.contained') ?? getContext('lutra.contained') ?? false; }
@@ -44,20 +67,20 @@
44
67
  const id = `po-${Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)}`;
45
68
  let isOpen = $state(false);
46
69
 
47
- function closeModal() { document.getElementById(id)!.hidePopover(); isOpen = false; }
48
- function toggleModal() { isOpen = !isOpen; }
49
-
50
- function clickElsewhere(e: MouseEvent) {
51
- if (e.target instanceof HTMLElement && !e.target.closest('.ModalContent')) {
52
- closeModal();
53
- }
70
+ function closeModal() {
71
+ document.getElementById(id)?.hidePopover();
72
+ isOpen = false;
73
+ }
74
+
75
+ function toggleModal() {
76
+ isOpen = !isOpen;
54
77
  }
55
78
 
56
79
  $effect(() => {
57
80
  if(isOpen) {
58
- document.getElementsByTagName("html")[0].style.overflow = "hidden";
81
+ document.documentElement.style.overflow = "hidden";
59
82
  } else {
60
- document.getElementsByTagName("html")[0].style.overflow = "auto";
83
+ document.documentElement.style.overflow = "";
61
84
  }
62
85
  });
63
86
 
@@ -77,19 +100,22 @@
77
100
  </div>
78
101
  {#if isOpen}
79
102
  <UiContent>
80
- <!-- svelte-ignore a11y_click_events_have_key_events -->
81
- <!-- svelte-ignore a11y_no_static_element_interactions -->
82
- <div {id} onclick={clickElsewhere} popover="auto" class="ModalContainer">
83
- <div class="ModalContent {shape}" class:contained>
84
- <div class="ModalContentInside">
85
- {@render content(closeModal)}
86
- {#if buttons}
87
- <div class="ModalActions">
88
- {@render buttons(closeModal)}
89
- </div>
90
- {/if}
91
- </div>
92
- </div>
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
+ />
93
119
  </div>
94
120
  </UiContent>
95
121
  {/if}
@@ -100,44 +126,21 @@
100
126
  position: relative;
101
127
  display: inline-block;
102
128
  }
129
+
103
130
  .ModalContainer {
104
131
  border: 0;
105
- width: 100vw;
106
- height: 100vh;
107
- background-color: var(--bg-overlay);
108
- backdrop-filter: var(--overlay-filter);
132
+ width: 100svw;
133
+ height: 100svh;
109
134
  overflow-y: auto;
110
- }
111
- .ModalContent {
112
- background: var(--bg, var(--background-main));
113
- box-shadow: var(--shadow);
114
- opacity: 1;
115
- position: absolute;
116
- left: 50%;
117
- top: 50%;
118
- transform: translate(-50%, -50%);
119
- box-shadow: 0 0.25rem 1rem 0 var(--shadow);
120
- }
121
- .ModalContentInsize {
122
- container-type: inline-size;
123
- }
124
- .ModalContent.rounded {
125
- border-radius: var(--border-radius);
126
- }
127
- .ModalContent.contained {
128
- border: var(--border);
129
- }
130
- .ModalActions {
131
135
  display: flex;
132
- gap: 1rem;
133
- border-top: var(--border);
134
- justify-content: flex-end;
135
- padding: 1rem;
136
- background: var(--bg-subtle) linear-gradient(0deg, transparent, 95%, color-mix(in hsl, transparent 95%, var(--mix-target)));
136
+ align-items: center;
137
+ justify-content: center;
137
138
  }
139
+
138
140
  [popover] {
139
141
  animation: fadeIn 0.2s;
140
142
  }
143
+
141
144
  @keyframes fadeIn {
142
145
  from {
143
146
  opacity: 0;
@@ -8,8 +8,24 @@ type $$ComponentProps = {
8
8
  trigger: Snippet<[attrs: (node: Element) => void]>;
9
9
  /** Buttons to be displayed in the modal */
10
10
  buttons?: Snippet<[close: () => void]>;
11
+ /** Optional title for the modal (improves a11y) */
12
+ title?: string;
11
13
  /** The shape of the modal */
12
14
  shape?: "rounded" | "sharp";
15
+ /** Whether to remove default styling */
16
+ unstyled?: boolean;
17
+ /** Whether to show the backdrop scrim */
18
+ showScrim?: boolean;
19
+ /** Whether clicking the scrim closes the modal */
20
+ closeOnScrim?: boolean;
21
+ /** Whether to trap focus within the modal */
22
+ trapFocus?: boolean;
23
+ /** Whether pressing Escape closes the modal */
24
+ dismissOnEsc?: boolean;
25
+ /** Maximum width of the modal */
26
+ maxWidth?: string;
27
+ /** Maximum height of the modal */
28
+ maxHeight?: string;
13
29
  };
14
30
  declare const Modal: import("svelte").Component<$$ComponentProps, {}, "">;
15
31
  type Modal = ReturnType<typeof Modal>;
@@ -0,0 +1,218 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { onMount, onDestroy } from 'svelte';
4
+
5
+ let {
6
+ children,
7
+ buttons,
8
+ close,
9
+ title,
10
+ shape = 'rounded',
11
+ contained = true,
12
+ unstyled = false,
13
+ showScrim = true,
14
+ closeOnScrim = true,
15
+ trapFocus = true,
16
+ dismissOnEsc = true,
17
+ maxWidth,
18
+ maxHeight,
19
+ }: {
20
+ children: Snippet<[close: () => void]>;
21
+ buttons?: Snippet<[close: () => void]>;
22
+ close: () => void;
23
+ title?: string;
24
+ shape?: 'rounded' | 'sharp';
25
+ contained?: boolean;
26
+ unstyled?: boolean;
27
+ showScrim?: boolean;
28
+ closeOnScrim?: boolean;
29
+ trapFocus?: boolean;
30
+ dismissOnEsc?: boolean;
31
+ maxWidth?: string;
32
+ maxHeight?: string;
33
+ } = $props();
34
+
35
+ let dialogEl: HTMLDivElement | null = $state(null);
36
+ let previousActiveElement: HTMLElement | null = null;
37
+ const titleId = title ? `modal-title-${crypto.randomUUID()}` : undefined;
38
+
39
+ onMount(() => {
40
+ previousActiveElement = document.activeElement as HTMLElement;
41
+ if (dialogEl && trapFocus) {
42
+ // Focus first focusable element
43
+ const focusableElements = getFocusableElements();
44
+ if (focusableElements.length > 0) {
45
+ focusableElements[0].focus();
46
+ }
47
+ }
48
+ });
49
+
50
+ onDestroy(() => {
51
+ // Restore focus on unmount
52
+ previousActiveElement?.focus();
53
+ });
54
+
55
+ function getFocusableElements(): HTMLElement[] {
56
+ if (!dialogEl) return [];
57
+ const selector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
58
+ return Array.from(dialogEl.querySelectorAll(selector));
59
+ }
60
+
61
+ function handleKeydown(e: KeyboardEvent) {
62
+ if (dismissOnEsc && e.key === 'Escape') {
63
+ e.preventDefault();
64
+ close();
65
+ return;
66
+ }
67
+
68
+ if (trapFocus && e.key === 'Tab') {
69
+ const focusableElements = getFocusableElements();
70
+ if (focusableElements.length === 0) return;
71
+
72
+ const firstElement = focusableElements[0];
73
+ const lastElement = focusableElements[focusableElements.length - 1];
74
+ const activeElement = document.activeElement;
75
+
76
+ if (e.shiftKey) {
77
+ // Shift+Tab
78
+ if (activeElement === firstElement) {
79
+ e.preventDefault();
80
+ lastElement.focus();
81
+ }
82
+ } else {
83
+ // Tab
84
+ if (activeElement === lastElement) {
85
+ e.preventDefault();
86
+ firstElement.focus();
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ function handleScrimClick(e: MouseEvent) {
93
+ if (closeOnScrim && e.target === e.currentTarget) {
94
+ close();
95
+ }
96
+ }
97
+ </script>
98
+
99
+ {#if showScrim}
100
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
101
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
102
+ <div class="ModalScrim" onclick={handleScrimClick}></div>
103
+ {/if}
104
+
105
+ <div
106
+ class="ModalContent {shape}"
107
+ class:contained
108
+ class:unstyled
109
+ role="dialog"
110
+ aria-modal="true"
111
+ aria-labelledby={titleId}
112
+ tabindex="-1"
113
+ bind:this={dialogEl}
114
+ onkeydown={handleKeydown}
115
+ style="--modal-max-width: {maxWidth}; --modal-max-height: {maxHeight};"
116
+ >
117
+ <div class="ModalContentArea">
118
+ {#if title}
119
+ <h2 id={titleId} class="ModalTitle">{title}</h2>
120
+ {/if}
121
+ {@render children(close)}
122
+ </div>
123
+ {#if buttons}
124
+ <div class="ModalActions">
125
+ {@render buttons(close)}
126
+ </div>
127
+ {/if}
128
+ </div>
129
+
130
+ <style>
131
+ .ModalScrim {
132
+ background: var(--scrim-background);
133
+ backdrop-filter: var(--scrim-backdrop-filter);
134
+ position: fixed;
135
+ inset: 0;
136
+ z-index: -1;
137
+ }
138
+
139
+ .ModalContent {
140
+ display: grid;
141
+ grid-template-rows: 1fr auto;
142
+ gap: var(--modal-gap);
143
+ max-width: min(var(--modal-max-width, 40rem), calc(100svw - 2rem));
144
+ max-height: min(var(--modal-max-height, 80svh), calc(100svh - 2rem));
145
+ background: var(--modal-background);
146
+ border: var(--modal-border);
147
+ border-radius: var(--modal-border-radius);
148
+ box-shadow: 0 0.5rem 1rem var(--modal-shadow-color);
149
+ overflow: hidden;
150
+ position: relative;
151
+ }
152
+
153
+ .ModalContent.sharp {
154
+ border-radius: 0;
155
+ }
156
+
157
+ .ModalContent:not(.contained) {
158
+ border: none;
159
+ }
160
+
161
+ .ModalContent.unstyled {
162
+ background: transparent;
163
+ border: none;
164
+ box-shadow: none;
165
+ max-width: none;
166
+ max-height: none;
167
+ display: block;
168
+ }
169
+
170
+ .ModalContentArea {
171
+ overflow: auto;
172
+ scrollbar-gutter: stable;
173
+ scrollbar-width: thin;
174
+ padding: var(--modal-padding);
175
+ text-wrap: pretty;
176
+ }
177
+
178
+ .ModalContent.unstyled .ModalContentArea {
179
+ padding: 0;
180
+ overflow: visible;
181
+ }
182
+
183
+ .ModalTitle {
184
+ margin-block-start: 0;
185
+ margin-block-end: var(--space-md);
186
+ font-size: var(--font-size-h4);
187
+ font-weight: var(--font-weight-semibold);
188
+ color: var(--text-color-heading);
189
+ line-height: var(--font-line-height-heading);
190
+ }
191
+
192
+ .ModalContent.unstyled .ModalTitle {
193
+ margin: 0;
194
+ }
195
+
196
+ .ModalActions {
197
+ background: var(--modal-actions-background);
198
+ border-top: var(--modal-border-size) var(--modal-border-style) var(--modal-actions-border-color);
199
+ padding: var(--modal-actions-padding);
200
+ display: flex;
201
+ gap: var(--space-sm);
202
+ justify-content: flex-end;
203
+ align-items: center;
204
+ }
205
+
206
+ .ModalContent.unstyled .ModalActions {
207
+ background: transparent;
208
+ border: none;
209
+ padding: 0;
210
+ }
211
+
212
+ @media (prefers-reduced-motion: reduce) {
213
+ .ModalContent {
214
+ transition: none;
215
+ }
216
+ }
217
+ </style>
218
+
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ children: Snippet<[close: () => void]>;
4
+ buttons?: Snippet<[close: () => void]>;
5
+ close: () => void;
6
+ title?: string;
7
+ shape?: 'rounded' | 'sharp';
8
+ contained?: boolean;
9
+ unstyled?: boolean;
10
+ showScrim?: boolean;
11
+ closeOnScrim?: boolean;
12
+ trapFocus?: boolean;
13
+ dismissOnEsc?: boolean;
14
+ maxWidth?: string;
15
+ maxHeight?: string;
16
+ };
17
+ declare const ModalContent: import("svelte").Component<$$ComponentProps, {}, "">;
18
+ type ModalContent = ReturnType<typeof ModalContent>;
19
+ export default ModalContent;
@@ -0,0 +1,29 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { OverlayPosition } from './overlays.svelte.js';
3
+ export type ModalButton = {
4
+ text: string;
5
+ variant?: 'action' | 'success' | 'danger' | 'ghost' | 'outline' | 'default';
6
+ onclick?: (close: () => void) => void | Promise<void>;
7
+ disabled?: boolean;
8
+ loading?: boolean;
9
+ };
10
+ export type ModalOptions = {
11
+ content: string | Snippet<[close: () => void]>;
12
+ title?: string;
13
+ buttons?: ModalButton[] | 'ok-cancel' | 'none';
14
+ position?: OverlayPosition;
15
+ width?: string;
16
+ maxWidth?: string;
17
+ maxHeight?: string;
18
+ shape?: 'rounded' | 'sharp';
19
+ contained?: boolean;
20
+ unstyled?: boolean;
21
+ showScrim?: boolean;
22
+ closeOnScrim?: boolean;
23
+ trapFocus?: boolean;
24
+ dismissOnEsc?: boolean;
25
+ onClose?: () => void;
26
+ onOpen?: () => void;
27
+ };
28
+ export declare const cancelButton: ModalButton;
29
+ export declare const okButton: ModalButton;
@@ -0,0 +1,3 @@
1
+ // Optional button presets
2
+ export const cancelButton = { text: 'Cancel', variant: 'outline' };
3
+ export const okButton = { text: 'OK', variant: 'action' };
@@ -13,6 +13,7 @@ export { default as MenuDropdown } from './MenuDropdown.svelte';
13
13
  export { default as MenuItem } from './MenuItem.svelte';
14
14
  export { default as MenuItemContent } from './MenuItemContent.svelte';
15
15
  export { default as Modal } from './Modal.svelte';
16
+ export { default as ModalContent } from './ModalContent.svelte';
16
17
  export { default as Notification } from './Notification.svelte';
17
18
  export { default as Overlay } from './Overlay.svelte';
18
19
  export { default as OverlayContainer } from './OverlayContainer.svelte';
@@ -24,5 +25,7 @@ export { default as Theme } from './Theme.svelte';
24
25
  export { default as Tooltip } from './Tooltip.svelte';
25
26
  export { default as UIContent } from './UIContent.svelte';
26
27
  export * from './MenuTypes.js';
28
+ export * from './ModalTypes.js';
27
29
  export * from './notifications.svelte.js';
30
+ export * from './modals.svelte.js';
28
31
  export * from './overlays.svelte.js';
@@ -13,6 +13,7 @@ export { default as MenuDropdown } from './MenuDropdown.svelte';
13
13
  export { default as MenuItem } from './MenuItem.svelte';
14
14
  export { default as MenuItemContent } from './MenuItemContent.svelte';
15
15
  export { default as Modal } from './Modal.svelte';
16
+ export { default as ModalContent } from './ModalContent.svelte';
16
17
  export { default as Notification } from './Notification.svelte';
17
18
  export { default as Overlay } from './Overlay.svelte';
18
19
  export { default as OverlayContainer } from './OverlayContainer.svelte';
@@ -25,5 +26,7 @@ export { default as Tooltip } from './Tooltip.svelte';
25
26
  export { default as UIContent } from './UIContent.svelte';
26
27
  // Export TypeScript files and stores
27
28
  export * from './MenuTypes.js';
29
+ export * from './ModalTypes.js';
28
30
  export * from './notifications.svelte.js';
31
+ export * from './modals.svelte.js';
29
32
  export * from './overlays.svelte.js';
@@ -0,0 +1,5 @@
1
+ import type { ModalOptions } from './ModalTypes.js';
2
+ export declare function openModal(opts: ModalOptions | string): {
3
+ id: string;
4
+ close: () => void;
5
+ };
@@ -0,0 +1,107 @@
1
+ import { addOverlay, removeOverlay } from './overlays.svelte.js';
2
+ import ModalContent from './ModalContent.svelte';
3
+ import { cancelButton, okButton } from './ModalTypes.js';
4
+ let modalCount = $state(0);
5
+ let activeElement = null;
6
+ function lockScroll() {
7
+ if (modalCount === 0) {
8
+ activeElement = document.activeElement;
9
+ document.documentElement.style.overflow = 'hidden';
10
+ }
11
+ modalCount++;
12
+ }
13
+ function unlockScroll() {
14
+ modalCount--;
15
+ if (modalCount === 0) {
16
+ document.documentElement.style.overflow = '';
17
+ activeElement?.focus();
18
+ activeElement = null;
19
+ }
20
+ }
21
+ export function openModal(opts) {
22
+ const id = crypto.randomUUID();
23
+ if (typeof opts === 'string') {
24
+ opts = { content: opts };
25
+ }
26
+ const close = () => {
27
+ removeOverlay(id);
28
+ unlockScroll();
29
+ opts.onClose?.();
30
+ };
31
+ lockScroll();
32
+ opts.onOpen?.();
33
+ // Handle defaultButtons shortcut
34
+ let buttons;
35
+ if (opts.buttons === 'ok-cancel') {
36
+ buttons = [cancelButton, okButton];
37
+ }
38
+ else if (opts.buttons === 'none' || opts.buttons === undefined) {
39
+ buttons = undefined;
40
+ }
41
+ else {
42
+ buttons = opts.buttons;
43
+ }
44
+ const buttonsSnippet = buttons ? createButtonsSnippet(buttons, close) : undefined;
45
+ addOverlay({
46
+ id,
47
+ z: 200,
48
+ component: ModalContent,
49
+ props: {
50
+ close,
51
+ children: wrapContent(opts.content, close),
52
+ buttons: buttonsSnippet,
53
+ title: opts.title,
54
+ showScrim: opts.showScrim ?? true,
55
+ closeOnScrim: opts.closeOnScrim ?? true,
56
+ trapFocus: opts.trapFocus ?? true,
57
+ dismissOnEsc: opts.dismissOnEsc ?? true,
58
+ unstyled: opts.unstyled ?? false,
59
+ shape: opts.shape ?? 'rounded',
60
+ contained: opts.contained ?? true,
61
+ maxWidth: opts.maxWidth,
62
+ maxHeight: opts.maxHeight,
63
+ },
64
+ position: opts.position || 'center',
65
+ layer: 'modals',
66
+ });
67
+ return { id, close };
68
+ }
69
+ function wrapContent(content, close) {
70
+ if (typeof content === 'string') {
71
+ // Return a snippet that renders the string
72
+ return () => content;
73
+ }
74
+ return content;
75
+ }
76
+ function createButtonsSnippet(buttons, close) {
77
+ // Create a snippet that renders the buttons
78
+ return (closeParam) => {
79
+ // We need to return actual DOM elements, not strings
80
+ // This will be rendered by Svelte's snippet system
81
+ const fragment = document.createDocumentFragment();
82
+ buttons.forEach((btn) => {
83
+ const button = document.createElement('button');
84
+ button.className = `button ${btn.variant || 'default'}`;
85
+ button.textContent = btn.text;
86
+ button.disabled = btn.disabled || btn.loading || false;
87
+ if (btn.onclick) {
88
+ button.addEventListener('click', async () => {
89
+ const result = btn.onclick?.(close);
90
+ if (result instanceof Promise) {
91
+ button.disabled = true;
92
+ button.classList.add('loading');
93
+ try {
94
+ await result;
95
+ }
96
+ finally {
97
+ button.disabled = false;
98
+ button.classList.remove('loading');
99
+ }
100
+ }
101
+ });
102
+ }
103
+ fragment.appendChild(button);
104
+ });
105
+ return fragment;
106
+ };
107
+ }
@@ -460,4 +460,33 @@
460
460
  @property --table-row-background-even { syntax: "<color>"; inherits: true; initial-value: #f8fafc; }
461
461
  @property --table-row-background-hover { syntax: "<color>"; inherits: true; initial-value: #f1f5f9; }
462
462
 
463
- @property --table-cell-color { syntax: "*"; inherits: true; initial-value: #1a1a1a; }
463
+ @property --table-cell-color { syntax: "*"; inherits: true; initial-value: #1a1a1a; }
464
+
465
+ /**
466
+ * Modal
467
+ */
468
+
469
+ @property --modal-background { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
470
+ @property --modal-border-color { syntax: "*"; inherits: true; initial-value: #d1d5db; }
471
+ @property --modal-border-size { syntax: "<length>"; inherits: true; initial-value: 1px; }
472
+ @property --modal-border-style { syntax: "solid | dashed | dotted | double | groove | ridge | inset | outset"; inherits: true; initial-value: solid; }
473
+ @property --modal-border-radius { syntax: "<length>"; inherits: true; initial-value: 8px; }
474
+ @property --modal-shadow-color { syntax: "*"; inherits: true; initial-value: rgba(0, 0, 0, 0.1); }
475
+
476
+ @property --modal-padding-inline { syntax: "<length>"; inherits: true; initial-value: 24px; }
477
+ @property --modal-padding-block { syntax: "<length>"; inherits: true; initial-value: 24px; }
478
+ @property --modal-max-width { syntax: "<length>"; inherits: true; initial-value: 40rem; }
479
+ @property --modal-max-height { syntax: "<length>"; inherits: true; initial-value: 80svh; }
480
+ @property --modal-gap { syntax: "<length>"; inherits: true; initial-value: 0px; }
481
+
482
+ @property --modal-actions-background { syntax: "<color>"; inherits: true; initial-value: #f8fafc; }
483
+ @property --modal-actions-border-color { syntax: "*"; inherits: true; initial-value: #d1d5db; }
484
+ @property --modal-actions-padding-inline { syntax: "<length>"; inherits: true; initial-value: 16px; }
485
+ @property --modal-actions-padding-block { syntax: "<length>"; inherits: true; initial-value: 16px; }
486
+
487
+ /**
488
+ * Scrim/Backdrop (shared across overlays)
489
+ */
490
+
491
+ @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); }
@@ -119,6 +119,11 @@
119
119
  --table-border: var(--table-border-size) var(--table-border-style) var(--table-border-color);
120
120
  --table-cell-padding: var(--table-cell-padding-block) var(--table-cell-padding-inline);
121
121
 
122
+ /* Modal compound variables */
123
+ --modal-border: var(--modal-border-size) var(--modal-border-style) var(--modal-border-color);
124
+ --modal-padding: var(--modal-padding-block) var(--modal-padding-inline);
125
+ --modal-actions-padding: var(--modal-actions-padding-block) var(--modal-actions-padding-inline);
126
+
122
127
  --mix-target: light-dark(black, white);
123
128
  }
124
129
 
@@ -233,6 +233,26 @@
233
233
  --table-row-background-even: transparent;
234
234
  --table-row-background-hover: color-mix(in srgb, var(--theme-surface-interactive) 60%, transparent);
235
235
  --table-cell-color: var(--text-color-p);
236
+
237
+ /**
238
+ * Modals
239
+ */
240
+
241
+ --modal-background: var(--background-body);
242
+ --modal-border-color: var(--border-color);
243
+ --modal-shadow-color: var(--shadow-color);
244
+ --modal-actions-background: var(--theme-surface-subtlest);
245
+ --modal-actions-border-color: var(--border-color);
246
+
247
+ /**
248
+ * Scrim/Backdrop
249
+ */
250
+
251
+ --scrim-background: light-dark(
252
+ oklch(from var(--lutra-primary-color) calc(l * 0.2) calc(c * 0.01) h / 0.5),
253
+ oklch(from var(--lutra-primary-color) calc(l * 0.1) calc(c * 0.01) h / 0.7)
254
+ );
255
+ --scrim-backdrop-filter: blur(2px);
236
256
 
237
257
  }
238
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lutra",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "bump-and-publish:patch": "pnpm version:patch && pnpm build && npm publish",