sv5ui 1.4.0 → 1.5.1

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 (43) hide show
  1. package/README.md +60 -183
  2. package/dist/Checkbox/Checkbox.svelte +2 -11
  3. package/dist/CheckboxGroup/CheckboxGroup.svelte +2 -11
  4. package/dist/FormField/FormField.svelte +2 -6
  5. package/dist/Input/Input.svelte +2 -10
  6. package/dist/PinInput/PinInput.svelte +2 -11
  7. package/dist/RadioGroup/RadioGroup.svelte +2 -11
  8. package/dist/Select/Select.svelte +2 -10
  9. package/dist/SelectMenu/SelectMenu.svelte +2 -10
  10. package/dist/Slider/Slider.svelte +2 -11
  11. package/dist/Switch/Switch.svelte +2 -11
  12. package/dist/Table/Table.svelte +763 -0
  13. package/dist/Table/Table.svelte.d.ts +26 -0
  14. package/dist/Table/index.d.ts +2 -0
  15. package/dist/Table/index.js +1 -0
  16. package/dist/Table/table.types.d.ts +202 -0
  17. package/dist/Table/table.types.js +1 -0
  18. package/dist/Table/table.utils.d.ts +51 -0
  19. package/dist/Table/table.utils.js +166 -0
  20. package/dist/Table/table.variants.d.ts +205 -0
  21. package/dist/Table/table.variants.js +126 -0
  22. package/dist/Textarea/Textarea.svelte +2 -10
  23. package/dist/config.d.ts +3 -0
  24. package/dist/config.js +4 -1
  25. package/dist/hooks/index.d.ts +14 -0
  26. package/dist/hooks/index.js +7 -0
  27. package/dist/hooks/useClickOutside.svelte.d.ts +31 -0
  28. package/dist/hooks/useClickOutside.svelte.js +37 -0
  29. package/dist/hooks/useClipboard.svelte.d.ts +30 -0
  30. package/dist/hooks/useClipboard.svelte.js +45 -0
  31. package/dist/hooks/useDebounce.svelte.d.ts +36 -0
  32. package/dist/hooks/useDebounce.svelte.js +56 -0
  33. package/dist/hooks/useEscapeKeydown.svelte.d.ts +31 -0
  34. package/dist/hooks/useEscapeKeydown.svelte.js +37 -0
  35. package/dist/hooks/useFormField.svelte.d.ts +21 -0
  36. package/dist/hooks/useFormField.svelte.js +17 -0
  37. package/dist/hooks/useInfiniteScroll.svelte.d.ts +57 -0
  38. package/dist/hooks/useInfiniteScroll.svelte.js +69 -0
  39. package/dist/hooks/useMediaQuery.svelte.d.ts +31 -0
  40. package/dist/hooks/useMediaQuery.svelte.js +38 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +3 -0
  43. package/package.json +1 -1
