@netlify/spark-ui 1.9.0 → 1.10.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.
@@ -1,6 +1,6 @@
1
1
  import { u as t } from "../../../chunks/jsxRuntime.module.Bzuv3cXw.js";
2
2
  import { A as a } from "../../../chunks/hooks.module.DGkdD5n9.js";
3
- import { C as s, K as i } from "../../../chunks/ChatInterface.BD7rlv4H.js";
3
+ import { C as s, K as i } from "../../../chunks/ChatInterface.EQAPs01R.js";
4
4
  import { A as c } from "../../../chunks/analytics.Ch1vuuOl.js";
5
5
  import '../../../assets/AskNetlify.css';function k(o) {
6
6
  const e = a(new c());
@@ -1,6 +1,6 @@
1
1
  import "../../../../chunks/jsxRuntime.module.Bzuv3cXw.js";
2
2
  import "../../../../chunks/hooks.module.DGkdD5n9.js";
3
- import { C as f } from "../../../../chunks/ChatInterface.BD7rlv4H.js";
3
+ import { C as f } from "../../../../chunks/ChatInterface.EQAPs01R.js";
4
4
  import "./QuestionList/questions.js";
5
5
  import "./QuestionList/QuestionList.js";
6
6
  import "./BackButton.js";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Fetches an SVG from a remote URL and strips any `fill` attributes
3
+ * to allow styling the SVG via CSS (e.g., using `currentColor` or masks).
4
+ *
5
+ * If the fetch fails or the response is not OK, logs the error and returns an empty string.
6
+ *
7
+ * @param {string} url - The URL of the SVG to fetch.
8
+ * @returns {Promise<string>} The cleaned SVG markup with `fill` attributes removed,
9
+ * or an empty string if the fetch fails.
10
+ */
11
+ export declare function fetchSvg(url: string): Promise<string>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Converts a given string to kebab-case.
3
+ *
4
+ * - Converts camelCase to kebab-case.
5
+ * - Replaces spaces and underscores with dashes.
6
+ * - Removes all non-alphanumeric characters except dashes.
7
+ * - Converts the entire string to lowercase.
8
+ *
9
+ * @param {string} str - The input string to convert.
10
+ * @returns {string} The kebab-cased version of the input string.
11
+ *
12
+ * @example
13
+ * kebabCase('HelloWorld') // 'hello-world'
14
+ * kebabCase('some_text Here') // 'some-text-here'
15
+ * kebabCase('already-kebab-case') // 'already-kebab-case'
16
+ */
17
+ export declare const kebabCase: (str: string) => string;
@@ -12,3 +12,16 @@ export interface ImageProps {
12
12
  width: string;
13
13
  height: string;
14
14
  }
15
+ export interface Testimonial {
16
+ quote?: string;
17
+ author?: string;
18
+ title?: string;
19
+ company?: string;
20
+ logoSrc?: string;
21
+ avatarSrc?: string;
22
+ cta?: {
23
+ id?: string;
24
+ url?: string;
25
+ label?: string;
26
+ };
27
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@netlify/spark-ui",
3
3
  "description": "Assets, design tokens, components, and utilities",
4
- "version": "1.9.0",
4
+ "version": "1.10.0",
5
5
  "type": "module",
6
6
  "main": "dist/components/preact/index.js",
7
7
  "types": "dist/components/preact/index.d.ts",
@@ -3,10 +3,20 @@ interface Props {
3
3
  quote?: string;
4
4
  author?: string;
5
5
  title?: string;
6
- variant?: 'default' | 'simple';
6
+ avatarSrc?: string;
7
+ class?: string;
8
+ variant?: 'default' | 'simple' | 'testimonial';
7
9
  }
8
10
 
9
- const { quote, variant = 'default', author, title, class: className, ...rest } = Astro.props;
11
+ const {
12
+ quote,
13
+ variant = 'default',
14
+ author,
15
+ title,
16
+ avatarSrc,
17
+ class: className,
18
+ ...rest
19
+ } = Astro.props as Props;
10
20
 
11
21
  /**
12
22
  * If quote is added via <slot />, trim surrounding whitespace so that quotation marks properly wrap inline with the text string.
@@ -25,8 +35,11 @@ if (Astro.slots.has('default')) {
25
35
  {
26
36
  author || title ? (
27
37
  <figcaption>
28
- {author && <span>{author}</span>}
29
- {title && <span>{title}</span>}
38
+ {avatarSrc && <img src={avatarSrc} alt={`Avatar of ${author}`} />}
39
+ <div class="n-blockquote-author-info">
40
+ {author && <span class="n-blockquote-author">{author}</span>}
41
+ {title && <span class="n-blockquote-title">{title}</span>}
42
+ </div>
30
43
  </figcaption>
31
44
  ) : null
32
45
  }
@@ -60,13 +73,15 @@ if (Astro.slots.has('default')) {
60
73
 
61
74
  blockquote::before {
62
75
  content: open-quote;
76
+ color: var(--blockquote-accent-color);
63
77
  }
64
78
 
65
79
  blockquote::after {
66
80
  content: close-quote;
81
+ color: var(--blockquote-accent-color);
67
82
  }
68
83
 
69
- figcaption {
84
+ .n-blockquote-author-info {
70
85
  display: flex;
71
86
  gap: 0.2em;
72
87
  font-size: var(--font-size-text-xs);
@@ -75,29 +90,67 @@ if (Astro.slots.has('default')) {
75
90
 
76
91
  /* Variants */
77
92
  [data-blockquote-variant='default'] {
78
- figcaption {
93
+ .n-blockquote-author-info {
79
94
  flex-direction: column;
80
95
  font-weight: var(--font-weight-regular);
81
96
  }
82
97
 
83
- figcaption > * {
98
+ .n-blockquote-author-info > * {
84
99
  display: block;
85
100
  font-family: var(--font-family-mono);
86
101
  text-transform: uppercase;
87
102
  }
88
103
 
89
- figcaption > *:last-child:not(:only-child) {
104
+ .n-blockquote-author-info > *:last-child:not(:only-child) {
90
105
  color: var(--color-text-weak);
91
106
  }
92
107
  }
93
108
 
94
109
  [data-blockquote-variant='simple'] {
95
- figcaption > * {
110
+ .n-blockquote-author-info > * {
96
111
  font-size: var(--font-size-text-sm);
97
112
  }
98
113
 
99
- figcaption > *:not(:last-child)::after {
114
+ .n-blockquote-author-info > *:not(:last-child)::after {
100
115
  content: ',';
101
116
  }
102
117
  }
118
+
119
+ [data-blockquote-variant='testimonial'] {
120
+ border-inline-start: none;
121
+
122
+ font-size: var(--font-size-display-lg);
123
+ font-weight: var(--font-weight-bold);
124
+ line-height: var(--line-height-heading);
125
+
126
+ .n-blockquote-author-info {
127
+ flex-direction: column;
128
+ font-weight: var(--font-weight-regular);
129
+ font-family: var(--font-family-mono);
130
+ }
131
+
132
+ figcaption {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: var(--_gap);
136
+ }
137
+
138
+ figcaption img {
139
+ aspect-ratio: 1 / 1;
140
+ object-fit: cover;
141
+ width: 3.125rem;
142
+ height: auto;
143
+ clip-path: circle();
144
+ }
145
+
146
+ .n-blockquote-author-info {
147
+ font-size: var(--font-size-text-sm);
148
+ font-weight: var(--font-weight-regular);
149
+ text-transform: uppercase;
150
+ }
151
+
152
+ .n-blockquote-title {
153
+ color: var(--color-text-weak);
154
+ }
155
+ }
103
156
  </style>
@@ -20,6 +20,7 @@ const { id, class: className, ...rest } = Astro.props;
20
20
  --_border-color-inactive: var(--color-bg-3);
21
21
  --_border-color-active: var(--color-icon);
22
22
  display: block;
23
+ isolation: isolate;
23
24
  }
24
25
 
25
26
  .n-tabbed-content :global([role='tablist']) {
@@ -48,6 +49,7 @@ const { id, class: className, ...rest } = Astro.props;
48
49
  color: var(--body-text-color);
49
50
  background: var(--tabbed-content-bg-color);
50
51
  border: var(--_border-size) var(--_border-style) var(--_border-color-inactive);
52
+ border-radius: 0;
51
53
  border-block-end: unset;
52
54
 
53
55
  > :is(svg, img) {
@@ -56,10 +58,6 @@ const { id, class: className, ...rest } = Astro.props;
56
58
  height: auto;
57
59
  }
58
60
 
59
- + [role='tab'] {
60
- margin-inline-start: -1px;
61
- }
62
-
63
61
  &[aria-selected='true'] {
64
62
  --_radius: var(--radius-s);
65
63
  color: var(--tabbed-content-tab-text-color-active);
@@ -70,4 +68,8 @@ const { id, class: className, ...rest } = Astro.props;
70
68
  z-index: 1;
71
69
  }
72
70
  }
71
+
72
+ .n-tabbed-content :global([role='tab'] + [role='tab']) {
73
+ margin-inline-start: -1px;
74
+ }
73
75
  </style>
@@ -0,0 +1,110 @@
1
+ ---
2
+ import { fetchSvg } from '../utils/fetch-svg.ts';
3
+ import { kebabCase } from '../utils/kebab';
4
+ import Blockquote from './Blockquote.astro';
5
+ import Button from './Button.astro';
6
+ import { Testimonial } from '../utils/types';
7
+
8
+ import Tabs from '@netlify/spark-ui/components/Tabs.astro';
9
+ import Tab from '@netlify/spark-ui/components/Tab.astro';
10
+ import TabList from '@netlify/spark-ui/components/TabList.astro';
11
+ import TabPanel from '@netlify/spark-ui/components/TabPanel.astro';
12
+
13
+ interface Props {
14
+ items?: Testimonial[];
15
+ }
16
+
17
+ const { items } = Astro.props as Props;
18
+ ---
19
+
20
+ <Tabs class="n-testimonial-slider">
21
+ <TabList class="n-testimonial-logos n-cluster">
22
+ {
23
+ items?.map(({ company, logoSrc }, index) => (
24
+ <Tab
25
+ class="n-logo-btn"
26
+ data-id={company && kebabCase(company)}
27
+ isSelected={index === 0}
28
+ aria-label={`Show testimonial from ${company}`}
29
+ >
30
+ {logoSrc && <slot set:html={fetchSvg(logoSrc)} />}
31
+ </Tab>
32
+ ))
33
+ }
34
+ </TabList>
35
+ {
36
+ items?.map((testimonial, index) => (
37
+ <TabPanel>
38
+ <div class="n-testimonial-quote-body n-flow">
39
+ <Blockquote
40
+ variant="testimonial"
41
+ aria-hidden={index !== 0}
42
+ class={`n-testimonial-quote n-fadeInUp ${index === 0 ? 'is-active' : ''}`}
43
+ quote={testimonial.quote}
44
+ author={testimonial.author}
45
+ title={testimonial.title}
46
+ avatarSrc={testimonial.avatarSrc}
47
+ />
48
+ {testimonial.cta && (
49
+ <Button
50
+ id={`cta-${testimonial.cta.id}`}
51
+ href={testimonial.cta.url}
52
+ class="n-testimonial-cta n-fadeIn"
53
+ >
54
+ {testimonial.cta.label}
55
+ </Button>
56
+ )}
57
+ </div>
58
+ </TabPanel>
59
+ ))
60
+ }
61
+ </Tabs>
62
+
63
+ <style>
64
+ @import url('../styles/animations.css');
65
+
66
+ .n-testimonial-slider {
67
+ --_spacing: var(--space-xl);
68
+ }
69
+
70
+ .n-testimonial-quote-body {
71
+ --n-flow-space: var(--_spacing);
72
+ }
73
+
74
+ .n-testimonial-logos {
75
+ --n-cluster-column-gap: var(--space-l);
76
+ }
77
+
78
+ [role='tablist'] {
79
+ margin-block-end: var(--_spacing);
80
+ }
81
+
82
+ .n-testimonial-cta {
83
+ width: fit-content;
84
+ animation-delay: 200ms;
85
+ }
86
+
87
+ .n-logo-btn {
88
+ background: none;
89
+ border: none;
90
+ cursor: pointer;
91
+ display: flex;
92
+ place-items: center;
93
+ outline-offset: 0.5rem;
94
+ }
95
+
96
+ .n-logo-btn :global(svg) {
97
+ width: 6.25rem;
98
+ height: auto;
99
+ fill: var(--testimonial-logo-inactive-color);
100
+ transition: fill 300ms ease-out;
101
+ }
102
+
103
+ .n-logo-btn:hover :global(svg) {
104
+ fill: var(--testimonial-logo-hover-color);
105
+ }
106
+
107
+ .n-logo-btn[aria-selected='true'] :global(svg) {
108
+ fill: var(--blockquote-accent-color);
109
+ }
110
+ </style>
@@ -1,3 +1,5 @@
1
+ @import url('../../../styles/animations.css');
2
+
1
3
  /* ----------------------------- */
2
4
  /* Layout Containers */
3
5
  /* ----------------------------- */
@@ -242,21 +244,6 @@
242
244
  /* ----------------------------- */
243
245
  /* Animations and Keyframes */
244
246
  /* ----------------------------- */
245
- @keyframes fadeIn {
246
- 0% {
247
- opacity: 0;
248
- }
249
- 100% {
250
- opacity: 1;
251
- }
252
- }
253
-
254
- @keyframes fadeInUp {
255
- to {
256
- opacity: 1;
257
- transform: translateY(0);
258
- }
259
- }
260
247
 
261
248
  @keyframes askEyeBlink {
262
249
  0%,
@@ -0,0 +1,26 @@
1
+ .n-fadeIn {
2
+ opacity: 0;
3
+ animation: fadeIn var(--animation-duration, 300ms) ease-out forwards;
4
+ }
5
+
6
+ .n-fadeInUp {
7
+ opacity: 0;
8
+ transform: translateY(var(--animation-transformY, 5px));
9
+ animation: fadeInUp var(--animation-duration, 300ms) ease-out forwards;
10
+ }
11
+
12
+ @keyframes fadeIn {
13
+ 0% {
14
+ opacity: 0;
15
+ }
16
+ 100% {
17
+ opacity: 1;
18
+ }
19
+ }
20
+
21
+ @keyframes fadeInUp {
22
+ to {
23
+ opacity: 1;
24
+ transform: translateY(0);
25
+ }
26
+ }
@@ -7,6 +7,7 @@
7
7
  --badge-bg-color: var(--neutral-100);
8
8
  --badge-border-color: var(--neutral-300);
9
9
  --badge-text-color: var(--neutral-500);
10
+ --blockquote-accent-color: var(--blue-700);
10
11
  --blue-000: color(srgb 0.9294117647058824 0.9568627450980393 1);
11
12
  --blue-100: color(srgb 0.803921568627451 0.8862745098039215 1);
12
13
  --blue-200: color(srgb 0.7098039215686275 0.8235294117647058 0.984313725490196);
@@ -196,6 +197,8 @@
196
197
  --teal-700: color(srgb 0.00392156862745098 0.4117647058823529 0.40784313725490196);
197
198
  --teal-800: color(srgb 0.00392156862745098 0.2823529411764706 0.2784313725490196);
198
199
  --teal-900: color(srgb 0.047058823529411764 0.16470588235294117 0.16470588235294117);
200
+ --testimonial-logo-hover-color: var(--neutral-500);
201
+ --testimonial-logo-inactive-color: var(--neutral-400);
199
202
  --title-text-color: color(srgb 0.32941176470588235 0.35294117647058826 0.3803921568627451 / 0.7490196078431373);
200
203
  }
201
204
 
@@ -205,6 +208,7 @@
205
208
  --badge-bg-color: var(--neutral-700);
206
209
  --badge-border-color: var(--neutral-600);
207
210
  --badge-text-color: var(--neutral-100);
211
+ --blockquote-accent-color: var(--teal-200);
208
212
  --button-outline-bg-color: color(srgb 0 0 0 / 0);
209
213
  --button-outline-bg-color-hover: color(srgb 0.6509803921568628 1 0.9921568627450981 / 0.09803921568627451);
210
214
  --button-outline-stroke-color: var(--teal-200);
@@ -277,6 +281,8 @@
277
281
  --tabbed-content-border-color: var(--color-icon);
278
282
  --tabbed-content-code-bg-color: var(--neutral-900);
279
283
  --tabbed-content-tab-text-color-active: var(--color-icon);
284
+ --testimonial-logo-hover-color: var(--neutral-300);
285
+ --testimonial-logo-inactive-color: var(--neutral-400);
280
286
  --title-text-color: color(srgb 0.8352941176470589 1 0.996078431372549 / 0.7490196078431373);
281
287
  }
282
288
  }
@@ -286,6 +292,7 @@
286
292
  --badge-bg-color: var(--neutral-700);
287
293
  --badge-border-color: var(--neutral-600);
288
294
  --badge-text-color: var(--neutral-100);
295
+ --blockquote-accent-color: var(--teal-200);
289
296
  --button-outline-bg-color: color(srgb 0 0 0 / 0);
290
297
  --button-outline-bg-color-hover: color(srgb 0.6509803921568628 1 0.9921568627450981 / 0.09803921568627451);
291
298
  --button-outline-stroke-color: var(--teal-200);
@@ -358,6 +365,8 @@
358
365
  --tabbed-content-border-color: var(--color-icon);
359
366
  --tabbed-content-code-bg-color: var(--neutral-900);
360
367
  --tabbed-content-tab-text-color-active: var(--color-icon);
368
+ --testimonial-logo-hover-color: var(--neutral-300);
369
+ --testimonial-logo-inactive-color: var(--neutral-400);
361
370
  --title-text-color: color(srgb 0.8352941176470589 1 0.996078431372549 / 0.7490196078431373);
362
371
  }
363
372
 
@@ -366,6 +375,7 @@
366
375
  --badge-bg-color: var(--neutral-100);
367
376
  --badge-border-color: var(--neutral-300);
368
377
  --badge-text-color: var(--neutral-500);
378
+ --blockquote-accent-color: var(--blue-700);
369
379
  --button-outline-bg-color: color(srgb 0 0 0 / 0);
370
380
  --button-outline-bg-color-hover: color(srgb 0.19607843137254902 0.9019607843137255 0.8862745098039215 / 0.2);
371
381
  --button-outline-stroke-color: var(--neutral-800);
@@ -437,6 +447,8 @@
437
447
  --tabbed-content-border-color: var(--color-icon);
438
448
  --tabbed-content-code-bg-color: var(--neutral-900);
439
449
  --tabbed-content-tab-text-color-active: var(--color-icon);
450
+ --testimonial-logo-hover-color: var(--neutral-500);
451
+ --testimonial-logo-inactive-color: var(--neutral-400);
440
452
  --title-text-color: color(srgb 0.32941176470588235 0.35294117647058826 0.3803921568627451 / 0.7490196078431373);
441
453
  }
442
454
 
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Fetches an SVG from a remote URL and strips any `fill` attributes
3
+ * to allow styling the SVG via CSS (e.g., using `currentColor` or masks).
4
+ *
5
+ * If the fetch fails or the response is not OK, logs the error and returns an empty string.
6
+ *
7
+ * @param {string} url - The URL of the SVG to fetch.
8
+ * @returns {Promise<string>} The cleaned SVG markup with `fill` attributes removed,
9
+ * or an empty string if the fetch fails.
10
+ */
11
+ export async function fetchSvg(url: string): Promise<string> {
12
+ function stripFillAttributes(svg: string): string {
13
+ return svg.replace(/fill\s*=\s*["'][^"']*["']/gi, '');
14
+ }
15
+
16
+ try {
17
+ const res = await fetch(url);
18
+ if (!res.ok) {
19
+ console.error(`[fetchSvg] Failed to fetch: ${url}`);
20
+ return '';
21
+ }
22
+ const raw = await res.text();
23
+ return stripFillAttributes(raw);
24
+ } catch (err) {
25
+ console.error(`[fetchSvg] Error fetching ${url}:`, err);
26
+ return '';
27
+ }
28
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Converts a given string to kebab-case.
3
+ *
4
+ * - Converts camelCase to kebab-case.
5
+ * - Replaces spaces and underscores with dashes.
6
+ * - Removes all non-alphanumeric characters except dashes.
7
+ * - Converts the entire string to lowercase.
8
+ *
9
+ * @param {string} str - The input string to convert.
10
+ * @returns {string} The kebab-cased version of the input string.
11
+ *
12
+ * @example
13
+ * kebabCase('HelloWorld') // 'hello-world'
14
+ * kebabCase('some_text Here') // 'some-text-here'
15
+ * kebabCase('already-kebab-case') // 'already-kebab-case'
16
+ */
17
+ export const kebabCase = (str: string) =>
18
+ str
19
+ .replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase → kebab-case
20
+ .replace(/[\s_]+/g, '-') // spaces & underscores → dashes
21
+ .replace(/[^a-zA-Z0-9-]/g, '') // remove non-alphanumeric except dash
22
+ .toLowerCase();
@@ -14,3 +14,17 @@ export interface ImageProps {
14
14
  width: string;
15
15
  height: string;
16
16
  }
17
+
18
+ export interface Testimonial {
19
+ quote?: string;
20
+ author?: string;
21
+ title?: string;
22
+ company?: string;
23
+ logoSrc?: string;
24
+ avatarSrc?: string;
25
+ cta?: {
26
+ id?: string;
27
+ url?: string;
28
+ label?: string;
29
+ };
30
+ }
File without changes