@hyvor/design 2.1.14 → 2.1.16

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.
@@ -3,6 +3,10 @@
3
3
  type: 'text';
4
4
  name: string;
5
5
  role: string;
6
+ company?: string;
7
+ companyUrl?: string;
8
+ imageUrl?: string;
9
+ summary?: string;
6
10
  quote: string;
7
11
  }
8
12
 
@@ -10,6 +14,9 @@
10
14
  type: 'video';
11
15
  name: string;
12
16
  role: string;
17
+ company: string;
18
+ companyUrl?: string;
19
+ imageUrl?: string;
13
20
  videoUrl?: string;
14
21
  posterUrl?: string;
15
22
  summary?: string;
@@ -31,37 +38,13 @@
31
38
  handwrittenNames = true
32
39
  }: Props = $props();
33
40
 
34
- function identicon(seed: string) {
35
- let hash = 0;
36
- for (let i = 0; i < seed.length; i++) {
37
- hash = (hash * 31 + seed.charCodeAt(i)) | 0;
38
- }
39
- const hue = Math.abs(hash) % 360;
40
- const cells: { x: number; y: number }[] = [];
41
- for (let col = 0; col < 3; col++) {
42
- for (let row = 0; row < 5; row++) {
43
- const bit = (hash >> (col * 5 + row)) & 1;
44
- if (!bit) continue;
45
- cells.push({ x: col, y: row });
46
- if (col < 2) cells.push({ x: 4 - col, y: row });
47
- }
48
- }
49
- return {
50
- bg: `hsl(${hue} 45% 92%)`,
51
- fg: `hsl(${hue} 50% 40%)`,
52
- cells
53
- };
54
- }
55
-
56
- let scrollEl: HTMLDivElement | undefined = $state();
57
- let dragging = $state(false);
58
- let dragStartX = 0;
59
- let dragStartScroll = 0;
60
-
61
41
  let started: boolean[] = $state(reviews.map(() => false));
62
42
  let playing: boolean[] = $state(reviews.map(() => false));
63
43
  let videoEls: (HTMLVideoElement | undefined)[] = $state([]);
64
44
 
45
+ let rowEl: HTMLDivElement | undefined = $state();
46
+ let activeIndex = $state(0);
47
+
65
48
  function play(i: number) {
66
49
  started[i] = true;
67
50
  videoEls[i]?.play().catch(() => {});
@@ -76,23 +59,48 @@
76
59
  playing[i] = false;
77
60
  }
78
61
 
