@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.
Files changed (128) hide show
  1. package/LICENCE +23 -0
  2. package/README.md +15 -0
  3. package/dist/components/ai/AiMessage.svelte +115 -0
  4. package/dist/components/ai/AiMessage.svelte.d.ts +18 -0
  5. package/dist/components/ai/AttachedFile.svelte +28 -0
  6. package/dist/components/ai/AttachedFile.svelte.d.ts +9 -0
  7. package/dist/components/ai/Chat.svelte +150 -0
  8. package/dist/components/ai/Chat.svelte.d.ts +40 -0
  9. package/dist/components/ai/Markdown.svelte +59 -0
  10. package/dist/components/ai/Markdown.svelte.d.ts +16 -0
  11. package/dist/components/ai/UserMessage.svelte +53 -0
  12. package/dist/components/ai/UserMessage.svelte.d.ts +16 -0
  13. package/dist/components/ai/index.d.ts +5 -0
  14. package/dist/components/ai/index.js +5 -0
  15. package/dist/components/basic/checkbox/Checkbox.svelte +79 -0
  16. package/dist/components/basic/checkbox/Checkbox.svelte.d.ts +18 -0
  17. package/dist/components/basic/index.d.ts +2 -0
  18. package/dist/components/basic/index.js +2 -0
  19. package/dist/components/basic/toggle/Toggle.svelte +47 -0
  20. package/dist/components/basic/toggle/Toggle.svelte.d.ts +12 -0
  21. package/dist/components/buttons/CopyToClipboardButton.svelte +33 -0
  22. package/dist/components/buttons/CopyToClipboardButton.svelte.d.ts +9 -0
  23. package/dist/components/index.d.ts +0 -0
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/layout/heading/Heading.svelte +33 -0
  26. package/dist/components/layout/heading/Heading.svelte.d.ts +16 -0
  27. package/dist/components/layout/heading/index.d.ts +5 -0
  28. package/dist/components/layout/heading/index.js +5 -0
  29. package/dist/components/layout/hiddenBackground/HiddenBackground.svelte +48 -0
  30. package/dist/components/layout/hiddenBackground/HiddenBackground.svelte.d.ts +13 -0
  31. package/dist/components/layout/hiddenBackground/index.js +6 -0
  32. package/dist/components/layout/index.d.ts +7 -0
  33. package/dist/components/layout/index.js +7 -0
  34. package/dist/components/layout/modal/Modal.svelte +114 -0
  35. package/dist/components/layout/modal/Modal.svelte.d.ts +30 -0
  36. package/dist/components/layout/modal/ModalTest.svelte +16 -0
  37. package/dist/components/layout/modal/ModalTest.svelte.d.ts +9 -0
  38. package/dist/components/layout/popover/Popover.svelte +108 -0
  39. package/dist/components/layout/popover/Popover.svelte.d.ts +36 -0
  40. package/dist/components/layout/portal/Portal.svelte +23 -0
  41. package/dist/components/layout/portal/Portal.svelte.d.ts +15 -0
  42. package/dist/components/layout/tabs/Tab.svelte +80 -0
  43. package/dist/components/layout/tabs/Tab.svelte.d.ts +21 -0
  44. package/dist/components/layout/tabs/TabPanel.svelte +23 -0
  45. package/dist/components/layout/tabs/TabPanel.svelte.d.ts +10 -0
  46. package/dist/components/layout/tabs/Tabs.svelte +86 -0
  47. package/dist/components/layout/tabs/Tabs.svelte.d.ts +21 -0
  48. package/dist/components/layout/tabs/index.d.ts +26 -0
  49. package/dist/components/layout/tabs/index.js +8 -0
  50. package/dist/components/layout/tooltip/Tooltip.svelte +111 -0
  51. package/dist/components/layout/tooltip/Tooltip.svelte.d.ts +32 -0
  52. package/dist/components/toast/Toast.svelte +100 -0
  53. package/dist/components/toast/Toast.svelte.d.ts +16 -0
  54. package/dist/components/toast/index.d.ts +2 -0
  55. package/dist/components/toast/index.js +2 -0
  56. package/dist/components/toast/toasts.svelte.d.ts +26 -0
  57. package/dist/components/toast/toasts.svelte.js +67 -0
  58. package/dist/index.d.ts +0 -0
  59. package/dist/index.js +2 -0
  60. package/dist/utils/actions/clickOutside.d.ts +11 -0
  61. package/dist/utils/actions/clickOutside.js +23 -0
  62. package/dist/utils/actions/focusTrap.d.ts +4 -0
  63. package/dist/utils/actions/focusTrap.js +64 -0
  64. package/dist/utils/actions/index.d.ts +5 -0
  65. package/dist/utils/actions/index.js +5 -0
  66. package/dist/utils/actions/portal.d.ts +9 -0
  67. package/dist/utils/actions/portal.js +39 -0
  68. package/dist/utils/actions/shortcut.d.ts +10 -0
  69. package/dist/utils/actions/shortcut.js +25 -0
  70. package/dist/utils/actions/visible.d.ts +5 -0
  71. package/dist/utils/actions/visible.js +14 -0
  72. package/dist/utils/functions/cookie.d.ts +12 -0
  73. package/dist/utils/functions/cookie.js +36 -0
  74. package/dist/utils/functions/index.d.ts +3 -0
  75. package/dist/utils/functions/index.js +3 -0
  76. package/dist/utils/functions/pseudoRandomId.d.ts +1 -0
  77. package/dist/utils/functions/pseudoRandomId.js +3 -0
  78. package/dist/utils/functions/queryParams.d.ts +1 -0
  79. package/dist/utils/functions/queryParams.js +14 -0
  80. package/package.json +107 -0
  81. package/src/lib/components/ai/AiMessage.svelte +115 -0
  82. package/src/lib/components/ai/AttachedFile.svelte +28 -0
  83. package/src/lib/components/ai/Chat.svelte +150 -0
  84. package/src/lib/components/ai/Markdown.svelte +59 -0
  85. package/src/lib/components/ai/UserMessage.svelte +53 -0
  86. package/src/lib/components/ai/index.ts +5 -0
  87. package/src/lib/components/basic/checkbox/Checkbox.svelte +79 -0
  88. package/src/lib/components/basic/checkbox/checkbox.svelte.spec.ts +39 -0
  89. package/src/lib/components/basic/index.ts +2 -0
  90. package/src/lib/components/basic/toggle/Toggle.svelte +47 -0
  91. package/src/lib/components/basic/toggle/toggle.svelte.spec.ts +19 -0
  92. package/src/lib/components/buttons/CopyToClipboardButton.svelte +33 -0
  93. package/src/lib/components/index.ts +0 -0
  94. package/src/lib/components/layout/heading/Heading.svelte +33 -0
  95. package/src/lib/components/layout/heading/index.ts +7 -0
  96. package/src/lib/components/layout/hiddenBackground/HiddenBackground.svelte +48 -0
  97. package/src/lib/components/layout/hiddenBackground/index.ts +8 -0
  98. package/src/lib/components/layout/index.ts +7 -0
  99. package/src/lib/components/layout/modal/Modal.svelte +114 -0
  100. package/src/lib/components/layout/modal/ModalTest.svelte +16 -0
  101. package/src/lib/components/layout/modal/modal.svelte.spec.ts +39 -0
  102. package/src/lib/components/layout/popover/Popover.svelte +108 -0
  103. package/src/lib/components/layout/portal/Portal.svelte +23 -0
  104. package/src/lib/components/layout/tabs/Tab.svelte +80 -0
  105. package/src/lib/components/layout/tabs/TabPanel.svelte +23 -0
  106. package/src/lib/components/layout/tabs/Tabs.svelte +86 -0
  107. package/src/lib/components/layout/tabs/Tabs.test.svelte +5 -0
  108. package/src/lib/components/layout/tabs/index.ts +10 -0
  109. package/src/lib/components/layout/tooltip/Tooltip.svelte +111 -0
  110. package/src/lib/components/toast/Toast.svelte +100 -0
  111. package/src/lib/components/toast/index.ts +2 -0
  112. package/src/lib/components/toast/toasts.svelte.ts +89 -0
  113. package/src/lib/index.ts +1 -0
  114. package/src/lib/utils/actions/clickOutside.svelte.spec.ts +67 -0
  115. package/src/lib/utils/actions/clickOutside.ts +38 -0
  116. package/src/lib/utils/actions/focusTrap.ts +65 -0
  117. package/src/lib/utils/actions/index.ts +5 -0
  118. package/src/lib/utils/actions/portal.ts +43 -0
  119. package/src/lib/utils/actions/shortcut.svelte.spec.ts +19 -0
  120. package/src/lib/utils/actions/shortcut.ts +35 -0
  121. package/src/lib/utils/actions/visible.ts +28 -0
  122. package/src/lib/utils/functions/cookie.svelte.spec.ts +55 -0
  123. package/src/lib/utils/functions/cookie.ts +46 -0
  124. package/src/lib/utils/functions/index.ts +3 -0
  125. package/src/lib/utils/functions/pseudoRandomId.spec.ts +19 -0
  126. package/src/lib/utils/functions/pseudoRandomId.ts +4 -0
  127. package/src/lib/utils/functions/queryParams.spec.ts +25 -0
  128. 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,2 @@
1
+ export { default as ToastUI } from './Toast.svelte';
2
+ export { Toasts } from './toasts.svelte';
@@ -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();
@@ -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,5 @@
1
+ export { clickOutside } from './clickOutside';
2
+ export { focusTrap } from './focusTrap';
3
+ export { portal } from './portal';
4
+ export { shortcut } from './shortcut';
5
+ export { onFirstVisible } from './visible';
@@ -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,3 @@
1
+ export { cookie } from './cookie';
2
+ export { pseudoRandomId } from './pseudoRandomId';
3
+ export { queryParams } from './queryParams';
@@ -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,4 @@
1
+ export const pseudoRandomId = (prefix: string = '') =>
2
+ Math.random()
3
+ .toString(36)
4
+ .replace('0.', prefix || '');
@@ -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
+ };