@axium/client 0.17.3 → 0.18.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.
@@ -16,15 +16,54 @@
16
16
  }
17
17
  }
18
18
 
19
- :root {
20
- --A-zoom: zoom 0.25s cubic-bezier(0.35, 1.55, 0.65, 1);
21
- --A-fade: fade 0.25s ease-out;
19
+ @keyframes fade-subtle {
20
+ from {
21
+ opacity: 0.75;
22
+ }
23
+ to {
24
+ opacity: 1;
25
+ }
26
+ }
27
+
28
+ @keyframes slide-up {
29
+ from {
30
+ translate: 0 var(--A-slide-amount, 100%);
31
+ }
32
+ to {
33
+ translate: 0 0;
34
+ }
22
35
  }
23
36
 
24
- dialog[open] {
37
+ @keyframes slide-right {
38
+ from {
39
+ translate: 0 0;
40
+ }
41
+ to {
42
+ translate: var(--A-slide-amount, 100%) 0;
43
+ }
44
+ }
45
+
46
+ @media not (prefers-reduced-motion) {
47
+ :root {
48
+ /* CSS variables to make using the animations easier.
49
+ Prefixed with `A-` to avoid namespace pollution. */
50
+ --A-zoom: zoom 0.25s cubic-bezier(0.35, 1.55, 0.65, 1);
51
+ --A-fade: fade 0.25s ease-out;
52
+ --A-slide-up: slide-up 0.25s ease;
53
+ --A-slide-right: slide-right 0.25s ease;
54
+ --A-slide-out-right: slide-right 0.25s ease forwards, var(--A-fade) reverse forwards;
55
+ }
56
+ }
57
+
58
+ dialog:open {
25
59
  animation: var(--A-zoom);
26
60
  }
27
61
 
28
- dialog[open]::backdrop {
62
+ dialog:open::backdrop {
29
63
  animation: var(--A-fade);
30
64
  }
65
+
66
+ .toast {
67
+ --A-slide-amount: 100px;
68
+ animation: var(--A-slide-up);
69
+ }
package/assets/styles.css CHANGED
@@ -120,7 +120,7 @@ button {
120
120
  cursor: pointer;
121
121
  }
122
122
 
123
- button:hover {
123
+ button:not(.reset):hover {
124
124
  background-color: hsl(var(--hue) 15 calc(var(--bg-light) + (var(--light-step) * 2)));
125
125
  }
126
126
 
@@ -144,7 +144,7 @@ dialog {
144
144
  }
145
145
  }
146
146
 
147
- dialog::backdrop {
147
+ dialog:modal::backdrop {
148
148
  background: #0003;
149
149
  }
150
150
 
@@ -178,24 +178,33 @@ progress::-moz-progress-bar {
178
178
  background-color: var(--bg-accent);
179
179
  }
180
180
 
181
- :not(input).error {
181
+ :is(p, span, i).error {
182
+ color: var(--fg-error);
183
+ }
184
+
185
+ input.error {
186
+ border: var(--border-error);
187
+ }
188
+
189
+ div.error {
182
190
  padding: 1em;
183
191
  border-radius: 0.5em;
184
192
  background-color: var(--bg-error);
193
+ border: var(--border-error);
185
194
  }
186
195
 
187
- .error-text {
188
- color: hsl(0 50 50%);
189
- }
190
-
191
- input.error {
192
- border: 1px solid var(--bg-error);
196
+ div.warning {
197
+ padding: 1em;
198
+ border-radius: 0.5em;
199
+ background-color: var(--bg-warning);
200
+ border: var(--border-warning);
193
201
  }
194
202
 
195
- .success {
203
+ div.success {
196
204
  padding: 1em;
197
205
  border-radius: 0.5em;
198
206
  background-color: var(--bg-success);
207
+ border: var(--border-success);
199
208
  }
200
209
 
201
210
  .subtle {
@@ -215,6 +224,7 @@ input.error {
215
224
  border: var(--border-error);
216
225
  background-color: hsl(0 20 calc(var(--bg-light) + var(--light-step)));
217
226
  color: hsl(0 33 var(--fg-light));
227
+ --fill: hsl(0 33 var(--fg-light));
218
228
  accent-color: hsl(0 33 var(--fg-light));
219
229
  }
220
230
 
@@ -308,6 +318,36 @@ h6 {
308
318
  }
309
319
  }
310
320
 
321
+ #toasts {
322
+ position: absolute;
323
+ right: 1em;
324
+ top: 1em;
325
+ width: 0;
326
+ height: 0;
327
+ overflow: visible;
328
+ display: flex;
329
+ flex-direction: column;
330
+ gap: 1em;
331
+ text-align: right;
332
+ align-items: end;
333
+ }
334
+
335
+ div.toast {
336
+ width: max-content;
337
+ padding: 0.4em 0.8em;
338
+ border-radius: 0.5em;
339
+
340
+ &:hover {
341
+ animation-play-state: paused;
342
+ }
343
+
344
+ &.plain,
345
+ &.info {
346
+ border: var(--border-accent);
347
+ background-color: var(--bg-accent);
348
+ }
349
+ }
350
+
311
351
  @media (width >= 700px) {
312
352
  .mobile-only {
313
353
  display: none;
@@ -333,7 +373,7 @@ h6 {
333
373
  }
334
374
 
335
375
  .mobile-button:hover {
336
- background-color: hsl(var(--hue) 15 calc(var(--bg-light) + (var(--light-step) * 2)));
376
+ background-color: var(--bg-accent);
337
377
  }
338
378
 
339
379
  .mobile-float-left {
package/assets/theme.css CHANGED
@@ -35,10 +35,20 @@ html {
35
35
  --bg-accent: hsl(var(--hue) 15 calc(var(--bg-light) + (var(--light-step) * 2)));
36
36
  --bg-alt: hsl(var(--hue) 5 calc(var(--bg-light) + var(--light-step)));
37
37
  --bg-strong: hsl(var(--hue) 20 calc(var(--bg-light) + var(--light-step)));
38
+
38
39
  --bg-error: hsl(0 40 var(--bg-light-status));
40
+ --bg-warning: hsl(60 40 var(--bg-light-status));
39
41
  --bg-success: hsl(120 40 var(--bg-light-status));
42
+
40
43
  --fg-accent: hsl(var(--hue) 15 var(--fg-light));
41
44
  --fg-strong: hsl(var(--hue) 25 calc(var(--fg-light) - var(--light-step)));
45
+
46
+ --fg-error: hsl(0 50 calc(var(--fg-light) - var(--light-step)));
47
+ --fg-warning: hsl(60 50 calc(var(--fg-light) - var(--light-step)));
48
+
42
49
  --border-accent: 1px solid hsl(var(--hue) 10 calc(var(--bg-light) + (var(--light-step) * 3)));
50
+
43
51
  --border-error: 1px solid hsl(0 50 var(--fg-light));
52
+ --border-warning: 1px solid hsl(60 50 var(--fg-light));
53
+ --border-success: 1px solid hsl(120 50 var(--fg-light));
44
54
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns whether an element currently has an animation.
3
+ * This will be empty or `none` when prefers-reduced-motion is enabled
4
+ */
5
+ export declare function hasAnimation(element: HTMLElement): boolean;
6
+ export declare function onAnimationEnd(element: HTMLElement): Promise<void>;
7
+ /** Waits for an animation to complete on an element, after any other animations. */
8
+ export declare function animate(element: HTMLElement, animation: string): Promise<void>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Returns whether an element currently has an animation.
3
+ * This will be empty or `none` when prefers-reduced-motion is enabled
4
+ */
5
+ export function hasAnimation(element) {
6
+ const { animationName } = getComputedStyle(element);
7
+ return !!animationName && animationName !== 'none';
8
+ }
9
+ const pending = new WeakMap();
10
+ export function onAnimationEnd(element) {
11
+ if (!hasAnimation(element))
12
+ return Promise.resolve();
13
+ const { promise, resolve } = Promise.withResolvers();
14
+ element.addEventListener('animationend', () => resolve(), { once: true });
15
+ element.addEventListener('animationcancel', () => resolve(), { once: true });
16
+ return promise;
17
+ }
18
+ /** Waits for an animation to complete on an element, after any other animations. */
19
+ export async function animate(element, animation) {
20
+ await pending.get(element);
21
+ element.style.animation = animation;
22
+ const ended = onAnimationEnd(element);
23
+ pending.set(element, ended);
24
+ await ended;
25
+ if (pending.get(element) === ended)
26
+ pending.delete(element);
27
+ }
@@ -0,0 +1,2 @@
1
+ export * from './animate.js';
2
+ export * from './clipboard.js';
@@ -0,0 +1,2 @@
1
+ export * from './animate.js';
2
+ export * from './clipboard.js';
package/dist/locales.d.ts CHANGED
@@ -10,6 +10,8 @@ declare let currentLoaded: {
10
10
  readonly named_title: "Permissions for <strong>{name}</strong>";
11
11
  readonly owner: "Owner";
12
12
  readonly title: "Permissions";
13
+ readonly remove: "Remove";
14
+ readonly toast_removed: "Removed access";
13
15
  };
14
16
  readonly AppMenu: {
15
17
  readonly failed: "Couldn't load apps.";
@@ -78,6 +80,7 @@ declare let currentLoaded: {
78
80
  readonly preferences: "Preferences";
79
81
  readonly register: "Register";
80
82
  readonly sessions: "Sessions";
83
+ readonly success: "Success";
81
84
  readonly unknown: "Unknown";
82
85
  readonly unnamed: "Unnamed";
83
86
  readonly username: "Name";
@@ -1,8 +1,9 @@
1
1
  <script lang="ts">
2
- import { addToACL, getACL, text, updateACL, userInfo } from '@axium/client';
2
+ import { addToACL, getACL, removeFromACL, text, updateACL, userInfo } from '@axium/client';
3
3
  import type { AccessControllable, AccessTarget, User } from '@axium/core';
4
4
  import { getTarget, pickPermissions } from '@axium/core';
5
5
  import { errorText } from '@axium/core/io';
6
+ import { toastStatus } from './toast.js';
6
7
  import type { HTMLDialogAttributes } from 'svelte/elements';
7
8
  import Icon from './Icon.svelte';
8
9
  import UserCard from './UserCard.svelte';
@@ -27,7 +28,7 @@
27
28
  }
28
29
  </script>
29
30
 
30
- <dialog bind:this={dialog} {...rest}>
31
+ <dialog bind:this={dialog} {...rest} onclick={e => e.stopPropagation()}>
31
32
  {#if item.name}
32
33
  <h3>{@html text('component.AccessControlDialog.named_title', { $html: true, name: item.name })}</h3>
33
34
  {:else}
@@ -38,16 +39,16 @@
38
39
  <div class="error">{error}</div>
39
40
  {/if}
40
41
 
41
- <div class="AccessControl">
42
+ <div class="AccessControl text">
42
43
  {#if item.user}
43
44
  <UserCard user={item.user} />
44
45
  {:else if item}
45
- {#await userInfo(item.userId) then user}<UserCard {user} />{/await}
46
+ {#await userInfo(item.userId) then user}<UserCard {user} />{:catch}<i>{text('generic.unknown')}</i>{/await}
46
47
  {/if}
47
48
  <span>{text('component.AccessControlDialog.owner')}</span>
48
49
  </div>
49
50
 
50
- {#each acl as control}
51
+ {#each acl as control, i (control.userId || control.role || control.tag)}
51
52
  {@const update = (key: string) => async (e: Event & { currentTarget: HTMLInputElement }) => {
52
53
  try {
53
54
  const updated = await updateACL(itemType, item.id, getTarget(control), { [key]: e.currentTarget.checked });
@@ -57,16 +58,36 @@
57
58
  }
58
59
  }}
59
60
  <div class="AccessControl">
60
- {#if control.user}
61
- <UserCard user={control.user} />
62
- {:else if control.role}
63
- <span class="icon-text">
64
- <Icon i="at" />
65
- <span>{control.role}</span>
66
- </span>
67
- {:else}
68
- <i>{text('generic.unknown')}</i>
69
- {/if}
61
+ <div class="target">
62
+ {#if control.user}
63
+ <UserCard user={control.user} />
64
+ {:else if control.role}
65
+ <span class="icon-text">
66
+ <Icon i="at" />
67
+ <span>{control.role}</span>
68
+ </span>
69
+ {:else if control.tag}
70
+ <span class="icon-text">
71
+ <Icon i="hashtag" />
72
+ <span>{control.tag}</span>
73
+ </span>
74
+ {:else}
75
+ <i>{text('generic.unknown')}</i>
76
+ {/if}
77
+ {#if editable}
78
+ <button
79
+ class="icon-text danger"
80
+ onclick={() =>
81
+ toastStatus(
82
+ removeFromACL(itemType, item.id, getTarget(control)).then(() => acl.splice(i, 1)),
83
+ text('component.AccessControlDialog.toast_removed')
84
+ )}
85
+ >
86
+ <Icon i="user-minus" />
87
+ <span>{text('component.AccessControlDialog.remove')}</span>
88
+ </button>
89
+ {/if}
90
+ </div>
70
91
  <div class="permissions">
71
92
  {#each Object.entries(pickPermissions(control) as Record<string, boolean>) as [key, value]}
72
93
  {@const id = `${item.id}.${getTarget(control)}.${key}`}
@@ -79,7 +100,7 @@
79
100
  {:else}
80
101
  <Icon i={value ? 'check' : 'xmark'} />
81
102
  {/if}
82
- <span>{key}</span>
103
+ <span>{text(`permission.${itemType}.${key}`, { $default: key })}</span>
83
104
  </span>
84
105
  {/each}
85
106
  </div>
@@ -113,12 +134,23 @@
113
134
  .AccessControl {
114
135
  display: grid;
115
136
  gap: 1em;
116
- grid-template-columns: 1fr 10em;
117
- min-width: 30em;
137
+ grid-template-columns: 1fr 1fr;
118
138
  padding: 1em 2em;
139
+ border-bottom: var(--border-accent);
140
+
141
+ &.text {
142
+ align-items: center;
143
+ }
144
+
145
+ .target {
146
+ display: flex;
147
+ flex-direction: column;
148
+ justify-content: space-around;
149
+ gap: 1em;
150
+ }
119
151
 
120
- &:not(.public) {
121
- border-bottom: var(--border-accent);
152
+ @media (width > 700px) {
153
+ min-width: 30em;
122
154
  }
123
155
  }
124
156
  </style>
@@ -2,7 +2,7 @@
2
2
  import { fade } from 'svelte/transition';
3
3
  import { wait } from 'utilium';
4
4
  import Icon from './Icon.svelte';
5
- import * as clip from '@axium/client/clipboard';
5
+ import * as clip from '@axium/client/gui';
6
6
 
7
7
  const { value, type = 'text/plain' }: { value: BlobPart; type?: string } = $props();
8
8
 
@@ -62,7 +62,7 @@
62
62
  <button type="submit" class={['submit', submitDanger && 'danger']}>{submitText}</button>
63
63
  {/snippet}
64
64
 
65
- <dialog bind:this={dialog} {onclose} {...rest}>
65
+ <dialog bind:this={dialog} {onclose} {...rest} onclick={e => e.stopPropagation()}>
66
66
  {@render header?.()}
67
67
  <form {onsubmit} class="main" method="dialog">
68
68
  {#if error}
@@ -67,15 +67,11 @@
67
67
  <span><img src={getUserImage(result.value)} alt={result.value.name} />{result.value.name}</span>
68
68
  {:else if result.type == 'role'}
69
69
  <span>
70
- <span class="icon-text tag-or-role" style:background-color={colorHashRGB(result.value)}
71
- ><Icon i="at" />{result.value}</span
72
- >
70
+ <span class="icon-text non-user"><Icon i="at" />{result.value}</span>
73
71
  </span>
74
72
  {:else if result.type == 'tag'}
75
73
  <span>
76
- <span class="icon-text tag-or-role" style:background-color={colorHashRGB(result.value)}
77
- ><Icon i="hashtag" />{result.value}</span
78
- >
74
+ <span class="icon-text non-user"><Icon i="hashtag" />{result.value}</span>
79
75
  </span>
80
76
  {:else if result.type == 'exact'}
81
77
  <span class="non-user">{result.value}</span>
@@ -90,7 +90,7 @@
90
90
  {#snippet _in(rest: HTMLInputAttributes)}
91
91
  <div class="ZodInput">
92
92
  {#if !noLabel}<label for={id}>{labelText}</label>{/if}
93
- {#if error}<span class="ZodInput-error error-text">{error}</span>{/if}
93
+ {#if error}<span class="ZodInput-error">{error}</span>{/if}
94
94
  <input {id} {...rest} bind:value {onchange} {oninput} required={!optional} {defaultValue} class={[error && 'error']} />
95
95
  </div>
96
96
  {/snippet}
@@ -136,7 +136,7 @@
136
136
  {:else if schema.type == 'array'}
137
137
  <div class="ZodInput">
138
138
  {#if !noLabel}<label for={id}>{labelText}</label>{/if}
139
- {#if error}<span class="ZodInput-error error-text">{error}</span>{/if}
139
+ {#if error}<span class="ZodInput-error">{error}</span>{/if}
140
140
  <div class="ZodInput-array">
141
141
  {#each value, i}
142
142
  <div class="ZodInput-element">
@@ -180,7 +180,7 @@
180
180
  {:else if schema.type == 'enum'}
181
181
  <div class="ZodInput">
182
182
  {#if !noLabel}<label for={id}>{labelText}</label>{/if}
183
- {#if error}<span class="ZodInput-error error-text">{error}</span>{/if}
183
+ {#if error}<span class="ZodInput-error">{error}</span>{/if}
184
184
  <select {id} {onchange} bind:value required={!optional}>
185
185
  {#each Object.entries(schema.enum) as [key, value]}
186
186
  <option {value} selected={value === value}>{key}</option>
@@ -189,7 +189,7 @@
189
189
  </div>
190
190
  {:else}
191
191
  <!-- No idea how to render this -->
192
- <i class="error-text">{text('component.ZodInput.invalid_type', { type: JSON.stringify((schema as ZodPref)?.def?.type) })}</i>
192
+ <i class="error">{text('component.ZodInput.invalid_type', { type: JSON.stringify((schema as ZodPref)?.def?.type) })}</i>
193
193
  {/if}
194
194
 
195
195
  <style>
@@ -198,6 +198,7 @@
198
198
  position-anchor: --zod-input;
199
199
  bottom: calc(anchor(top) - 0.3em);
200
200
  left: anchor(left);
201
+ color: var(--fg-error);
201
202
  }
202
203
 
203
204
  .ZodInput {
@@ -49,6 +49,10 @@ export function contextMenu(...menuItems: (ContextMenuItem | false | null | unde
49
49
  let _forcePopover = false;
50
50
 
51
51
  element.oncontextmenu = (e: MouseEvent) => {
52
+ for (let node = e.target as HTMLElement | null; node && node !== element; node = node.parentElement) {
53
+ if (node instanceof HTMLDialogElement || (node.popover && node !== menu)) return;
54
+ }
55
+
52
56
  e.preventDefault();
53
57
  e.stopPropagation();
54
58
 
package/lib/index.ts CHANGED
@@ -11,7 +11,6 @@ export { default as Popover } from './Popover.svelte';
11
11
  export { default as Register } from './Register.svelte';
12
12
  export { default as SessionList } from './SessionList.svelte';
13
13
  export { default as SidebarLayout } from './SidebarLayout.svelte';
14
- export { default as Toast } from './Toast.svelte';
15
14
  export { default as Upload } from './Upload.svelte';
16
15
  export { default as URLText } from './URLText.svelte';
17
16
  export { default as UserCard } from './UserCard.svelte';
package/lib/toast.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { debug, errorText } from '@axium/core/io';
2
+ import { text } from '@axium/client/locales';
3
+ import { animate } from '@axium/client/gui';
4
+ import Icon from './Icon.svelte';
5
+ import { mount } from 'svelte';
6
+
7
+ const list = document.querySelector<HTMLDivElement>('#toasts')!;
8
+
9
+ const toastIcons = {
10
+ success: 'check',
11
+ warning: 'regular/triangle-exclamation',
12
+ error: 'octagon-xmark',
13
+ info: 'regular/circle-info',
14
+ };
15
+
16
+ /** Used to determine icon and styling */
17
+ export type ToastType = 'plain' | keyof typeof toastIcons;
18
+
19
+ const durationMultiplier = {
20
+ warning: 1.25,
21
+ error: 1.5,
22
+ } as Record<ToastType, number>;
23
+
24
+ export async function toast(type: ToastType, message: any): Promise<void> {
25
+ const text = errorText(message);
26
+
27
+ const toast = document.createElement('div');
28
+ toast.classList.add('toast', 'icon-text', type);
29
+ if (type != 'plain') mount(Icon, { target: toast, props: { i: toastIcons[type] } });
30
+
31
+ const span = document.createElement('span');
32
+ span.textContent = text;
33
+ toast.appendChild(span);
34
+
35
+ list.appendChild(toast);
36
+
37
+ async function dismiss() {
38
+ debug('Toast dismissed');
39
+ await animate(toast, 'var(--A-slide-out-right)');
40
+ toast.remove();
41
+ }
42
+
43
+ let persisted = false;
44
+
45
+ function persist() {
46
+ debug('Toast persisted');
47
+ persisted = true;
48
+ toast.onclick = null;
49
+ toast.style.animation = 'none';
50
+ toast.style.opacity = '1';
51
+
52
+ const button = document.createElement('button');
53
+ button.classList.add('reset');
54
+ button.onclick = dismiss;
55
+ mount(Icon, { target: button, props: { i: 'xmark-large' } });
56
+ toast.appendChild(button);
57
+ }
58
+
59
+ if (message && message instanceof Error) return persist();
60
+
61
+ /**
62
+ * @see https://ux.stackexchange.com/a/85898
63
+ */
64
+ const duration = Math.min(Math.max(text.length * 50 * (durationMultiplier[type] || 1), 2000), 7000);
65
+
66
+ toast.onclick = persist;
67
+ await animate(toast, `fade-subtle ${duration}ms ease reverse forwards`);
68
+ if (!persisted) await dismiss();
69
+ else debug('Toast not auto-dismissed due to persistence');
70
+ }
71
+
72
+ export async function toastStatus(promise: Promise<unknown>, successMessage: string = text('generic.success')): Promise<void> {
73
+ try {
74
+ await promise;
75
+ await toast('success', successMessage);
76
+ } catch (err) {
77
+ await toast('error', err);
78
+ }
79
+ }
80
+
81
+ Object.assign(globalThis, { toast, toastStatus });
package/locales/en.json CHANGED
@@ -3,7 +3,9 @@
3
3
  "AccessControlDialog": {
4
4
  "named_title": "Permissions for <strong>{name}</strong>",
5
5
  "owner": "Owner",
6
- "title": "Permissions"
6
+ "title": "Permissions",
7
+ "remove": "Remove",
8
+ "toast_removed": "Removed access"
7
9
  },
8
10
  "AppMenu": {
9
11
  "failed": "Couldn't load apps.",
@@ -72,6 +74,7 @@
72
74
  "preferences": "Preferences",
73
75
  "register": "Register",
74
76
  "sessions": "Sessions",
77
+ "success": "Success",
75
78
  "unknown": "Unknown",
76
79
  "unnamed": "Unnamed",
77
80
  "username": "Name",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.17.3",
3
+ "version": "0.18.0",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -33,6 +33,7 @@
33
33
  "exports": {
34
34
  "./package.json": "./package.json",
35
35
  ".": "./dist/index.js",
36
+ "./gui": "./dist/gui/index.js",
36
37
  "./*": "./dist/*.js",
37
38
  "./components": "./lib/index.js",
38
39
  "./components/*": "./lib/*.svelte",
package/lib/Toast.svelte DELETED
@@ -1,35 +0,0 @@
1
- <script lang="ts">
2
- import { fade } from 'svelte/transition';
3
-
4
- const { enabled, children, delay = 5000, duration = 1000, ...rest } = $props();
5
-
6
- let hiding = $state(false);
7
-
8
- const show = $derived(enabled && !hiding);
9
- </script>
10
-
11
- {#if show}
12
- <div
13
- class="Toast"
14
- in:fade|global={{ duration }}
15
- onintroend={() => (hiding = true)}
16
- out:fade|global={{ delay, duration }}
17
- onoutroend={() => (hiding = false)}
18
- {...rest}
19
- >
20
- {@render children()}
21
- </div>
22
- {/if}
23
-
24
- <style>
25
- .Toast {
26
- position: fixed;
27
- bottom: 1em;
28
- left: calc(50% - 10em);
29
- right: calc(50% - 10em);
30
- width: 20em;
31
- padding: 0.5em 1em;
32
- border-radius: 1em;
33
- opacity: 0.5;
34
- }
35
- </style>
File without changes
File without changes