@@ -0,0 +1,126 @@
1
+ import { tv } from 'tailwind-variants';
2
+ export const tableVariants = tv({
3
+ slots: {
4
+ root: 'relative w-full overflow-x-auto scrollbar-thin rounded-xl border border-outline-variant/50 bg-surface [contain:inline-size]',
5
+ base: 'min-w-full',
6
+ caption: 'sr-only',
7
+ thead: 'relative bg-surface-container-low',
8
+ tbody: [
9
+ '[&>tr]:transition-colors [&>tr]:duration-150',
10
+ '[&>tr]:border-b [&>tr]:border-outline-variant/30',
11
+ '[&>tr:last-child]:border-b-0'
12
+ ],
13
+ tfoot: 'relative bg-surface-container-low border-t border-outline-variant/50',
14
+ tr: 'data-[selected=true]:bg-primary-container/20 data-[pinned-row=true]:bg-surface-container-lowest',
15
+ th: [
16
+ 'relative px-4 py-3 text-xs font-semibold uppercase tracking-wider',
17
+ 'text-on-surface-variant text-left rtl:text-right',
18
+ '[&:has([role=checkbox])]:pe-0 [&:has([role=checkbox])]:w-12'
19
+ ],
20
+ td: [
21
+ 'px-4 py-3.5 text-sm text-on-surface',
22
+ 'whitespace-nowrap max-sm:whitespace-normal max-sm:break-words',
23
+ '[&:has([role=checkbox])]:pe-0 [&:has([role=checkbox])]:w-12'
24
+ ],
25
+ separator: 'h-px bg-outline-variant/50',
26
+ empty: 'py-10 text-center text-sm text-on-surface-variant/70',
27
+ loading: 'py-10 text-center'
28
+ },
29
+ variants: {
30
+ pinned: {
31
+ true: {
32
+ th: 'sticky bg-surface-container-low/95 backdrop-blur-sm z-1',
33
+ td: 'sticky bg-surface/95 backdrop-blur-sm z-1'
34
+ }
35
+ },
36
+ hoverable: {
37
+ true: {
38
+ tbody: '[&>tr]:data-[selectable=true]:cursor-pointer [&>tr]:data-[selectable=true]:hover:bg-surface-container/60 [&>tr]:data-[selectable=true]:focus-visible:outline-2 [&>tr]:data-[selectable=true]:focus-visible:outline-primary [&>tr]:data-[selectable=true]:focus-visible:outline-offset-[-2px]'
39
+ }
40
+ },
41
+ sticky: {
42
+ true: {
43
+ thead: 'sticky top-0 inset-x-0 bg-surface-container-low/95 backdrop-blur-sm z-10 rounded-t-xl',
44
+ tfoot: 'sticky bottom-0 inset-x-0 bg-surface-container-low/95 backdrop-blur-sm z-10 rounded-b-xl'
45
+ },
46
+ header: {
47
+ thead: 'sticky top-0 inset-x-0 bg-surface-container-low/95 backdrop-blur-sm z-10 rounded-t-xl'
48
+ },
49
+ footer: {
50
+ tfoot: 'sticky bottom-0 inset-x-0 bg-surface-container-low/95 backdrop-blur-sm z-10 rounded-b-xl'
51
+ }
52
+ },
53
+ striped: {
54
+ true: {
55
+ tbody: '[&>tr:nth-child(even)]:bg-surface-container-lowest/60'
56
+ }
57
+ },
58
+ loading: {
59
+ true: {
60
+ thead: 'after:absolute after:top-0 after:left-0 after:z-20 after:h-0.5 after:rounded-full'
61
+ }
62
+ },
63
+ loadingColor: {
64
+ primary: '',
65
+ secondary: '',
66
+ tertiary: '',
67
+ success: '',
68
+ warning: '',
69
+ error: '',
70
+ info: '',
71
+ surface: ''
72
+ },
73
+ loadingAnimation: {
74
+ carousel: '',
75
+ 'carousel-inverse': '',
76
+ swing: '',
77
+ elastic: ''
78
+ }
79
+ },
80
+ compoundVariants: [
81
+ // ========== LOADING COLOR ==========
82
+ { loading: true, loadingColor: 'primary', class: { thead: 'after:bg-primary' } },
83
+ { loading: true, loadingColor: 'secondary', class: { thead: 'after:bg-secondary' } },
84
+ { loading: true, loadingColor: 'tertiary', class: { thead: 'after:bg-tertiary' } },
85
+ { loading: true, loadingColor: 'success', class: { thead: 'after:bg-success' } },
86
+ { loading: true, loadingColor: 'warning', class: { thead: 'after:bg-warning' } },
87
+ { loading: true, loadingColor: 'error', class: { thead: 'after:bg-error' } },
88
+ { loading: true, loadingColor: 'info', class: { thead: 'after:bg-info' } },
89
+ { loading: true, loadingColor: 'surface', class: { thead: 'after:bg-on-surface' } },
90
+ // ========== LOADING ANIMATION ==========
91
+ {
92
+ loading: true,
93
+ loadingAnimation: 'carousel',
94
+ class: { thead: 'after:animate-[carousel_2s_ease-in-out_infinite]' }
95
+ },
96
+ {
97
+ loading: true,
98
+ loadingAnimation: 'carousel-inverse',
99
+ class: { thead: 'after:animate-[carousel-inverse_2s_ease-in-out_infinite]' }
100
+ },
101
+ {
102
+ loading: true,
103
+ loadingAnimation: 'swing',
104
+ class: { thead: 'after:animate-[swing_2s_ease-in-out_infinite]' }
105
+ },
106
+ {
107
+ loading: true,
108
+ loadingAnimation: 'elastic',
109
+ class: { thead: 'after:animate-[elastic_2s_ease-in-out_infinite]' }
110
+ }
111
+ ],
112
+ defaultVariants: {
113
+ hoverable: true,
114
+ loadingColor: 'primary',
115
+ loadingAnimation: 'carousel'
116
+ }
117
+ });
118
+ export const tableDefaults = {
119
+ defaultVariants: {
120
+ ...tableVariants.defaultVariants,
121
+ hoverable: true,
122
+ loadingColor: 'primary',
123
+ loadingAnimation: 'carousel'
124
+ },
125
+ slots: {}
126
+ };
@@ -13,7 +13,7 @@
13
13
  type FieldGroupVariantProps
