@ims360/svelte-ivory 0.0.2
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/LICENCE +23 -0
- package/README.md +15 -0
- package/dist/components/ai/AiMessage.svelte +115 -0
- package/dist/components/ai/AiMessage.svelte.d.ts +18 -0
- package/dist/components/ai/AttachedFile.svelte +28 -0
- package/dist/components/ai/AttachedFile.svelte.d.ts +9 -0
- package/dist/components/ai/Chat.svelte +150 -0
- package/dist/components/ai/Chat.svelte.d.ts +40 -0
- package/dist/components/ai/Markdown.svelte +59 -0
- package/dist/components/ai/Markdown.svelte.d.ts +16 -0
- package/dist/components/ai/UserMessage.svelte +53 -0
- package/dist/components/ai/UserMessage.svelte.d.ts +16 -0
- package/dist/components/ai/index.d.ts +5 -0
- package/dist/components/ai/index.js +5 -0
- package/dist/components/basic/checkbox/Checkbox.svelte +79 -0
- package/dist/components/basic/checkbox/Checkbox.svelte.d.ts +18 -0
- package/dist/components/basic/index.d.ts +2 -0
- package/dist/components/basic/index.js +2 -0
- package/dist/components/basic/toggle/Toggle.svelte +47 -0
- package/dist/components/basic/toggle/Toggle.svelte.d.ts +12 -0
- package/dist/components/buttons/CopyToClipboardButton.svelte +33 -0
- package/dist/components/buttons/CopyToClipboardButton.svelte.d.ts +9 -0
- package/dist/components/index.d.ts +0 -0
- package/dist/components/index.js +1 -0
- package/dist/components/layout/heading/Heading.svelte +33 -0
- package/dist/components/layout/heading/Heading.svelte.d.ts +16 -0
- package/dist/components/layout/heading/index.d.ts +5 -0
- package/dist/components/layout/heading/index.js +5 -0
- package/dist/components/layout/hiddenBackground/HiddenBackground.svelte +48 -0
- package/dist/components/layout/hiddenBackground/HiddenBackground.svelte.d.ts +13 -0
- package/dist/components/layout/hiddenBackground/index.js +6 -0
- package/dist/components/layout/index.d.ts +7 -0
- package/dist/components/layout/index.js +7 -0
- package/dist/components/layout/modal/Modal.svelte +114 -0
- package/dist/components/layout/modal/Modal.svelte.d.ts +30 -0
- package/dist/components/layout/modal/ModalTest.svelte +16 -0
- package/dist/components/layout/modal/ModalTest.svelte.d.ts +9 -0
- package/dist/components/layout/popover/Popover.svelte +108 -0
- package/dist/components/layout/popover/Popover.svelte.d.ts +36 -0
- package/dist/components/layout/portal/Portal.svelte +23 -0
- package/dist/components/layout/portal/Portal.svelte.d.ts +15 -0
- package/dist/components/layout/tabs/Tab.svelte +80 -0
- package/dist/components/layout/tabs/Tab.svelte.d.ts +21 -0
- package/dist/components/layout/tabs/TabPanel.svelte +23 -0
- package/dist/components/layout/tabs/TabPanel.svelte.d.ts +10 -0
- package/dist/components/layout/tabs/Tabs.svelte +86 -0
- package/dist/components/layout/tabs/Tabs.svelte.d.ts +21 -0
- package/dist/components/layout/tabs/index.d.ts +26 -0
- package/dist/components/layout/tabs/index.js +8 -0
- package/dist/components/layout/tooltip/Tooltip.svelte +111 -0
- package/dist/components/layout/tooltip/Tooltip.svelte.d.ts +32 -0
- package/dist/components/toast/Toast.svelte +100 -0
- package/dist/components/toast/Toast.svelte.d.ts +16 -0
- package/dist/components/toast/index.d.ts +2 -0
- package/dist/components/toast/index.js +2 -0
- package/dist/components/toast/toasts.svelte.d.ts +26 -0
- package/dist/components/toast/toasts.svelte.js +67 -0
- package/dist/index.d.ts +0 -0
- package/dist/index.js +2 -0
- package/dist/utils/actions/clickOutside.d.ts +11 -0
- package/dist/utils/actions/clickOutside.js +23 -0
- package/dist/utils/actions/focusTrap.d.ts +4 -0
- package/dist/utils/actions/focusTrap.js +64 -0
- package/dist/utils/actions/index.d.ts +5 -0
- package/dist/utils/actions/index.js +5 -0
- package/dist/utils/actions/portal.d.ts +9 -0
- package/dist/utils/actions/portal.js +39 -0
- package/dist/utils/actions/shortcut.d.ts +10 -0
- package/dist/utils/actions/shortcut.js +25 -0
- package/dist/utils/actions/visible.d.ts +5 -0
- package/dist/utils/actions/visible.js +14 -0
- package/dist/utils/functions/cookie.d.ts +12 -0
- package/dist/utils/functions/cookie.js +36 -0
- package/dist/utils/functions/index.d.ts +3 -0
- package/dist/utils/functions/index.js +3 -0
- package/dist/utils/functions/pseudoRandomId.d.ts +1 -0
- package/dist/utils/functions/pseudoRandomId.js +3 -0
- package/dist/utils/functions/queryParams.d.ts +1 -0
- package/dist/utils/functions/queryParams.js +14 -0
- package/package.json +107 -0
- package/src/lib/components/ai/AiMessage.svelte +115 -0
- package/src/lib/components/ai/AttachedFile.svelte +28 -0
- package/src/lib/components/ai/Chat.svelte +150 -0
- package/src/lib/components/ai/Markdown.svelte +59 -0
- package/src/lib/components/ai/UserMessage.svelte +53 -0
- package/src/lib/components/ai/index.ts +5 -0
- package/src/lib/components/basic/checkbox/Checkbox.svelte +79 -0
- package/src/lib/components/basic/checkbox/checkbox.svelte.spec.ts +39 -0
- package/src/lib/components/basic/index.ts +2 -0
- package/src/lib/components/basic/toggle/Toggle.svelte +47 -0
- package/src/lib/components/basic/toggle/toggle.svelte.spec.ts +19 -0
- package/src/lib/components/buttons/CopyToClipboardButton.svelte +33 -0
- package/src/lib/components/index.ts +0 -0
- package/src/lib/components/layout/heading/Heading.svelte +33 -0
- package/src/lib/components/layout/heading/index.ts +7 -0
- package/src/lib/components/layout/hiddenBackground/HiddenBackground.svelte +48 -0
- package/src/lib/components/layout/hiddenBackground/index.ts +8 -0
- package/src/lib/components/layout/index.ts +7 -0
- package/src/lib/components/layout/modal/Modal.svelte +114 -0
- package/src/lib/components/layout/modal/ModalTest.svelte +16 -0
- package/src/lib/components/layout/modal/modal.svelte.spec.ts +39 -0
- package/src/lib/components/layout/popover/Popover.svelte +108 -0
- package/src/lib/components/layout/portal/Portal.svelte +23 -0
- package/src/lib/components/layout/tabs/Tab.svelte +80 -0
- package/src/lib/components/layout/tabs/TabPanel.svelte +23 -0
- package/src/lib/components/layout/tabs/Tabs.svelte +86 -0
- package/src/lib/components/layout/tabs/Tabs.test.svelte +5 -0
- package/src/lib/components/layout/tabs/index.ts +10 -0
- package/src/lib/components/layout/tooltip/Tooltip.svelte +111 -0
- package/src/lib/components/toast/Toast.svelte +100 -0
- package/src/lib/components/toast/index.ts +2 -0
- package/src/lib/components/toast/toasts.svelte.ts +89 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/utils/actions/clickOutside.svelte.spec.ts +67 -0
- package/src/lib/utils/actions/clickOutside.ts +38 -0
- package/src/lib/utils/actions/focusTrap.ts +65 -0
- package/src/lib/utils/actions/index.ts +5 -0
- package/src/lib/utils/actions/portal.ts +43 -0
- package/src/lib/utils/actions/shortcut.svelte.spec.ts +19 -0
- package/src/lib/utils/actions/shortcut.ts +35 -0
- package/src/lib/utils/actions/visible.ts +28 -0
- package/src/lib/utils/functions/cookie.svelte.spec.ts +55 -0
- package/src/lib/utils/functions/cookie.ts +46 -0
- package/src/lib/utils/functions/index.ts +3 -0
- package/src/lib/utils/functions/pseudoRandomId.spec.ts +19 -0
- package/src/lib/utils/functions/pseudoRandomId.ts +4 -0
- package/src/lib/utils/functions/queryParams.spec.ts +25 -0
- package/src/lib/utils/functions/queryParams.ts +15 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Renders the toasts that have been triggered by the `Toasts` store.
|
|
4
|
+
Make sure to include this component in your root `+layout.svelte` file.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import type { Icon } from '@lucide/svelte';
|
|
8
|
+
import { Check, CircleAlert, Info, TriangleAlert, X } from '@lucide/svelte';
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
10
|
+
import { flip } from 'svelte/animate';
|
|
11
|
+
import type { ClassValue } from 'svelte/elements';
|
|
12
|
+
import { fly, scale } from 'svelte/transition';
|
|
13
|
+
import { Toasts, type ToastSettings } from './toasts.svelte';
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
class?: ClassValue;
|
|
17
|
+
children?: Snippet<[toast: ToastSettings & { close: () => void }]>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let { class: clazz = 'px-2 pb-2', children }: Props = $props();
|
|
21
|
+
|
|
22
|
+
function getIcon(
|
|
23
|
+
variant: 'info' | 'success' | 'warning' | 'error',
|
|
24
|
+
icon?: typeof Icon
|
|
25
|
+
): typeof Icon {
|
|
26
|
+
if (icon) return icon;
|
|
27
|
+
switch (variant) {
|
|
28
|
+
case 'info':
|
|
29
|
+
return Info;
|
|
30
|
+
case 'success':
|
|
31
|
+
return Check;
|
|
32
|
+
case 'warning':
|
|
33
|
+
return CircleAlert;
|
|
34
|
+
case 'error':
|
|
35
|
+
return TriangleAlert;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div
|
|
41
|
+
class="pointer-events-none absolute top-0 left-0 z-50 flex h-full w-full flex-col items-center justify-start"
|
|
42
|
+
>
|
|
43
|
+
<div
|
|
44
|
+
class={[
|
|
45
|
+
'flex h-full max-h-full flex-col-reverse items-center justify-end gap-2 overflow-hidden',
|
|
46
|
+
clazz
|
|
47
|
+
]}
|
|
48
|
+
>
|
|
49
|
+
{#each Toasts.toasts as toast (toast.id)}
|
|
50
|
+
{@const VariantIcon = getIcon(toast.variant, toast.icon)}
|
|
51
|
+
|
|
52
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
53
|
+
<div
|
|
54
|
+
in:fly={{ y: '-100%', duration: 300 }}
|
|
55
|
+
out:scale={{ duration: 300 }}
|
|
56
|
+
animate:flip={{ duration: 300 }}
|
|
57
|
+
onpointerenter={() => {
|
|
58
|
+
Toasts.freeze(toast.id);
|
|
59
|
+
}}
|
|
60
|
+
onpointerleave={() => {
|
|
61
|
+
Toasts.unfreeze(toast.id);
|
|
62
|
+
}}
|
|
63
|
+
class="group pointer-events-auto flex h-fit w-fit flex-row items-center"
|
|
64
|
+
>
|
|
65
|
+
{#if children}
|
|
66
|
+
{@render children({
|
|
67
|
+
...toast,
|
|
68
|
+
close: () => Toasts.close(toast.id)
|
|
69
|
+
})}
|
|
70
|
+
{:else}
|
|
71
|
+
<div
|
|
72
|
+
class={[
|
|
73
|
+
'bg-opacity-85 flex flex-row items-center gap-4 rounded px-4 py-2 shadow-lg group-last:rounded-t-none',
|
|
74
|
+
toast.variant === 'info' && 'preset-filled-primary-500',
|
|
75
|
+
toast.variant === 'success' && 'preset-filled-success-500',
|
|
76
|
+
toast.variant === 'warning' && 'preset-filled-warning-500',
|
|
77
|
+
toast.variant === 'error' && 'preset-filled-error-500'
|
|
78
|
+
]}
|
|
79
|
+
>
|
|
80
|
+
<VariantIcon />
|
|
81
|
+
<p class="font-bold">
|
|
82
|
+
{toast.message}
|
|
83
|
+
</p>
|
|
84
|
+
{#if !toast.hideDismiss}
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onclick={() => {
|
|
88
|
+
Toasts.close(toast.id);
|
|
89
|
+
}}
|
|
90
|
+
class="transition-transform hover:rotate-90"
|
|
91
|
+
>
|
|
92
|
+
<X />
|
|
93
|
+
</button>
|
|
94
|
+
{/if}
|
|
95
|
+
</div>
|
|
96
|
+
{/if}
|
|
97
|
+
</div>
|
|
98
|
+
{/each}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Icon } from '@lucide/svelte';
|
|
2
|
+
|
|
3
|
+
export type ToastSettings = {
|
|
4
|
+
message: string;
|
|
5
|
+
autohide?: boolean;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
hideDismiss?: boolean;
|
|
8
|
+
icon?: typeof Icon;
|
|
9
|
+
variant: 'info' | 'success' | 'warning' | 'error';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Toast = ToastSettings & {
|
|
13
|
+
id: string;
|
|
14
|
+
timeoutId?: ReturnType<typeof setTimeout>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const TOAST_DEFAULTS: ToastSettings = {
|
|
18
|
+
message: 'Missing Toast Message',
|
|
19
|
+
autohide: true,
|
|
20
|
+
timeout: 3000,
|
|
21
|
+
variant: 'info'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
class ToastStore {
|
|
25
|
+
toasts = $state<Toast[]>([]);
|
|
26
|
+
|
|
27
|
+
trigger(toast: ToastSettings, id = crypto.randomUUID() as string) {
|
|
28
|
+
const mergedToasts: Toast = { ...TOAST_DEFAULTS, ...toast, id };
|
|
29
|
+
|
|
30
|
+
// start the autohide timeout
|
|
31
|
+
this.startAutoHide(mergedToasts);
|
|
32
|
+
|
|
33
|
+
// add the toasts to the list
|
|
34
|
+
this.toasts.push(mergedToasts);
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
close(id: string) {
|
|
39
|
+
const toast = this.toasts.find((t) => t.id === id);
|
|
40
|
+
if (!toast) return;
|
|
41
|
+
this.stopAutoHide(toast);
|
|
42
|
+
this.toasts = this.toasts.filter((t) => t.id !== id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
update(id: string, toast: ToastSettings) {
|
|
46
|
+
const existingToast = this.toasts.find((t) => t.id === id);
|
|
47
|
+
if (!existingToast) {
|
|
48
|
+
this.trigger(toast, id);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const mergedToast: Toast = {
|
|
52
|
+
...TOAST_DEFAULTS,
|
|
53
|
+
...existingToast,
|
|
54
|
+
...toast
|
|
55
|
+
};
|
|
56
|
+
// clear the existing timeout and restart the autohide
|
|
57
|
+
this.stopAutoHide(existingToast);
|
|
58
|
+
this.startAutoHide(mergedToast);
|
|
59
|
+
this.toasts.splice(this.toasts.indexOf(existingToast), 1, mergedToast);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
freeze(id: string) {
|
|
63
|
+
const toast = this.toasts.find((t) => t.id === id);
|
|
64
|
+
if (!toast) return;
|
|
65
|
+
this.stopAutoHide(toast);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
unfreeze(id: string) {
|
|
69
|
+
const toast = this.toasts.find((t) => t.id === id);
|
|
70
|
+
if (!toast) return;
|
|
71
|
+
this.startAutoHide(toast);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private startAutoHide(toast: Toast) {
|
|
75
|
+
if (!toast.autohide) return;
|
|
76
|
+
toast.timeoutId = setTimeout(() => {
|
|
77
|
+
this.close(toast.id);
|
|
78
|
+
}, toast.timeout);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private stopAutoHide(toast: Toast) {
|
|
82
|
+
if (!toast.timeoutId) return;
|
|
83
|
+
clearTimeout(toast.timeoutId);
|
|
84
|
+
delete toast.timeoutId;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Exposes functions to manage toasts, contains currently active toasts */
|
|
89
|
+
export const Toasts = new ToastStore();
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Reexport your entry components here
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { fn } from '@vitest/spy';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { clickOutside } from './clickOutside';
|
|
6
|
+
|
|
7
|
+
describe('clickOutside', () => {
|
|
8
|
+
it('should fire when an element outside the node is clicked', async () => {
|
|
9
|
+
const node = document.createElement('div');
|
|
10
|
+
document.body.appendChild(node);
|
|
11
|
+
const otherNode = document.createElement('div');
|
|
12
|
+
document.body.appendChild(otherNode);
|
|
13
|
+
|
|
14
|
+
const callback = fn();
|
|
15
|
+
clickOutside(node, callback);
|
|
16
|
+
|
|
17
|
+
await userEvent.click(otherNode);
|
|
18
|
+
|
|
19
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should not fire when the target is inside the node', async () => {
|
|
23
|
+
const node = document.createElement('div');
|
|
24
|
+
document.body.appendChild(node);
|
|
25
|
+
const nestedNode = node.appendChild(document.createElement('div'));
|
|
26
|
+
const otherNode = document.createElement('div');
|
|
27
|
+
document.body.appendChild(otherNode);
|
|
28
|
+
|
|
29
|
+
const callback = fn();
|
|
30
|
+
clickOutside(node, callback);
|
|
31
|
+
|
|
32
|
+
await userEvent.click(nestedNode);
|
|
33
|
+
|
|
34
|
+
expect(callback).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should fire when the callback is passed in as an object', async () => {
|
|
38
|
+
const node = document.createElement('div');
|
|
39
|
+
document.body.appendChild(node);
|
|
40
|
+
const otherNode = document.createElement('div');
|
|
41
|
+
document.body.appendChild(otherNode);
|
|
42
|
+
|
|
43
|
+
const callback = fn();
|
|
44
|
+
clickOutside(node, { callback });
|
|
45
|
+
|
|
46
|
+
await userEvent.click(otherNode);
|
|
47
|
+
|
|
48
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should not fire when the target is inside the passed target', async () => {
|
|
52
|
+
const node = document.createElement('div');
|
|
53
|
+
document.body.appendChild(node);
|
|
54
|
+
|
|
55
|
+
const otherNode = document.createElement('div');
|
|
56
|
+
document.body.appendChild(otherNode);
|
|
57
|
+
const nestedNode = otherNode.appendChild(document.createElement('div'));
|
|
58
|
+
|
|
59
|
+
const callback = fn();
|
|
60
|
+
clickOutside(node, { callback, target: otherNode });
|
|
61
|
+
|
|
62
|
+
await userEvent.click(nestedNode);
|
|
63
|
+
await userEvent.click(otherNode);
|
|
64
|
+
|
|
65
|
+
expect(callback).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
interface ClickOutsideParams {
|
|
2
|
+
/** Callback to be called when clicking outside of node */
|
|
3
|
+
callback: (e: MouseEvent) => void;
|
|
4
|
+
/** Callback is also not fired if the click target is inside this element */
|
|
5
|
+
target?: Element;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Dispatch event on click outside of node */
|
|
9
|
+
export function clickOutside(
|
|
10
|
+
node: Element,
|
|
11
|
+
params: ((e: MouseEvent) => void) | ClickOutsideParams
|
|
12
|
+
) {
|
|
13
|
+
function handleClick(event: MouseEvent) {
|
|
14
|
+
if (
|
|
15
|
+
!(event.target instanceof Node) ||
|
|
16
|
+
!node ||
|
|
17
|
+
node.contains(event.target) ||
|
|
18
|
+
event.defaultPrevented
|
|
19
|
+
)
|
|
20
|
+
return;
|
|
21
|
+
|
|
22
|
+
if (typeof params === 'function') {
|
|
23
|
+
params(event);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (params.target?.contains(event.target)) return;
|
|
28
|
+
params.callback(event);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
document.addEventListener('click', handleClick, true);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
destroy() {
|
|
35
|
+
document.removeEventListener('click', handleClick, true);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function focusTrap(node: HTMLElement, enabled: boolean) {
|
|
2
|
+
const elementWhitelist =
|
|
3
|
+
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])';
|
|
4
|
+
let firstElement: HTMLElement;
|
|
5
|
+
let lastElement: HTMLElement;
|
|
6
|
+
|
|
7
|
+
function onFirstElemKeydown(e: KeyboardEvent): void {
|
|
8
|
+
if (e.shiftKey && e.code === 'Tab') {
|
|
9
|
+
e.preventDefault();
|
|
10
|
+
lastElement.focus();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function onLastElemKeydown(e: KeyboardEvent): void {
|
|
15
|
+
if (!e.shiftKey && e.code === 'Tab') {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
firstElement.focus();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function scanElements(fromObserver: boolean) {
|
|
22
|
+
if (enabled === false) return;
|
|
23
|
+
const focusableElems: HTMLElement[] = Array.from(node.querySelectorAll(elementWhitelist));
|
|
24
|
+
if (focusableElems.length) {
|
|
25
|
+
// Set first/last focusable elements
|
|
26
|
+
firstElement = focusableElems[0];
|
|
27
|
+
lastElement = focusableElems[focusableElems.length - 1];
|
|
28
|
+
// Auto-focus first focusable element only when not called from mutation observer
|
|
29
|
+
if (!fromObserver) firstElement.focus();
|
|
30
|
+
// Listen for keydown on first & last element
|
|
31
|
+
firstElement.addEventListener('keydown', onFirstElemKeydown);
|
|
32
|
+
lastElement.addEventListener('keydown', onLastElemKeydown);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
scanElements(false);
|
|
36
|
+
|
|
37
|
+
function cleanUp(): void {
|
|
38
|
+
if (firstElement) firstElement.removeEventListener('keydown', onFirstElemKeydown);
|
|
39
|
+
if (lastElement) lastElement.removeEventListener('keydown', onLastElemKeydown);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Re-determine focusable elements when mutations are observed
|
|
43
|
+
const onObservationChange = (mutationRecords: MutationRecord[], observer: MutationObserver) => {
|
|
44
|
+
if (mutationRecords.length) {
|
|
45
|
+
cleanUp();
|
|
46
|
+
scanElements(true);
|
|
47
|
+
}
|
|
48
|
+
return observer;
|
|
49
|
+
};
|
|
50
|
+
const observer = new MutationObserver(onObservationChange);
|
|
51
|
+
observer.observe(node, { childList: true, subtree: true });
|
|
52
|
+
|
|
53
|
+
// Lifecycle
|
|
54
|
+
return {
|
|
55
|
+
update(newArgs: boolean) {
|
|
56
|
+
enabled = newArgs;
|
|
57
|
+
if (newArgs) scanElements(false);
|
|
58
|
+
else cleanUp();
|
|
59
|
+
},
|
|
60
|
+
destroy() {
|
|
61
|
+
cleanUp();
|
|
62
|
+
observer.disconnect();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { tick } from 'svelte';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Places the element in a diffrent place in the DOM
|
|
5
|
+
*
|
|
6
|
+
* **Use sparingy as it can make the DOM structure confusing**
|
|
7
|
+
*/
|
|
8
|
+
export function portal(el: HTMLElement, target: HTMLElement | string = 'body') {
|
|
9
|
+
let targetEl;
|
|
10
|
+
async function update(newTarget: HTMLElement | string) {
|
|
11
|
+
target = newTarget;
|
|
12
|
+
if (typeof target === 'string') {
|
|
13
|
+
targetEl = document.querySelector(target);
|
|
14
|
+
if (targetEl === null) {
|
|
15
|
+
await tick();
|
|
16
|
+
targetEl = document.querySelector(target);
|
|
17
|
+
}
|
|
18
|
+
if (targetEl === null) {
|
|
19
|
+
throw new Error(`No element found matching css selector: "${target}"`);
|
|
20
|
+
}
|
|
21
|
+
} else if (target instanceof HTMLElement) {
|
|
22
|
+
targetEl = target;
|
|
23
|
+
} else {
|
|
24
|
+
throw new TypeError(
|
|
25
|
+
`Unknown portal target type: ${
|
|
26
|
+
target === null ? 'null' : typeof target
|
|
27
|
+
}. Allowed types: string (CSS selector) or HTMLElement.`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
targetEl.appendChild(el);
|
|
31
|
+
el.hidden = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function destroy() {
|
|
35
|
+
if (el.parentNode) el.parentNode.removeChild(el);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
update(target);
|
|
39
|
+
return {
|
|
40
|
+
update,
|
|
41
|
+
destroy
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { fn } from '@vitest/spy';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { shortcut } from './shortcut';
|
|
6
|
+
|
|
7
|
+
describe('shortcut', () => {
|
|
8
|
+
it('should call the callback when the key is pressed', async () => {
|
|
9
|
+
const node = document.createElement('div');
|
|
10
|
+
const keyCode = 'Enter';
|
|
11
|
+
const callback = fn();
|
|
12
|
+
shortcut(node, {
|
|
13
|
+
callback,
|
|
14
|
+
code: keyCode
|
|
15
|
+
});
|
|
16
|
+
await userEvent.keyboard(`{${keyCode}}`);
|
|
17
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const shortcut = (
|
|
2
|
+
node: HTMLElement,
|
|
3
|
+
params?: {
|
|
4
|
+
alt?: boolean;
|
|
5
|
+
shift?: boolean;
|
|
6
|
+
control?: boolean;
|
|
7
|
+
code: string;
|
|
8
|
+
callback: () => void;
|
|
9
|
+
}
|
|
10
|
+
) => {
|
|
11
|
+
if (!params) return;
|
|
12
|
+
let handler: (e: KeyboardEvent) => void;
|
|
13
|
+
const removeHandler = () => window.removeEventListener('keydown', handler);
|
|
14
|
+
const setHandler = () => {
|
|
15
|
+
removeHandler();
|
|
16
|
+
handler = (e) => {
|
|
17
|
+
if (
|
|
18
|
+
!!params.alt != e.altKey ||
|
|
19
|
+
!!params.shift != e.shiftKey ||
|
|
20
|
+
!!params.control != (e.ctrlKey || e.metaKey) ||
|
|
21
|
+
params.code != e.code
|
|
22
|
+
)
|
|
23
|
+
return;
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
e.stopPropagation();
|
|
26
|
+
params.callback();
|
|
27
|
+
};
|
|
28
|
+
window.addEventListener('keydown', handler);
|
|
29
|
+
};
|
|
30
|
+
setHandler();
|
|
31
|
+
return {
|
|
32
|
+
update: setHandler,
|
|
33
|
+
destroy: removeHandler
|
|
34
|
+
};
|
|
35
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Action } from 'svelte/action';
|
|
2
|
+
|
|
3
|
+
export const onFirstVisible: Action<
|
|
4
|
+
HTMLElement,
|
|
5
|
+
{
|
|
6
|
+
callback: () => void;
|
|
7
|
+
options?: Partial<IntersectionObserverInit>;
|
|
8
|
+
}
|
|
9
|
+
> = (node, input) => {
|
|
10
|
+
const observer = new IntersectionObserver(
|
|
11
|
+
(e) => {
|
|
12
|
+
if (!e[0].isIntersecting) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
input.callback();
|
|
17
|
+
},
|
|
18
|
+
{ root: null, threshold: 1, ...input.options }
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
observer.observe(node);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
destroy: () => {
|
|
25
|
+
observer.disconnect();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { cookie } from './cookie';
|
|
4
|
+
|
|
5
|
+
describe('cookie', () => {
|
|
6
|
+
it('sets correctly', () => {
|
|
7
|
+
const name = 'test';
|
|
8
|
+
const value = 'test';
|
|
9
|
+
const days = 1;
|
|
10
|
+
// this is a hack to get the value back that is actually being set
|
|
11
|
+
const setCookie = cookie.set({ name, value, days });
|
|
12
|
+
expect(setCookie).toBe(
|
|
13
|
+
`${name}=${value}; expires=${new Date(new Date().getTime() + days * 24 * 60 * 60 * 1000).toUTCString()}; path=/`
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('gets correctly', () => {
|
|
18
|
+
const name = 'test';
|
|
19
|
+
const value = 'test';
|
|
20
|
+
const days = 1;
|
|
21
|
+
cookie.set({ name, value, days });
|
|
22
|
+
expect(cookie.get(name)).toBe(value);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("preserves existing cookie's path", () => {
|
|
26
|
+
const cookie1 = {
|
|
27
|
+
name: 'test',
|
|
28
|
+
value: 'test'
|
|
29
|
+
};
|
|
30
|
+
const cookie2 = {
|
|
31
|
+
name: 'test2',
|
|
32
|
+
value: 'test2'
|
|
33
|
+
};
|
|
34
|
+
cookie.set(cookie1);
|
|
35
|
+
cookie.set(cookie2);
|
|
36
|
+
expect(cookie.get(cookie1.name)).toBe(cookie1.value);
|
|
37
|
+
expect(cookie.get(cookie2.name)).toBe(cookie2.value);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('updates cookies correctly', () => {
|
|
41
|
+
const cookie1 = {
|
|
42
|
+
name: 'test',
|
|
43
|
+
value: 'test'
|
|
44
|
+
};
|
|
45
|
+
const cookie2 = {
|
|
46
|
+
name: 'test2',
|
|
47
|
+
value: 'test2'
|
|
48
|
+
};
|
|
49
|
+
cookie.set(cookie1);
|
|
50
|
+
cookie.set(cookie2);
|
|
51
|
+
cookie.set({ name: cookie1.name, value: 'newValue' });
|
|
52
|
+
expect(cookie.get(cookie1.name)).toBe('newValue');
|
|
53
|
+
expect(cookie.get(cookie2.name)).toBe(cookie2.value);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function getCookie(name: string) {
|
|
2
|
+
const cname = name + '=';
|
|
3
|
+
let decodedCookie: string;
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
decodedCookie = decodeURIComponent(document.cookie);
|
|
7
|
+
} catch (error) {
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ca = decodedCookie.split(';');
|
|
12
|
+
|
|
13
|
+
for (let c of ca) {
|
|
14
|
+
while (c.startsWith(' ')) {
|
|
15
|
+
c = c.substring(1);
|
|
16
|
+
}
|
|
17
|
+
if (c.startsWith(cname)) {
|
|
18
|
+
return c.substring(cname.length);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CookieSetParams {
|
|
25
|
+
name: string;
|
|
26
|
+
value: string;
|
|
27
|
+
days?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function setCookie(params: CookieSetParams): string {
|
|
31
|
+
const { name, value, days } = params;
|
|
32
|
+
let expires = '';
|
|
33
|
+
if (days) {
|
|
34
|
+
const date = new Date();
|
|
35
|
+
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
36
|
+
expires = '; expires=' + date.toUTCString();
|
|
37
|
+
}
|
|
38
|
+
const cookie = name + '=' + (value || '') + expires + '; path=/';
|
|
39
|
+
document.cookie = cookie;
|
|
40
|
+
return cookie;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const cookie = {
|
|
44
|
+
get: getCookie,
|
|
45
|
+
set: setCookie
|
|
46
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { pseudoRandomId } from './pseudoRandomId';
|
|
3
|
+
|
|
4
|
+
describe('pseudoRandomId', () => {
|
|
5
|
+
it('generates different values', () => {
|
|
6
|
+
const a = pseudoRandomId();
|
|
7
|
+
const b = pseudoRandomId();
|
|
8
|
+
expect(a).not.toBe(b);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('generates values that start with the prefix', () => {
|
|
12
|
+
const prefix = 'test-';
|
|
13
|
+
const a = pseudoRandomId(prefix);
|
|
14
|
+
const b = pseudoRandomId(prefix);
|
|
15
|
+
expect(a.startsWith(prefix)).toBe(true);
|
|
16
|
+
expect(b.startsWith(prefix)).toBe(true);
|
|
17
|
+
expect(a).not.toBe(b);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { queryParams } from './queryParams';
|
|
3
|
+
|
|
4
|
+
describe('queryParams', () => {
|
|
5
|
+
it('adds all query params', () => {
|
|
6
|
+
const params = {
|
|
7
|
+
a: 0,
|
|
8
|
+
b: '2',
|
|
9
|
+
c: 3
|
|
10
|
+
};
|
|
11
|
+
const query = queryParams(params);
|
|
12
|
+
expect(query).toBe('?a=0&b=2&c=3');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('removes nullish values', () => {
|
|
16
|
+
const params = {
|
|
17
|
+
a: 0,
|
|
18
|
+
b: undefined,
|
|
19
|
+
c: 3,
|
|
20
|
+
d: null
|
|
21
|
+
};
|
|
22
|
+
const query = queryParams(params);
|
|
23
|
+
expect(query).toBe('?a=0&c=3');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const queryParams = <T extends Record<string, string | number | undefined | null>>(
|
|
2
|
+
params: T
|
|
3
|
+
): string => {
|
|
4
|
+
// create the string
|
|
5
|
+
let query = '?';
|
|
6
|
+
let isFirst = true;
|
|
7
|
+
for (const [key, value] of Object.entries(params)) {
|
|
8
|
+
if (value === undefined || value === null) continue;
|
|
9
|
+
if (!isFirst) query += '&';
|
|
10
|
+
query += `${key}=${value}`;
|
|
11
|
+
isFirst = false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return isFirst ? '' : query;
|
|
15
|
+
};
|