@happyvertical/smrt-ui 0.34.9 → 0.35.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.
@@ -5,6 +5,7 @@
5
5
  * Shows a label with a prominent value, optionally with a count badge
6
6
  * and link functionality.
7
7
  */
8
+ import type { Snippet } from 'svelte';
8
9
  import { ripple } from '../../actions/ripple.js';
9
10
  import { Icon } from '../display/index.js';
10
11
 
@@ -19,8 +20,18 @@ export interface Props {
19
20
  highlight?: boolean;
20
21
  /** Make card clickable with href */
21
22
  href?: string;
22
- /** Optional icon (name for Icon or SVG string) */
23
+ /** Optional icon name, rendered via the {@link Icon} component. */
23
24
  icon?: string;
25
+ /**
26
+ * Optional custom icon content, rendered as a Svelte snippet. Use this
27
+ * escape hatch for bespoke icon markup. It replaces the former raw-SVG
28
+ * **string** support, removed in #1591: SummaryCard no longer renders any
29
+ * consumer-supplied value via `{@html}` (an XSS sink). `iconContent` is
30
+ * rendered as a snippet, so the trust boundary moves to the consumer — the
31
+ * snippet's contents are whatever the consumer authors. When both
32
+ * `iconContent` and `icon` are provided, `iconContent` wins.
33
+ */
34
+ iconContent?: Snippet;
24
35
  /** Value color variant */
25
36
  variant?: 'default' | 'success' | 'warning' | 'danger';
26
37
  }
@@ -32,6 +43,7 @@ const {
32
43
  highlight = false,
33
44
  href,
34
45
  icon,
46
+ iconContent,
35
47
  variant = 'default',
36
48
  }: Props = $props();
37
49
 
@@ -48,10 +60,16 @@ const valueColorClass = $derived.by(() => {
48
60
  return 'color-default';
49
61
  }
50
62
  });
51
-
52
- const isSvg = $derived(icon?.startsWith('<svg'));
53
63
  </script>
54
64
 