14
14
  } from '../FieldGroup/field-group.variants.js'
15
15
  import Icon from '../Icon/Icon.svelte'
16
- import type { FormFieldProps } from '../FormField/form-field.types.js'
16
+ import { useFormField } from '../hooks/useFormField.svelte.js'
17
17
 
18
18
  const config = getComponentConfig('textarea', textareaDefaults)
19
19
  const icons = getComponentConfig('icons', iconsDefaults)
@@ -44,15 +44,7 @@
44
44
  ...restProps
45
45
  }: Props = $props()
46
46
 
47
- const formFieldContext = getContext<
48
- | {
49
- name?: string
50
- size: NonNullable<FormFieldProps['size']>
51
- error?: string | boolean
52
- ariaId: string
53
- }
54
- | undefined
55
- >('formField')
47
+ const formFieldContext = useFormField()
56
48
 
57
49
  const fieldGroupContext = getContext<
58
50
  | {
package/dist/config.d.ts CHANGED
@@ -38,6 +38,9 @@ export declare const iconsDefaults: {
38
38
  zoomIn: string;
39
39
  trash: string;
40
40
  search: string;
41
+ sortAsc: string;
42
+ sortDesc: string;
43
+ sortDefault: string;
41
44
  };
42
45
  /** Deep partial type for config objects */
43
46
  type DeepPartial<T> = {
package/dist/config.js CHANGED
@@ -38,7 +38,10 @@ export const iconsDefaults = {
38
38
  file: 'lucide:file',
39
39
  zoomIn: 'lucide:zoom-in',
40
40
  trash: 'lucide:trash-2',
41
- search: 'lucide:search'
41
+ search: 'lucide:search',
42
+ sortAsc: 'lucide:chevron-up',
43
+ sortDesc: 'lucide:chevron-down',
44
+ sortDefault: 'lucide:chevrons-up-down'
42
45
  };
43
46
  // ==================== GLOBAL STATE ====================
44
47
  let globalConfig = {};
@@ -0,0 +1,14 @@
1
+ export { useMediaQuery } from './useMediaQuery.svelte.js';
2
+ export type { UseMediaQueryOptions } from './useMediaQuery.svelte.js';
3
+ export { useClipboard } from './useClipboard.svelte.js';
4
+ export type { UseClipboardOptions } from './useClipboard.svelte.js';
5
+ export { useFormField } from './useFormField.svelte.js';
6
+ export type { FormFieldContext } from './useFormField.svelte.js';
7
+ export { useClickOutside } from './useClickOutside.svelte.js';
8
+ export type { UseClickOutsideOptions } from './useClickOutside.svelte.js';
9
+ export { useInfiniteScroll } from './useInfiniteScroll.svelte.js';
10
+ export type { UseInfiniteScrollOptions } from './useInfiniteScroll.svelte.js';
11
+ export { useEscapeKeydown } from './useEscapeKeydown.svelte.js';
12
+ export type { UseEscapeKeydownOptions } from './useEscapeKeydown.svelte.js';
13
+ export { useDebounce } from './useDebounce.svelte.js';
14
+ export type { UseDebounceOptions } from './useDebounce.svelte.js';
@@ -0,0 +1,7 @@
1
+ export { useMediaQuery } from './useMediaQuery.svelte.js';
2
+ export { useClipboard } from './useClipboard.svelte.js';
3
+ export { useFormField } from './useFormField.svelte.js';
4
+ export { useClickOutside } from './useClickOutside.svelte.js';
5
+ export { useInfiniteScroll } from './useInfiniteScroll.svelte.js';
6
+ export { useEscapeKeydown } from './useEscapeKeydown.svelte.js';
7
+ export { useDebounce } from './useDebounce.svelte.js';
@@ -0,0 +1,31 @@
1
+ import type { Action } from 'svelte/action';
2
+ export interface UseClickOutsideOptions {
3
+ /**
4
+ * Callback fired when a click occurs outside the element.
5
+ */
6
+ handler: (event: PointerEvent) => void;
7
+ /**
8
+ * Whether the listener is active.
9
+ * @default true
10
+ */
11
+ enabled?: boolean;
12
+ }
13
+ /**
14
+ * Svelte action that detects clicks outside an element.
15
+ *
16
+ * @example
17
+ * ```svelte
18
+ * <script>
19
+ * import { useClickOutside } from 'sv5ui'
20
+ *
21
+ * let open = $state(false)
22
+ * </script>
23
+ *
24
+ * {#if open}
25
+ * <div use:useClickOutside={{ handler: () => (open = false) }}>
26
+ * Dropdown content
27
+ * </div>
28
+ * {/if}
29
+ * ```
30
+ */
31
+ export declare const useClickOutside: Action<HTMLElement, UseClickOutsideOptions>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Svelte action that detects clicks outside an element.
3
+ *
4
+ * @example
5
+ * ```svelte
6
+ * <script>
7
+ * import { useClickOutside } from 'sv5ui'
8
+ *
9
+ * let open = $state(false)
10
+ * </script>
11
+ *
12
+ * {#if open}
13
+ * <div use:useClickOutside={{ handler: () => (open = false) }}>
14
+ * Dropdown content
15
+ * </div>
16
+ * {/if}
17
+ * ```
18
+ */
19
+ export const useClickOutside = (node, initialOptions) => {
20
+ let currentOptions = initialOptions;
21
+ function handlePointerDown(event) {
22
+ if (currentOptions.enabled === false)
23
+ return;
24
+ if (!node.contains(event.target)) {
25
+ currentOptions.handler(event);
26
+ }
27
+ }
28
+ document.addEventListener('pointerdown', handlePointerDown, true);
29
+ return {
30
+ update(newOptions) {
31
+ currentOptions = newOptions;
32
+ },
33
+ destroy() {
34
+ document.removeEventListener('pointerdown', handlePointerDown, true);
35
+ }
36
+ };
37
+ };
@@ -0,0 +1,30 @@
1
+ export interface UseClipboardOptions {
2
+ /**
3
+ * Duration in milliseconds before `copied` resets to `false`.
4
+ * @default 2000
5
+ */
6
+ timeout?: number;
7
+ }
8
+ export interface UseClipboardReturn {
9
+ /** Whether text was recently copied (resets after timeout) */
10
+ readonly copied: boolean;
11
+ /** Copy text to clipboard */
12
+ copy: (text: string) => Promise<void>;
13
+ }
14
+ /**
15
+ * Reactive clipboard hook. Copies text and tracks copied state.
16
+ *
17
+ * @example
18
+ * ```svelte
19
+ * <script>
20
+ * import { useClipboard } from 'sv5ui'
21
+ *
22
+ * const clipboard = useClipboard()
23
+ * </script>
24
+ *
25
+ * <Button onclick={() => clipboard.copy('Hello!')}>
26
+ * {clipboard.copied ? 'Copied!' : 'Copy'}
27
+ * </Button>
28
+ * ```
29
+ */
30
+ export declare function useClipboard(options?: UseClipboardOptions): UseClipboardReturn;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Reactive clipboard hook. Copies text and tracks copied state.
3
+ *
4
+ * @example
5
+ * ```svelte
6
+ * <script>
7
+ * import { useClipboard } from 'sv5ui'
8
+ *
9
+ * const clipboard = useClipboard()
10
+ * </script>
11
+ *
12
+ * <Button onclick={() => clipboard.copy('Hello!')}>
13
+ * {clipboard.copied ? 'Copied!' : 'Copy'}
14
+ * </Button>
15
+ * ```
16
+ */
17
+ export function useClipboard(options = {}) {
18
+ const { timeout = 2000 } = options;
19
+ let copied = $state(false);
20
+ let timeoutId;
21
+ async function copy(text) {
22
+ if (typeof navigator === 'undefined' || !navigator.clipboard)
23
+ return;
24
+ try {
25
+ await navigator.clipboard.writeText(text);
26
+ copied = true;
27
+ clearTimeout(timeoutId);
28
+ timeoutId = setTimeout(() => {
29
+ copied = false;
30
+ }, timeout);
31
+ }
32
+ catch {
33
+ copied = false;
34
+ }
35
+ }
36
+ $effect(() => {
37
+ return () => clearTimeout(timeoutId);
38
+ });
39
+ return {
40
+ get copied() {
41
+ return copied;
42
+ },
43
+ copy
44
+ };
45
+ }
@@ -0,0 +1,36 @@
1
+ export interface UseDebounceOptions {
2
+ /**
3
+ * Delay in milliseconds before the callback is executed.
4
+ * @default 300
5
+ */
6
+ delay?: number;
7
+ }
8
+ /**
9
+ * Reactive debounce hook. Delays execution until a pause in calls.
10
+ *
11
+ * @example
12
+ * ```svelte
13
+ * <script>
14
+ * import { useDebounce } from 'sv5ui'
15
+ *
16
+ * const debounce = useDebounce({ delay: 500 })
17
+ * let query = $state('')
18
+ *
19
+ * function handleInput(e: Event) {
20
+ * query = e.currentTarget.value
21
+ * debounce.run(() => fetchResults(query))
22
+ * }
23
+ * </script>
24
+ *
25
+ * <input oninput={handleInput} />
26
+ * {#if debounce.pending}
27
+ * <Spinner />
28
+ * {/if}
29
+ * ```
30
+ */
31
+ export declare function useDebounce(options?: UseDebounceOptions): {
32
+ readonly pending: boolean;
33
+ run: (callback: () => void) => void;
34
+ cancel: () => void;
35
+ flush: (callback: () => void) => void;
36
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Reactive debounce hook. Delays execution until a pause in calls.
3
+ *
4
+ * @example
5
+ * ```svelte
6
+ * <script>
7
+ * import { useDebounce } from 'sv5ui'
8
+ *
9
+ * const debounce = useDebounce({ delay: 500 })
10
+ * let query = $state('')
11
+ *
12
+ * function handleInput(e: Event) {
13
+ * query = e.currentTarget.value
14
+ * debounce.run(() => fetchResults(query))
15
+ * }
16
+ * </script>
17
+ *
18
+ * <input oninput={handleInput} />
19
+ * {#if debounce.pending}
20
+ * <Spinner />
21
+ * {/if}
22
+ * ```
23
+ */
24
+ export function useDebounce(options = {}) {
25
+ const { delay = 300 } = options;
26
+ let pending = $state(false);
27
+ let timeoutId;
28
+ function run(callback) {
29
+ pending = true;
30
+ clearTimeout(timeoutId);
31
+ timeoutId = setTimeout(() => {
32
+ pending = false;
33
+ callback();
34
+ }, delay);
35
+ }
36
+ function cancel() {
37
+ clearTimeout(timeoutId);
38
+ pending = false;
39
+ }
40
+ function flush(callback) {
41
+ clearTimeout(timeoutId);
42
+ pending = false;
43
+ callback();
44
+ }
45
+ $effect(() => {
46
+ return () => clearTimeout(timeoutId);
47
+ });
48
+ return {
49
+ get pending() {
50
+ return pending;
51
+ },
52
+ run,
53
+ cancel,
54
+ flush
55
+ };
56
+ }
@@ -0,0 +1,31 @@
1
+ import type { Action } from 'svelte/action';
2
+ export interface UseEscapeKeydownOptions {
3
+ /**
4
+ * Callback fired when Escape is pressed.
5
+ */
6
+ handler: (event: KeyboardEvent) => void;
7
+ /**
8
+ * Whether the listener is active.
9
+ * @default true
10
+ */
11
+ enabled?: boolean;
12
+ }
13
+ /**
14
+ * Svelte action that listens for Escape keydown on an element or the document.
15
+ *
16
+ * @example
17
+ * ```svelte
18
+ * <script>
19
+ * import { useEscapeKeydown } from 'sv5ui'
20
+ *
21
+ * let open = $state(true)
22
+ * </script>
23
+ *
24
+ * {#if open}
25
+ * <div use:useEscapeKeydown={{ handler: () => (open = false) }}>
26
+ * Press Escape to close
27
+ * </div>
28
+ * {/if}
29
+ * ```
30
+ */
31
+ export declare const useEscapeKeydown: Action<HTMLElement, UseEscapeKeydownOptions>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Svelte action that listens for Escape keydown on an element or the document.
3
+ *
4
+ * @example
5
+ * ```svelte
6
+ * <script>
7
+ * import { useEscapeKeydown } from 'sv5ui'
8
+ *
9
+ * let open = $state(true)
10
+ * </script>
11
+ *
12
+ * {#if open}
13
+ * <div use:useEscapeKeydown={{ handler: () => (open = false) }}>
14
+ * Press Escape to close
15
+ * </div>
16
+ * {/if}
17
+ * ```
18
+ */
19
+ export const useEscapeKeydown = (_node, initialOptions) => {
20
+ let currentOptions = initialOptions;
21
+ function handleKeydown(event) {
22
+ if (currentOptions.enabled === false)
23
+ return;
24
+ if (event.key === 'Escape') {
25
+ currentOptions.handler(event);
26
+ }
27
+ }
28
+ document.addEventListener('keydown', handleKeydown);
29
+ return {
30
+ update(newOptions) {
31
+ currentOptions = newOptions;
32
+ },
33
+ destroy() {
34
+ document.removeEventListener('keydown', handleKeydown);
35
+ }
36
+ };
37
+ };
@@ -0,0 +1,21 @@
1
+ import type { FormFieldProps } from '../FormField/form-field.types.js';
2
+ export interface FormFieldContext {
3
+ name?: string;
4
+ size: NonNullable<FormFieldProps['size']>;
5
+ error?: string | boolean;
6
+ ariaId: string;
7
+ }
8
+ /**
9
+ * Access the nearest FormField context. Returns `undefined` when used outside a FormField.
10
+ *
11
+ * @example
12
+ * ```svelte
13
+ * <script>
14
+ * import { useFormField } from 'sv5ui'
15
+ *
16
+ * const formField = useFormField()
17
+ * const hasError = $derived(formField?.error !== undefined && formField?.error !== false)
18
+ * </script>
19
+ * ```
20
+ */
21
+ export declare function useFormField(): FormFieldContext | undefined;
@@ -0,0 +1,17 @@
1
+ import { getContext } from 'svelte';
2
+ /**
3
+ * Access the nearest FormField context. Returns `undefined` when used outside a FormField.
4
+ *
5
+ * @example
6
+ * ```svelte
7
+ * <script>
8
+ * import { useFormField } from 'sv5ui'
9
+ *
10
+ * const formField = useFormField()
11
+ * const hasError = $derived(formField?.error !== undefined && formField?.error !== false)
12
+ * </script>
13
+ * ```
14
+ */
15
+ export function useFormField() {
16
+ return getContext('formField');
17
+ }
@@ -0,0 +1,57 @@
1
+ import type { Action } from 'svelte/action';
2
+ export interface UseInfiniteScrollOptions {
3
+ /**
4
+ * Callback fired when the user scrolls near the bottom.
5
+ * Return a promise to automatically manage the loading state.
6
+ */
7
+ onLoad: () => void | Promise<void>;
8
+ /**
9
+ * Distance in pixels from the bottom to trigger loading.
10
+ * @default 100
11
+ */
12
+ threshold?: number;
13
+ /**
14
+ * Whether the hook is active. Set to `false` when all data is loaded.
15
+ * Supports getter function for reactive control.
16
+ * @default true
17
+ */
18
+ enabled?: boolean | (() => boolean);
19
+ }
20
+ export interface UseInfiniteScrollReturn {
21
+ /** Whether an async onLoad is currently in progress */
22
+ readonly loading: boolean;
23
+ }
24
+ /**
25
+ * Reactive infinite scroll hook with Svelte action.
26
+ *
27
+ * @example
28
+ * ```svelte
29
+ * <script>
30
+ * import { useInfiniteScroll } from 'sv5ui'
31
+ *
32
+ * let items = $state([...])
33
+ * let hasMore = $state(true)
34
+ *
35
+ * const scroll = useInfiniteScroll({
36
+ * onLoad: async () => {
37
+ * const next = await fetchMore()
38
+ * items.push(...next)
39
+ * if (next.length === 0) hasMore = false
40
+ * },
41
+ * enabled: () => hasMore
42
+ * })
43
+ * </script>
44
+ *
45
+ * <div use:scroll.action>
46
+ * {#each items as item (item.id)}
47
+ * <div>{item.name}</div>
48
+ * {/each}
49
+ * {#if scroll.loading}
50
+ * <Spinner />
51
+ * {/if}
52
+ * </div>
53
+ * ```
54
+ */
55
+ export declare function useInfiniteScroll(options: UseInfiniteScrollOptions): UseInfiniteScrollReturn & {
56
+ action: Action<HTMLElement>;
57
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Reactive infinite scroll hook with Svelte action.
3
+ *
4
+ * @example
5
+ * ```svelte
6
+ * <script>
7
+ * import { useInfiniteScroll } from 'sv5ui'
8
+ *
9
+ * let items = $state([...])
10
+ * let hasMore = $state(true)
11
+ *
12
+ * const scroll = useInfiniteScroll({
13
+ * onLoad: async () => {
14
+ * const next = await fetchMore()
15
+ * items.push(...next)
16
+ * if (next.length === 0) hasMore = false
17
+ * },
18
+ * enabled: () => hasMore
19
+ * })
20
+ * </script>
21
+ *
22
+ * <div use:scroll.action>
23
+ * {#each items as item (item.id)}
24
+ * <div>{item.name}</div>
25
+ * {/each}
26
+ * {#if scroll.loading}
27
+ * <Spinner />
28
+ * {/if}
29
+ * </div>
30
+ * ```
31
+ */
32
+ export function useInfiniteScroll(options) {
33
+ const { onLoad, threshold = 100 } = options;
34
+ let loading = $state(false);
35
+ function getEnabled() {
36
+ return typeof options.enabled === 'function' ? options.enabled() : (options.enabled ?? true);
37
+ }
38
+ async function check(el) {
39
+ if (loading || !getEnabled())
40
+ return;
41
+ const { scrollTop, scrollHeight, clientHeight } = el;
42
+ if (scrollHeight - scrollTop - clientHeight > threshold)
43
+ return;
44
+ loading = true;
45
+ try {
46
+ await onLoad();
47
+ }
48
+ finally {
49
+ loading = false;
50
+ }
51
+ }
52
+ const action = (node) => {
53
+ function handleScroll() {
54
+ check(node);
55
+ }
56
+ node.addEventListener('scroll', handleScroll, { passive: true });
57
+ return {
58
+ destroy() {
59
+ node.removeEventListener('scroll', handleScroll);
60
+ }
61
+ };
62
+ };
63
+ return {
64
+ get loading() {
65
+ return loading;
66
+ },
67
+ action
68
+ };
69
+ }