@softwareone/spi-sv5-library 1.16.1 → 1.17.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.
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import { Button, Input, addToast } from '../../index.js';
3
+
4
+ interface Props {
5
+ label?: string;
6
+ notification?: string;
7
+ iconOnly?: boolean;
8
+ value: string;
9
+ }
10
+
11
+ let { label, notification = label, iconOnly = false, value }: Props = $props();
12
+
13
+ const copyToClipboard = async (value: string, notification?: string) => {
14
+ await navigator.clipboard.writeText(value);
15
+ addToast({
16
+ message: `${notification || 'Item'} copied to clipboard.`,
17
+ type: 'neutral'
18
+ });
19
+ };
20
+ </script>
21
+
22
+ <div class={['clipboard', label && 'clipboard-labeled']}>
23
+ {#if label}
24
+ <span class="clipboard-label">{label}</span>
25
+ {/if}
26
+ <div class={[!iconOnly && 'clipboard-row-with-input']}>
27
+ {#if !iconOnly}
28
+ <div class="clipboard-input">
29
+ <Input
30
+ placeholder={label}
31
+ aria-label={label}
32
+ {value}
33
+ readonly
34
+ disableValidationColor
35
+ />
36
+ </div>
37
+ {/if}
38
+ <Button
39
+ variant="outline"
40
+ aria-label={notification ?? label ?? 'Copy to clipboard'}
41
+ onclick={() => copyToClipboard(value, notification)}
42
+ >
43
+ <span class="material-icons" style:font-size="var(--spi-font-size-sm)"
44
+ >content_copy</span
45
+ >
46
+ </Button>
47
+ </div>
48
+ </div>
49
+
50
+ <style>
51
+ .clipboard {
52
+ width: 100%;
53
+ }
54
+
55
+ .clipboard-labeled {
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: var(--spi-size-2);
59
+ }
60
+
61
+ .clipboard-label {
62
+ font-size: var(--spi-font-size-sm);
63
+ font-weight: var(--spi-font-weight-medium);
64
+ }
65
+
66
+ .clipboard-row-with-input {
67
+ display: flex;
68
+ gap: var(--spi-size-2);
69
+ }
70
+
71
+ .clipboard-input {
72
+ flex: 1;
73
+ }
74
+ </style>
@@ -0,0 +1,9 @@
1
+ interface Props {
2
+ label?: string;
3
+ notification?: string;
4
+ iconOnly?: boolean;
5
+ value: string;
6
+ }
7
+ declare const Clipboard: import("svelte").Component<Props, {}, "">;
8
+ type Clipboard = ReturnType<typeof Clipboard>;
9
+ export default Clipboard;
@@ -3,6 +3,7 @@ export { default as Avatar } from './avatar/Avatar.svelte';
3
3
  export { default as Button } from './button/Button.svelte';
4
4
  export { default as Card } from './card/Card.svelte';
5
5
  export { default as Chips } from './chips/Chips.svelte';
6
+ export { default as Clipboard } from './clipboard/Clipboard.svelte';
6
7
  export { default as ConfirmationModal } from './confirmation/Confirmation.svelte';
7
8
  export { default as AttachFile } from './controls/attach-file/AttachFile.svelte';
8
9
  export { default as Input } from './controls/input/Input.svelte';
@@ -3,6 +3,7 @@ export { default as Avatar } from './avatar/Avatar.svelte';
3
3
  export { default as Button } from './button/Button.svelte';
4
4
  export { default as Card } from './card/Card.svelte';
5
5
  export { default as Chips } from './chips/Chips.svelte';
6
+ export { default as Clipboard } from './clipboard/Clipboard.svelte';
6
7
  export { default as ConfirmationModal } from './confirmation/Confirmation.svelte';
7
8
  export { default as AttachFile } from './controls/attach-file/AttachFile.svelte';
8
9
  export { default as Input } from './controls/input/Input.svelte';
@@ -1,9 +1,11 @@
1
1
  <script lang="ts" generics="T extends object">
2
+ import { browser } from '$app/environment';
2
3
  import { goto } from '$app/navigation';
3
4
  import { page } from '$app/state';
4
- import { type Snippet } from 'svelte';
5
+ import { onMount, type Snippet } from 'svelte';
5
6
 
6
7
  import { Button, Search } from '../../index.js';
8
+ import { LocalStorage } from '../../utils/local-storage.js';
7
9
  import {
8
10
  createSvelteTable,
9
11
  getCoreRowModel,
@@ -40,7 +42,8 @@
40
42
  createCheckedColumn,
41
43
  isCursorPagination,
42
44
  isValidPage,
43
- isValidPageSize
45
+ isValidPageSize,
46
+ sanitizeFilters
44
47
  } from './utils.js';
45
48
 
46
49
  interface BaseProps<T> {
@@ -54,6 +57,8 @@
54
57
  enableColumnSearch?: boolean;
55
58
  enableColumnVisibility?: boolean;
56
59
  enableAdvancedFilter?: boolean;
60
+ stickyFilter?: boolean;
61
+ stickyFilterKey?: string;
57
62
  serverSide?: boolean;
58
63
  pagination?: Pagination;
59
64
  initialPage?: number;
@@ -101,6 +106,8 @@
101
106
  enableColumnSearch = false,
102
107
  enableColumnVisibility = true,
103
108
  enableAdvancedFilter = false,
109
+ stickyFilter = false,
110
+ stickyFilterKey,
104
111
  serverSide = false,
105
112
  pagination,
106
113
  initialPage,
@@ -118,6 +125,7 @@
118
125
  }: Props<T> = $props();
119
126
 
120
127
  const tableManualPagination = serverSide ? setPaginationTableContext() : undefined;
128
+ const STICKY_FILTER_STORAGE_PREFIX = 'spi-table-sticky-filters';
121
129
 
122
130
  let globalFilter = $state<string>('');
123
131
  let sorting = $state<SortingState>([]);
@@ -272,17 +280,58 @@
272
280
  rowSelection = {};
273
281
  };
274
282
 
283
+ const getStickyFilterStorageKey = () => {
284
+ const key = stickyFilterKey?.trim() ? stickyFilterKey : page.url.pathname;
285
+ return `${STICKY_FILTER_STORAGE_PREFIX}:${key}`;
286
+ };
287
+
288
+ const isStickyFilterActive = () => browser && stickyFilter;
289
+
290
+ const readStickyFilterValues = (): string[] =>
291
+ isStickyFilterActive()
292
+ ? (LocalStorage.getItem<string[]>(getStickyFilterStorageKey()) ?? [])
293
+ : [];
294
+
295
+ const writeStickyFilterValues = (filters: string[]) => {
296
+ if (!isStickyFilterActive()) return;
297
+
298
+ const storageKey = getStickyFilterStorageKey();
299
+ if (!filters.length) {
300
+ LocalStorage.removeItem(storageKey);
301
+ } else {
302
+ LocalStorage.setItem(storageKey, filters);
303
+ }
304
+ };
305
+
306
+ const restoreStickyFilters = () => {
307
+ if (!isStickyFilterActive() || !enableAdvancedFilter) return;
308
+
309
+ const currentFilters = page.url.searchParams.getAll('filter');
310
+ if (currentFilters.length) {
311
+ writeStickyFilterValues(sanitizeFilters(currentFilters).map(buildFilterValue));
312
+ return;
313
+ }
314
+
315
+ const storedFilters = sanitizeFilters(readStickyFilterValues()).map(buildFilterValue);
316
+ if (!storedFilters.length) return;
317
+
318
+ const searchParams = new URLSearchParams(page.url.searchParams);
319
+ storedFilters.forEach((filterValue) => {
320
+ searchParams.append('filter', filterValue);
321
+ });
322
+
323
+ goto(buildUrl(searchParams), { replaceState: true, noScroll: true, keepFocus: true });
324
+ };
325
+
275
326
  const changeFilters = (filters: Filter[]) => {
276
327
  const searchParams = new URLSearchParams(page.url.searchParams);
277
328
  searchParams.delete('filter');
278
329
 
279
- filters.forEach((filter) => {
280
- const filterValue = buildFilterValue(filter);
281
- searchParams.append('filter', filterValue);
282
- });
330
+ const filterValues = filters.map(buildFilterValue);
331
+ filterValues.forEach((filterValue) => searchParams.append('filter', filterValue));
283
332
 
284
- const url = buildUrl(searchParams);
285
- goto(url);
333
+ writeStickyFilterValues(filterValues);
334
+ goto(buildUrl(searchParams));
286
335
  };
287
336
 
288
337
  const buildFilterValue = (filter: Filter) => {
@@ -295,6 +344,13 @@
295
344
  return queryString ? `${page.url.pathname}?${queryString}` : page.url.pathname;
296
345
  };
297
346
 
347
+ $effect(() => {
348
+ columnFilters = sanitizeFilters(page.url.searchParams.getAll('filter')).map((filter) => ({
349
+ id: filter.column,
350
+ value: filter.value
351
+ }));
352
+ });
353
+
298
354
  $effect(() => {
299
355
  if (serverSide && pagination) {
300
356
  $tableManualPagination = { ...pagination };
@@ -310,6 +366,10 @@
310
366
  if (!enableChecked) return;
311
367
  onclearselection?.(clearSelection);
312
368
  });
369
+
370
+ onMount(() => {
371
+ restoreStickyFilters();
372
+ });
313
373
  </script>
314
374
 
315
375
  <section class="table-container">
@@ -13,6 +13,8 @@ interface BaseProps<T> {
13
13
  enableColumnSearch?: boolean;
14
14
  enableColumnVisibility?: boolean;
15
15
  enableAdvancedFilter?: boolean;
16
+ stickyFilter?: boolean;
17
+ stickyFilterKey?: string;
16
18
  serverSide?: boolean;
17
19
  pagination?: Pagination;
18
20
  initialPage?: number;
@@ -1 +1,2 @@
1
+ export * from './local-storage.js';
1
2
  export * from './url.js';
@@ -1 +1,2 @@
1
+ export * from './local-storage.js';
1
2
  export * from './url.js';
@@ -0,0 +1,6 @@
1
+ export declare const LocalStorage: {
2
+ readonly getItem: <T>(key: string) => T | null;
3
+ readonly setItem: <T>(key: string, value: T, expiresInMs?: number) => void;
4
+ readonly removeItem: (key: string) => void;
5
+ readonly clear: () => void;
6
+ };
@@ -0,0 +1,26 @@
1
+ export const LocalStorage = {
2
+ getItem: (key) => {
3
+ const item = localStorage.getItem(key);
4
+ if (item === null)
5
+ return null;
6
+ const cachedItem = JSON.parse(item);
7
+ if (cachedItem.expiresAt && Date.now() > cachedItem.expiresAt) {
8
+ localStorage.removeItem(key);
9
+ return null;
10
+ }
11
+ return cachedItem.value;
12
+ },
13
+ setItem: (key, value, expiresInMs) => {
14
+ const stored = {
15
+ value,
16
+ expiresAt: expiresInMs ? Date.now() + expiresInMs : undefined
17
+ };
18
+ localStorage.setItem(key, JSON.stringify(stored));
19
+ },
20
+ removeItem: (key) => {
21
+ localStorage.removeItem(key);
22
+ },
23
+ clear: () => {
24
+ localStorage.clear();
25
+ }
26
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softwareone/spi-sv5-library",
3
- "version": "1.16.1",
3
+ "version": "1.17.0",
4
4
  "description": "Svelte components",
5
5
  "keywords": [
6
6
  "svelte",