79
- function onPointerDown(e: PointerEvent) {
80
- if (!scrollEl) return;
81
- // let clicks and native <video> controls work
82
- if ((e.target as HTMLElement).closest('button, video, a')) return;
83
- dragging = true;
84
- dragStartX = e.clientX;
85
- dragStartScroll = scrollEl.scrollLeft;
86
- scrollEl.setPointerCapture(e.pointerId);
62
+ function onCardClick(e: MouseEvent, i: number) {
63
+ if ((e.target as HTMLElement).closest('a')) return;
64
+ if (!started[i]) {
65
+ play(i);
66
+ return;
67
+ }
68
+ const video = videoEls[i];
69
+ if (!video) return;
70
+ if (playing[i]) {
71
+ video.pause();
72
+ } else {
73
+ video.play().catch(() => {});
74
+ }
75
+ }
76
+
77
+ function onCardKeydown(e: KeyboardEvent, i: number) {
78
+ if (e.key !== 'Enter' && e.key !== ' ') return;
79
+ e.preventDefault();
80
+ onCardClick(e as unknown as MouseEvent, i);
87
81
  }
88
82
 
89
- function onPointerMove(e: PointerEvent) {
90
- if (!dragging || !scrollEl) return;
91
- scrollEl.scrollLeft = dragStartScroll - (e.clientX - dragStartX);
83
+ function goTo(i: number) {
84
+ if (!rowEl) return;
85
+ const card = rowEl.children[i] as HTMLElement | undefined;
86
+ card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
87
+ activeIndex = i;
92
88
  }
93
89
 
94
- function onPointerUp() {
95
- dragging = false;
90
+ function onRowScroll() {
91
+ if (!rowEl) return;
92
+ const rowCenter = rowEl.getBoundingClientRect().left + rowEl.clientWidth / 2;
93
+ let closest = 0;
94
+ let closestDist = Infinity;
95
+ Array.from(rowEl.children).forEach((child, i) => {
96
+ const rect = (child as HTMLElement).getBoundingClientRect();
97
+ const dist = Math.abs(rect.left + rect.width / 2 - rowCenter);
98
+ if (dist < closestDist) {
99
+ closestDist = dist;
100
+ closest = i;
101
+ }
102
+ });
103
+ activeIndex = closest;
96
104
  }
97
105
  </script>
98
106
 
@@ -102,92 +110,142 @@
102
110
  {/if}
103
111
  </svelte:head>
104
112
 
113
+ {#snippet meta(review: Review)}
114
+ <div class="role">
115
+ {review.role}, <br />
116
+ {#if review.companyUrl}
117
+ <a
118
+ class="company"
119
+ href={review.companyUrl}
120
+ target="_blank"
121
+ rel="nofollow noopener noreferrer"
122
+ >
123
+ {review.company}
124
+ </a>
125
+ {:else}
126
+ {review.company}
127
+ {/if}
128
+ </div>
129
+ {/snippet}
130
+
105
131
  <section class="hds-testimonials" class:handwritten={handwrittenNames}>
106
132
  <div class="hds-container head">
107
133
  <p class="label">{label}</p>
108
134
  <h2>{@html title}</h2>
109
135
  </div>
110
136
 
111
- <!-- svelte-ignore a11y_no_static_element_interactions -->
112
- <div
113
- class="scroll-row"
114
- class:dragging
115
- bind:this={scrollEl}
116
- onpointerdown={onPointerDown}
117
- onpointermove={onPointerMove}
118
- onpointerup={onPointerUp}
119
- onpointerleave={onPointerUp}
120
- role="region"
121
- aria-label={label}
122
- >
123
- {#each reviews as review, i}
124
- {#if review.type === 'text'}
125
- {@const av = identicon(review.name)}
126
- <figure class="card text-card hds-box">
127
- <svg class="avatar" viewBox="0 0 5 5" style="background: {av.bg}" aria-hidden="true">
128
- {#each av.cells as cell}
129
- <rect x={cell.x} y={cell.y} width="1" height="1" fill={av.fg} />
130
- {/each}
131
- </svg>
132
- <blockquote>&ldquo;{review.quote}&rdquo;</blockquote>
133
- <figcaption>
134
- <span class="name">{review.name}</span>
135
- <span class="role">{review.role}</span>
136
- </figcaption>
137
- </figure>
138
- {:else}
139
- <figure class="card video-card hds-box" class:playing={playing[i]}>
140
- {#if started[i]}
141
- <!-- svelte-ignore a11y_media_has_caption -->
142
- <video
143
- class="video"
144
- src={review.videoUrl}
145
- poster={review.posterUrl}
146
- controls={playing[i]}
147
- playsinline
148
- bind:this={videoEls[i]}
149
- use:autoplayVideo
150
- onplay={() => (playing[i] = true)}
151
- onpause={(e) => onVideoPause(e, i)}
152
- onended={() => (playing[i] = false)}
153
- ></video>
154
- {:else}
155
- <div class="poster">
156
- <img src={review.posterUrl} alt="" />
157
- </div>
158
- {/if}
159
-
160
- {#if !playing[i]}
161
- <div class="poster-scrim"></div>
162
-
163
- {#if !started[i] && review.summary}
164
- <p class="video-summary">"{review.summary}"</p>
137
+ <div class="hds-container-max cards-wrap">
138
+ <div
139
+ class="cards-row"
140
+ bind:this={rowEl}
141
+ onscroll={onRowScroll}
142
+ role="region"
143
+ aria-label={label}
144
+ >
145
+ {#each reviews as review, i}
146
+ {#if review.type === 'text'}
147
+ <figure class="card text-card hds-box">
148
+ {#if review.imageUrl}
149
+ <img class="avatar photo card-avatar" src={review.imageUrl} alt={review.name} />
165
150
  {/if}
166
-
167
- <button
168
- class="play-btn"
169
- aria-label={started[i] ? 'Resume video testimonial' : 'Play video testimonial'}
170
- onclick={() => play(i)}
171
- >
172
- <svg
173
- width="20"
174
- height="20"
175
- viewBox="0 0 16 16"
176
- fill="currentColor"
177
- aria-hidden="true"
178
- >
179
- <path d="M5 3.5v9l8-4.5-8-4.5z" />
180
- </svg>
181
- </button>
182
-
151
+ <svg class="quote-mark" viewBox="0 0 24 24" aria-hidden="true">
152
+ <path
153
+ d="M10 7c-3.3 0-6 2.7-6 6v4h6v-6H7c0-1.7 1.3-3 3-3V7zm10 0c-3.3 0-6 2.7-6 6v4h6v-6h-3c0-1.7 1.3-3 3-3V7z"
154
+ />
155
+ </svg>
156
+ {#if review.summary}
157
+ <p class="text-summary">{review.summary}</p>
158
+ {/if}
159
+ <blockquote>&ldquo;{review.quote}&rdquo;</blockquote>
183
160
  <figcaption>
184
- <span class="name">{review.name}</span>
185
- <span class="role">{review.role}</span>
161
+ <span class="caption-text">
162
+ <span class="name">{review.name}</span>
163
+ {@render meta(review)}
164
+ </span>
186
165
  </figcaption>
187
- {/if}
188
- </figure>
189
- {/if}
190
- {/each}
166
+ </figure>
167
+ {:else}
168
+ <!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
169
+ <figure
170
+ class="card video-card hds-box"
171
+ class:playing={playing[i]}
172
+ role="button"
173
+ tabindex="0"
174
+ aria-label={playing[i]
175
+ ? 'Pause video testimonial'
176
+ : started[i]
177
+ ? 'Resume video testimonial'
178
+ : 'Play video testimonial'}
179
+ onclick={(e) => onCardClick(e, i)}
180
+ onkeydown={(e) => onCardKeydown(e, i)}
181
+ >
182
+ {#if started[i]}
183
+ <!-- svelte-ignore a11y_media_has_caption -->
184
+ <video
185
+ class="video"
186
+ src={review.videoUrl}
187
+ poster={review.posterUrl}
188
+ playsinline
189
+ bind:this={videoEls[i]}
190
+ use:autoplayVideo
191
+ onplay={() => (playing[i] = true)}
192
+ onpause={(e) => onVideoPause(e, i)}
193
+ onended={() => (playing[i] = false)}
194
+ ></video>
195
+ {:else}
196
+ <div class="poster">
197
+ <img src={review.posterUrl} alt="" />
198
+ </div>
199
+ {/if}
200
+
201
+ {#if !playing[i]}
202
+ <div class="poster-scrim"></div>
203
+
204
+ {#if review.summary}
205
+ <p class="video-summary">“{review.summary}”</p>
206
+ {/if}
207
+
208
+ <div class="play-btn" aria-hidden="true">
209
+ <svg
210
+ width="20"
211
+ height="20"
212
+ viewBox="0 0 16 16"
213
+ fill="currentColor"
214
+ aria-hidden="true"
215
+ >
216
+ <path d="M5 3.5v9l8-4.5-8-4.5z" />
217
+ </svg>
218
+ </div>
219
+
220
+ <figcaption>
221
+ {#if review.imageUrl}
222
+ <img class="avatar photo" src={review.imageUrl} alt={review.name} />
223
+ {/if}
224
+ <span class="caption-text">
225
+ <span class="name">{review.name}</span>
226
+ {@render meta(review)}
227
+ </span>
228
+ </figcaption>
229
+ {/if}
230
+ </figure>
231
+ {/if}
232
+ {/each}
233
+ </div>
234
+
235
+ {#if reviews.length > 1}
236
+ <div class="dots" role="tablist" aria-label="Testimonials navigation">
237
+ {#each reviews as _, i}
238
+ <button
239
+ class="dot"
240
+ class:active={i === activeIndex}
241
+ role="tab"
242
+ aria-selected={i === activeIndex}
243
+ aria-label={`Go to testimonial ${i + 1}`}
244
+ onclick={() => goTo(i)}
245
+ ></button>
246
+ {/each}
247
+ </div>
248
+ {/if}
191
249
  </div>
192
250
  </section>
193
251
 
@@ -196,6 +254,7 @@
196
254
  background: var(--accent-lightest);
197
255
  padding: 100px 0 96px;
198
256
  overflow: hidden;
257
+ scroll-margin-top: calc(var(--header-height, 55px) + 20px);
199
258
  }
200
259
 
201
260
  .head {
@@ -221,31 +280,17 @@
221
280
  font-family: var(--font-serif);
222
281
  }
223
282
 
224
- .scroll-row {
225
- --side-padding: max(15px, calc((100vw - 1000px) / 2));
283
+ .cards-wrap {
284
+ padding: 32px 15px 4px;
285
+ box-sizing: border-box;
286
+ }
226
287
 
288
+ .cards-row {
227
289
  display: flex;
290
+ flex-wrap: wrap;
228
291
  align-items: stretch;
292
+ justify-content: center;
229
293
  gap: 20px;
230
- overflow-x: auto;
231
- overflow-y: visible;
232
- scroll-behavior: smooth;
233
- cursor: grab;
234
- padding: 32px var(--side-padding) 36px;
235
- scrollbar-width: none;
236
- -webkit-overflow-scrolling: touch;
237
- touch-action: pan-y;
238
- user-select: none;
239
- -webkit-user-select: none;
240
- }
241
-
242
- .scroll-row.dragging {
243
- cursor: grabbing;
244
- scroll-behavior: auto;
245
- }
246
-
247
- .scroll-row::-webkit-scrollbar {
248
- display: none;
249
294
  }
250
295
 
251
296
  .card {
@@ -261,11 +306,29 @@
261
306
  }
262
307
 
263
308
  .text-card {
309
+ height: auto;
310
+ min-height: 440px;
264
311
  padding: 28px;
265
312
  display: flex;
266
313
  flex-direction: column;
267
314
  }
268
315
 
316
+ .quote-mark {
317
+ width: 40px;
318
+ height: 40px;
319
+ margin-bottom: 20px;
320
+ fill: var(--accent);
321
+ opacity: 0.14;
322
+ flex-shrink: 0;
323
+ }
324
+
325
+ .card-avatar {
326
+ position: absolute;
327
+ top: 28px;
328
+ right: 28px;
329
+ z-index: 1;
330
+ }
331
+
269
332
  .avatar {
270
333
  width: 46px;
271
334
  height: 46px;
@@ -275,48 +338,91 @@
275
338
  shape-rendering: crispEdges;
276
339
  }
277
340
 
341
+ .avatar.photo {
342
+ object-fit: cover;
343
+ }
344
+
345
+ .text-summary {
346
+ margin: 0 0 14px;
347
+ font-family: var(--font-serif);
348
+ font-size: 18px;
349
+ font-weight: 800;
350
+ line-height: 1.25;
351
+ letter-spacing: -0.01em;
352
+ color: var(--text);
353
+ }
354
+
278
355
  blockquote {
279
356
  font-size: 16px;
280
357
  line-height: 1.7;
281
358
  color: var(--text);
282
- margin: 20px 0 0;
359
+ margin: 0 0 20px;
283
360
  flex: 1;
361
+ font-family: var(--font-serif);
284
362
  }
285
363
 
286
364
  .text-card figcaption {
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 12px;
368
+ }
369
+
370
+ .caption-text {
287
371
  display: flex;
288
372
  flex-direction: column;
289
- gap: 0;
373
+ justify-content: center;
374
+ gap: 2px;
375
+ min-width: 0;
290
376
  }
291
377
 
292
378
  .name {
293
379
  font-size: 16px;
294
380
  font-weight: 600;
295
381
  color: var(--text);
296
- line-height: 1.2;
382
+ line-height: 1.3;
297
383
  }
298
384
 
299
385
  .handwritten .name {
300
386
  font-family: 'Caveat', cursive;
301
387
  font-size: 28px;
302
388
  font-weight: 600;
389
+ line-height: 1.15;
303
390
  }
304
391
 
305
392
  .role {
306
- font-size: 13px;
393
+ font-size: 14px;
394
+ line-height: 1.35;
307
395
  color: var(--text-light);
396
+ margin-top: 4px;
397
+ font-family: var(--font-serif);
398
+ }
399
+
400
+ .company {
401
+ align-self: flex-start;
402
+ font-size: 13px;
403
+ line-height: 1.35;
404
+ font-weight: 500;
405
+ color: var(--text);
406
+ text-decoration: none;
407
+ }
408
+
409
+ a.company:hover {
410
+ text-decoration: underline;
411
+ }
412
+
413
+ .handwritten .company {
414
+ font-weight: 600;
308
415
  }
309
416
 
310
417
  .video-card {
311
- width: 280px;
312
418
  overflow: hidden;
313
419
  padding: 0;
420
+ cursor: pointer;
314
421
  }
315
422
 
316
- .video-card.playing {
317
- width: 340px;
318
- height: 520px;
319
- z-index: 1;
423
+ .video-card:focus-visible {
424
+ outline: 2px solid var(--accent);
425
+ outline-offset: 2px;
320
426
  }
321
427
 
322
428
  .poster {
@@ -344,13 +450,12 @@
344
450
  right: 20px;
345
451
  margin: 0;
346
452
  font-family: var(--font-serif);
347
- font-size: 18px;
453
+ font-size: 16px;
348
454
  font-weight: 800;
349
455
  line-height: 1.25;
350
456
  letter-spacing: -0.01em;
351
457
  color: #fff;
352
458
  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
353
- font-style: italic;
354
459
  }
355
460
 
356
461
  .play-btn {
@@ -400,7 +505,12 @@
400
505
  padding: 40px 20px 20px;
401
506
  background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
402
507
  display: flex;
403
- flex-direction: column;
508
+ align-items: center;
509
+ gap: 12px;
510
+ }
511
+
512
+ .video-card .avatar.photo {
513
+ border: 2px solid rgba(255, 255, 255, 0.7);
404
514
  }
405
515
 
406
516
  .video-card .name {
@@ -411,11 +521,30 @@
411
521
  color: rgba(255, 255, 255, 0.75);
412
522
  }
413
523
 
414
- @media (max-width: 600px) {
415
- .scroll-row {
416
- --side-padding: max(20px, calc((100vw - 1000px) / 2));
417
- padding: 32px var(--side-padding) 36px;
418
- }
524
+ .video-card .company {
525
+ color: rgba(255, 255, 255, 0.9);
526
+ }
527
+
528
+ .dots {
529
+ display: none;
530
+ }
531
+
532
+ .dot {
533
+ width: 8px;
534
+ height: 8px;
535
+ padding: 0;
536
+ border: none;
537
+ border-radius: 50%;
538
+ background: var(--border);
539
+ cursor: pointer;
540
+ transition:
541
+ background 0.2s,
542
+ transform 0.2s;
543
+ }
544
+
545
+ .dot.active {
546
+ background: var(--accent);
547
+ transform: scale(1.3);
419
548
  }
420
549
 
421
550
  @media (max-width: 768px) {
@@ -429,13 +558,44 @@
429
558
  height: 400px;
430
559
  }
431
560
 
432
- .video-card {
433
- width: 240px;
561
+ .text-card {
562
+ height: auto;
563
+ min-height: 400px;
434
564
  }
565
+ }
435
566
 
436
- .video-card.playing {
437
- width: 280px;
438
- height: 460px;
567
+ @media (max-width: 640px) {
568
+ .cards-wrap {
569
+ padding: 24px 0 4px;
570
+ }
571
+
572
+ .cards-row {
573
+ flex-wrap: nowrap;
574
+ justify-content: flex-start;
575
+ overflow-x: auto;
576
+ scroll-snap-type: x mandatory;
577
+ -webkit-overflow-scrolling: touch;
578
+ scrollbar-width: none;
579
+ padding: 0 24px;
580
+ }
581
+
582
+ .cards-row::-webkit-scrollbar {
583
+ display: none;
584
+ }
585
+
586
+ .card {
587
+ flex: 0 0 calc(100% - 32px);
588
+ width: calc(100% - 32px);
589
+ max-width: 360px;
590
+ scroll-snap-align: center;
591
+ }
592
+
593
+ .dots {
594
+ display: flex;
595
+ justify-content: center;
596
+ align-items: center;
597
+ gap: 8px;
598
+ margin-top: 20px;
439
599
  }
440
600
  }
441
601
  </style>
@@ -2,12 +2,19 @@ interface TextReview {
2
2
  type: 'text';
3
3
  name: string;
4
4
  role: string;
5
+ company?: string;
6
+ companyUrl?: string;
7
+ imageUrl?: string;
8
+ summary?: string;
5
9
  quote: string;
6
10
  }
7
11
  interface VideoReview {
8
12
  type: 'video';
9
13
  name: string;
10
14
  role: string;
15
+ company: string;
16
+ companyUrl?: string;
17
+ imageUrl?: string;
11
18
  videoUrl?: string;
12
19
  posterUrl?: string;
13
20
  summary?: string;
@@ -1,6 +1,7 @@
1
1
  export { default as Accordion } from './DetailsAccordion/DetailsAccordion.svelte';
2
2
  export { default as Header } from './Header/Header.svelte';
3
3
  export { default as HeaderNavLink } from './Header/HeaderNavLink.svelte';
4
+ export { default as HeaderNavMenu } from './Header/HeaderNavMenu.svelte';
4
5
  export { default as HeaderLanguageToggle } from './Header/HeaderLanguageToggle.svelte';
5
6
  export { buildLocalizedUrl } from './Header/language.js';
6
7
  export type { LanguageOption } from './Header/language.js';
@@ -3,6 +3,7 @@ export { default as Accordion } from './DetailsAccordion/DetailsAccordion.svelte
3
3
  // ## Header
4
4
  export { default as Header } from './Header/Header.svelte';
5
5
  export { default as HeaderNavLink } from './Header/HeaderNavLink.svelte';
6
+ export { default as HeaderNavMenu } from './Header/HeaderNavMenu.svelte';
6
7
  export { default as HeaderLanguageToggle } from './Header/HeaderLanguageToggle.svelte';
7
8
  export { buildLocalizedUrl } from './Header/language.js';
8
9
  // ## Footer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyvor/design",
3
- "version": "2.1.14",
3
+ "version": "2.1.16",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "repository": {