65
+ {#snippet cardIcon()}
66
+ {#if iconContent}
67
+ <span class="card-icon">{@render iconContent()}</span>
68
+ {:else if icon}
69
+ <span class="card-icon"><Icon name={icon} size={24} /></span>
70
+ {/if}
71
+ {/snippet}
72
+
55
73
  {#if href}
56
74
  <a
57
75
  class="summary-card"
@@ -60,15 +78,7 @@ const isSvg = $derived(icon?.startsWith('<svg'));
60
78
  {href}
61
79
  use:ripple
62
80
  >
63
- {#if icon}
64
- <span class="card-icon">
65
- {#if isSvg}
66
- {@html icon}
67
- {:else}
68
- <Icon name={icon} size={24} />
69
- {/if}
70
- </span>
71
- {/if}
81
+ {@render cardIcon()}
72
82
  <div class="card-content">
73
83
  <span class="card-label">
74
84
  {label}
@@ -84,15 +94,7 @@ const isSvg = $derived(icon?.startsWith('<svg'));
84
94
  </a>
85
95
  {:else}
86
96
  <div class="summary-card" class:highlight>
87
- {#if icon}
88
- <span class="card-icon">
89
- {#if isSvg}
90
- {@html icon}
91
- {:else}
92
- <Icon name={icon} size={24} />
93
- {/if}
94
- </span>
95
- {/if}
97
+ {@render cardIcon()}
96
98
  <div class="card-content">
97
99
  <span class="card-label">
98
100
  {label}
@@ -1,3 +1,10 @@
1
+ /**
2
+ * SummaryCard - Statistic/summary display card refactored for Material 3
3
+ *
4
+ * Shows a label with a prominent value, optionally with a count badge
5
+ * and link functionality.
6
+ */
7
+ import type { Snippet } from 'svelte';
1
8
  export interface Props {
2
9
  /** Card label */
3
10
  label: string;
@@ -9,8 +16,18 @@ export interface Props {
9
16
  highlight?: boolean;
10
17
  /** Make card clickable with href */
11
18
  href?: string;
12
- /** Optional icon (name for Icon or SVG string) */
19
+ /** Optional icon name, rendered via the {@link Icon} component. */
13
20
  icon?: string;
21
+ /**
22
+ * Optional custom icon content, rendered as a Svelte snippet. Use this
23
+ * escape hatch for bespoke icon markup. It replaces the former raw-SVG
24
+ * **string** support, removed in #1591: SummaryCard no longer renders any
25
+ * consumer-supplied value via `{@html}` (an XSS sink). `iconContent` is
26
+ * rendered as a snippet, so the trust boundary moves to the consumer — the
27
+ * snippet's contents are whatever the consumer authors. When both
28
+ * `iconContent` and `icon` are provided, `iconContent` wins.
29
+ */
30
+ iconContent?: Snippet;
14
31
  /** Value color variant */
15
32
  variant?: 'default' | 'success' | 'warning' | 'danger';
16
33
  }
@@ -1 +1 @@
1
- {"version":3,"file":"SummaryCard.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/layout/SummaryCard.svelte.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,+BAA+B;IAC/B,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oCAAoC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0BAA0B;IAC1B,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;CACxD;AAoFD,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"SummaryCard.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/layout/SummaryCard.svelte.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAKtC,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,+BAA+B;IAC/B,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oCAAoC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,0BAA0B;IAC1B,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;CACxD;AA4ED,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -6,6 +6,7 @@
6
6
  * axe-clean output across a couple of states.
7
7
  */
8
8
  import { render, screen } from '@testing-library/svelte';
9
+ import { createRawSnippet } from 'svelte';
9
10
  import { describe, expect, it } from 'vitest';
10
11
  import { expectNoA11yViolations } from '../../../test-support/a11y';
11
12
  import SummaryCard from '../SummaryCard.svelte';
@@ -37,15 +38,36 @@ describe('SummaryCard', () => {
37
38
  });
38
39
  expect(container.querySelector('.card-count')).toBeNull();
39
40
  });
40
- it('renders a raw SVG icon when icon starts with <svg', () => {
41
+ it('renders an icon via the Icon component when icon is a known name', () => {
42
+ const { container } = render(SummaryCard, {
43
+ // `search` is a real Icon preset, so the rendered <path> must carry a
44
+ // non-empty `d` — proving the icon-name path actually resolved (an
45
+ // unknown name would still render an <svg> but with an empty path).
46
+ props: { label: 'Visitors', value: 99, icon: 'search' },
47
+ });
48
+ const iconPath = container.querySelector('.card-icon svg path');
49
+ expect(iconPath?.getAttribute('d')).toBeTruthy();
50
+ });
51
+ it('does NOT inject a raw SVG string as markup (no {@html} sink) — #1591', () => {
41
52
  const { container } = render(SummaryCard, {
42
53
  props: {
43
54
  label: 'Visitors',
44
55
  value: 99,
45
- icon: '<svg data-testid="svg-icon" viewBox="0 0 24 24"></svg>',
56
+ // A would-be XSS payload: the string is forwarded to <Icon name>, never
57
+ // rendered as HTML, so the attacker-controlled element must be absent.
58
+ icon: '<svg data-evil="1" viewBox="0 0 24 24"></svg>',
46
59
  },
47
60
  });
48
- expect(container.querySelector('.card-icon svg')).not.toBeNull();
61
+ expect(container.querySelector('svg[data-evil]')).toBeNull();
62
+ });
63
+ it('renders custom icon markup via the iconContent snippet', () => {
64
+ const iconContent = createRawSnippet(() => ({
65
+ render: () => '<span data-testid="custom-icon">★</span>',
66
+ }));
67
+ const { container } = render(SummaryCard, {
68
+ props: { label: 'Starred', value: 1, iconContent },
69
+ });
70
+ expect(container.querySelector('.card-icon [data-testid="custom-icon"]')).not.toBeNull();
49
71
  });
50
72
  it('renders as a clickable link when href is provided', () => {
51
73
  render(SummaryCard, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-ui",
3
- "version": "0.34.9",
3
+ "version": "0.35.0",
4
4
  "description": "Domain-agnostic Svelte 5 UI runtime for SMRT: primitives, i18n client, theme system, and module UI registry",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -114,7 +114,7 @@
114
114
  },
115
115
  "dependencies": {
116
116
  "esm-env": "^1.2.2",
117
- "@happyvertical/smrt-types": "0.34.9"
117
+ "@happyvertical/smrt-types": "0.35.0"
118
118
  },
119
119
  "peerDependencies": {
120
120
  "svelte": "^5.18.2"