@softwareone/spi-sv5-library 1.16.0 → 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;
@@ -362,7 +362,7 @@
362
362
  {#snippet removeButton(event: VoidFunction)}
363
363
  <button
364
364
  type="button"
365
- class="clear-button"
365
+ class="clear-button spi-text-regular-2"
366
366
  onclick={(e) => {
367
367
  e.stopPropagation();
368
368
  event();
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { AnnouncementItem, MenuItem } from '../../index.js';
2
+ import type { AnnouncementItem, MainMenu, MenuItem } from '../../index.js';
3
3
  import Announcement from '../announcement/Announcement.svelte';
4
4
  import Menu from '../menu/Menu.svelte';
5
5
  import Waffle from '../waffle/Waffle.svelte';
@@ -17,7 +17,7 @@
17
17
  userName?: string;
18
18
  hideLoader?: boolean;
19
19
  hideAnnouncement?: boolean;
20
- menuItems?: MenuItem[];
20
+ menuItems?: MainMenu[];
21
21
  waffleItems?: WaffleItem[];
22
22
  profileMenuItems?: MenuItem[];
23
23
  announcementItems?: AnnouncementItem[];
@@ -1,4 +1,4 @@
1
- import type { AnnouncementItem, MenuItem } from '../../index.js';
1
+ import type { AnnouncementItem, MainMenu, MenuItem } from '../../index.js';
2
2
  import type { WaffleItem } from '../waffle/types.js';
3
3
  interface Props {
4
4
  title?: string;
@@ -9,7 +9,7 @@ interface Props {
9
9
  userName?: string;
10
10
  hideLoader?: boolean;
11
11
  hideAnnouncement?: boolean;
12
- menuItems?: MenuItem[];
12
+ menuItems?: MainMenu[];
13
13
  waffleItems?: WaffleItem[];
14
14
  profileMenuItems?: MenuItem[];
15
15
  announcementItems?: AnnouncementItem[];
@@ -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,12 +1,14 @@
1
1
  <script lang="ts">
2
+ import { page } from '$app/state';
2
3
  import { fade } from 'svelte/transition';
3
4
 
4
- import { existRoute, getFirstPathSegment, getNormalizedPathname } from '../../utils/index.js';
5
+ import { existRoute, getSectionPrefix } from '../../utils/index.js';
5
6
  import MainMenuItem from './MenuItem.svelte';
6
- import type { MenuItem } from './types.js';
7
+ import type { MainMenu } from './types.js';
8
+ import { hasMenuItems } from './utils.js';
7
9
 
8
10
  interface Props {
9
- menuItems: MenuItem[];
11
+ menuItems: MainMenu[];
10
12
  showMenu: boolean;
11
13
  }
12
14
 
@@ -15,14 +17,13 @@
15
17
  let activeItem = $state('');
16
18
 
17
19
  const setActiveMenuItem = () => {
18
- activeItem =
19
- menuItems.find((menuItem: MenuItem) => isActiveMenuItem(menuItem.url))?.text || '';
20
+ activeItem = menuItems.find((menu) => isActiveMenuItem(menu))?.text || '';
20
21
  };
21
22
 
22
- const isActiveMenuItem = (url: string) => {
23
- const pathname = getNormalizedPathname();
24
- const firstPathSegment = getFirstPathSegment(pathname);
25
- return existRoute(firstPathSegment, url);
23
+ const isActiveMenuItem = (menu: MainMenu): boolean => {
24
+ const pathname = page.url.pathname;
25
+ const routeToMatch = hasMenuItems(menu.children) ? getSectionPrefix(menu.url) : menu.url;
26
+ return existRoute(routeToMatch, pathname);
26
27
  };
27
28
 
28
29
  const onHandleMenu = () => {
@@ -1,6 +1,6 @@
1
- import type { MenuItem } from './types.js';
1
+ import type { MainMenu } from './types.js';
2
2
  interface Props {
3
- menuItems: MenuItem[];
3
+ menuItems: MainMenu[];
4
4
  showMenu: boolean;
5
5
  }
6
6
  declare const Menu: import("svelte").Component<Props, {}, "showMenu">;
@@ -1,2 +1,3 @@
1
1
  import type { MainMenu, SubMenuItem } from './types.js';
2
+ export declare const hasMenuItems: (children?: SubMenuItem[]) => boolean;
2
3
  export declare const getSubMenuItemsFromMenu: (items: MainMenu[], excludedRoutes?: string[]) => SubMenuItem[];
@@ -1,11 +1,16 @@
1
- import { existRoute, getFirstPathSegment, getNormalizedPathname } from '../../utils/index.js';
1
+ import { page } from '$app/state';
2
+ import { existRoute, getSectionPrefix } from '../../utils/index.js';
3
+ export const hasMenuItems = (children) => {
4
+ return Boolean(children?.some((child) => child.menuItems.length));
5
+ };
2
6
  export const getSubMenuItemsFromMenu = (items, excludedRoutes) => {
3
- const pathname = getNormalizedPathname();
7
+ const pathname = page.url.pathname;
4
8
  if (excludedRoutes?.includes(pathname))
5
9
  return [];
6
- const firstPathSegment = getFirstPathSegment(pathname);
7
10
  const matchedMenu = items.find((menu) => {
8
- return existRoute(firstPathSegment, menu.url);
11
+ if (!hasMenuItems(menu.children))
12
+ return false;
13
+ return existRoute(getSectionPrefix(menu.url), pathname);
9
14
  });
10
15
  return matchedMenu?.children ?? [];
11
16
  };
@@ -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
+ };
@@ -1,3 +1,2 @@
1
- export declare const getNormalizedPathname: () => string;
2
- export declare const getFirstPathSegment: (pathname: string) => string;
1
+ export declare const getSectionPrefix: (url: string) => string;
3
2
  export declare const existRoute: (url: string, pathname: string) => boolean;
package/dist/utils/url.js CHANGED
@@ -1,17 +1,9 @@
1
- import { base } from '$app/paths';
2
- import { page } from '$app/state';
3
- const normalizePathname = (path) => path.replace(/^\/v\d+\//, '/');
4
- export const getNormalizedPathname = () => {
5
- const pathname = page.url.pathname;
6
- const withoutBase = base && pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
7
- return normalizePathname(withoutBase);
8
- };
9
- export const getFirstPathSegment = (pathname) => {
10
- const matchedPath = /^\/[^/]+/.exec(pathname);
11
- return matchedPath?.[0] ?? '/';
1
+ export const getSectionPrefix = (url) => {
2
+ const segments = url.split('/');
3
+ segments.pop();
4
+ return segments.join('/') || '/';
12
5
  };
13
6
  export const existRoute = (url, pathname) => {
14
- const normalizedPathname = normalizePathname(pathname);
15
- const regex = new RegExp(`^${url}(?![\\w-])`);
16
- return regex.test(normalizedPathname);
7
+ const regex = new RegExp(String.raw `^${url}(?![\w-])`);
8
+ return regex.test(pathname);
17
9
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softwareone/spi-sv5-library",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "Svelte components",
5
5
  "keywords": [
6
6
  "svelte",