@hyvor/design 2.1.15 → 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.
@@ -9,7 +9,8 @@
9
9
 
10
10
  let { label, content, children }: Props = $props();
11
11
 
12
- const labelId = `detail-card-${Math.random().toString(36).substring(2, 15)}`;
12
+ const uid = $props.id();
13
+ const labelId = `detail-card-${uid}`;
13
14
  </script>
14
15
 
15
16
  <div class="detail-card" role="region" aria-labelledby={labelId}>
@@ -38,37 +38,13 @@
38
38
  handwrittenNames = true
39
39
  }: Props = $props();
40
40
 
41
- function identicon(seed: string) {
42
- let hash = 0;
43
- for (let i = 0; i < seed.length; i++) {
44
- hash = (hash * 31 + seed.charCodeAt(i)) | 0;
45
- }
46
- const hue = Math.abs(hash) % 360;
47
- const cells: { x: number; y: number }[] = [];
48
- for (let col = 0; col < 3; col++) {
49
- for (let row = 0; row < 5; row++) {
50
- const bit = (hash >> (col * 5 + row)) & 1;
51
- if (!bit) continue;
52
- cells.push({ x: col, y: row });
53
- if (col < 2) cells.push({ x: 4 - col, y: row });
54
- }
55
- }
56
- return {
57
- bg: `hsl(${hue} 45% 92%)`,
58
- fg: `hsl(${hue} 50% 40%)`,
59
- cells
60
- };
61
- }
62
-
63
- let scrollEl: HTMLDivElement | undefined = $state();
64
- let dragging = $state(false);
65
- let dragStartX = 0;
66
- let dragStartScroll = 0;
67
-
68
41
  let started: boolean[] = $state(reviews.map(() => false));
69
42
  let playing: boolean[] = $state(reviews.map(() => false));
70
43
  let videoEls: (HTMLVideoElement | undefined)[] = $state([]);
71
44
 
45
+ let rowEl: HTMLDivElement | undefined = $state();
46
+ let activeIndex = $state(0);
47
+
72
48
  function play(i: number) {
73
49
  started[i] = true;
74
50
  videoEls[i]?.play().catch(() => {});
@@ -83,23 +59,48 @@
83
59
  playing[i] = false;
84
60
  }
85
61
 
86
- function onPointerDown(e: PointerEvent) {
87
- if (!scrollEl) return;
88
- // let clicks and native <video> controls work
89
- if ((e.target as HTMLElement).closest('button, video, a')) return;
90
- dragging = true;
91
- dragStartX = e.clientX;
92
- dragStartScroll = scrollEl.scrollLeft;
93
- 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
+ }
94
75
  }
95
76
 
96
- function onPointerMove(e: PointerEvent) {
97
- if (!dragging || !scrollEl) return;
98
- scrollEl.scrollLeft = dragStartScroll - (e.clientX - dragStartX);
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);
99
81
  }
100
82
 
101
- function onPointerUp() {
102
- dragging = false;
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;
88
+ }
89
+
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;
103
104
  }
104
105
  </script>
105
106
 
@@ -133,110 +134,118 @@
133
134
  <h2>{@html title}</h2>
134
135
  </div>
135
136
 
136
- <!-- svelte-ignore a11y_no_static_element_interactions -->
137
- <div
138
- class="scroll-row"
139
- class:dragging
140
- bind:this={scrollEl}
141
- onpointerdown={onPointerDown}
142
- onpointermove={onPointerMove}
143
- onpointerup={onPointerUp}
144
- onpointerleave={onPointerUp}
145
- role="region"
146
- aria-label={label}
147
- >
148
- {#each reviews as review, i}
149
- {#if review.type === 'text'}
150
- {@const av = identicon(review.name)}
151
- <figure class="card text-card hds-box">
152
- {#if review.imageUrl}
153
- <img class="avatar photo card-avatar" src={review.imageUrl} alt={review.name} />
154
- {:else}
155
- <svg
156
- class="avatar card-avatar"
157
- viewBox="0 0 5 5"
158
- style="background: {av.bg}"
159
- aria-hidden="true"
160
- >
161
- {#each av.cells as cell}
162
- <rect x={cell.x} y={cell.y} width="1" height="1" fill={av.fg} />
163
- {/each}
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} />
150
+ {/if}
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
+ />
164
155
  </svg>
165
- {/if}
166
- <svg class="quote-mark" viewBox="0 0 24 24" aria-hidden="true">
167
- <path
168
- 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"
169
- />
170
- </svg>
171
- {#if review.summary}
172
- <p class="text-summary">{review.summary}</p>
173
- {/if}
174
- <blockquote>&ldquo;{review.quote}&rdquo;</blockquote>
175
- <figcaption>
176
- <span class="caption-text">
177
- <span class="name">{review.name}</span>
178
- {@render meta(review)}
179
- </span>
180
- </figcaption>
181
- </figure>
182
- {:else}
183
- <figure class="card video-card hds-box" class:playing={playing[i]}>
184
- {#if started[i]}
185
- <!-- svelte-ignore a11y_media_has_caption -->
186
- <video
187
- class="video"
188
- src={review.videoUrl}
189
- poster={review.posterUrl}
190
- controls={playing[i]}
191
- playsinline
192
- bind:this={videoEls[i]}
193
- use:autoplayVideo
194
- onplay={() => (playing[i] = true)}
195
- onpause={(e) => onVideoPause(e, i)}
196
- onended={() => (playing[i] = false)}
197
- ></video>
198
- {:else}
199
- <div class="poster">
200
- <img src={review.posterUrl} alt="" />
201
- </div>
202
- {/if}
203
-
204
- {#if !playing[i]}
205
- <div class="poster-scrim"></div>
206
-
207
156
  {#if review.summary}
208
- <p class="video-summary">“{review.summary}”</p>
157
+ <p class="text-summary">{review.summary}</p>
209
158
  {/if}
210
-
211
- <button
212
- class="play-btn"
213
- aria-label={started[i] ? 'Resume video testimonial' : 'Play video testimonial'}
214
- onclick={() => play(i)}
215
- >
216
- <svg
217
- width="20"
218
- height="20"
219
- viewBox="0 0 16 16"
220
- fill="currentColor"
221
- aria-hidden="true"
222
- >
223
- <path d="M5 3.5v9l8-4.5-8-4.5z" />
224
- </svg>
225
- </button>
226
-
159
+ <blockquote>&ldquo;{review.quote}&rdquo;</blockquote>
227
160
  <figcaption>
228
- {#if review.imageUrl}
229
- <img class="avatar photo" src={review.imageUrl} alt={review.name} />
230
- {/if}
231
161
  <span class="caption-text">
232
162
  <span class="name">{review.name}</span>
233
163
  {@render meta(review)}
234
164
  </span>
235
165
  </figcaption>
236
- {/if}
237
- </figure>
238
- {/if}
239
- {/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}
240
249
  </div>
241
250
  </section>
242
251
 
@@ -245,6 +254,7 @@
245
254
  background: var(--accent-lightest);
246
255
  padding: 100px 0 96px;
247
256
  overflow: hidden;
257
+ scroll-margin-top: calc(var(--header-height, 55px) + 20px);
248
258
  }
249
259
 
250
260
  .head {
@@ -270,31 +280,17 @@
270
280
  font-family: var(--font-serif);
271
281
  }
272
282
 
273
- .scroll-row {
274
- --side-padding: max(15px, calc((100vw - 1000px) / 2));
283
+ .cards-wrap {
284
+ padding: 32px 15px 4px;
285
+ box-sizing: border-box;
286
+ }
275
287
 
288
+ .cards-row {
276
289
  display: flex;
290
+ flex-wrap: wrap;
277
291
  align-items: stretch;
292
+ justify-content: center;
278
293
  gap: 20px;
279
- overflow-x: auto;
280
- overflow-y: visible;
281
- scroll-behavior: smooth;
282
- cursor: grab;
283
- padding: 32px var(--side-padding) 36px;
284
- scrollbar-width: none;
285
- -webkit-overflow-scrolling: touch;
286
- touch-action: pan-y;
287
- user-select: none;
288
- -webkit-user-select: none;
289
- }
290
-
291
- .scroll-row.dragging {
292
- cursor: grabbing;
293
- scroll-behavior: auto;
294
- }
295
-
296
- .scroll-row::-webkit-scrollbar {
297
- display: none;
298
294
  }
299
295
 
300
296
  .card {
@@ -310,6 +306,8 @@
310
306
  }
311
307
 
312
308
  .text-card {
309
+ height: auto;
310
+ min-height: 440px;
313
311
  padding: 28px;
314
312
  display: flex;
315
313
  flex-direction: column;
@@ -417,15 +415,14 @@
417
415
  }
418
416
 
419
417
  .video-card {
420
- width: 280px;
421
418
  overflow: hidden;
422
419
  padding: 0;
420
+ cursor: pointer;
423
421
  }
424
422
 
425
- .video-card.playing {
426
- width: 340px;
427
- height: 520px;
428
- z-index: 1;
423
+ .video-card:focus-visible {
424
+ outline: 2px solid var(--accent);
425
+ outline-offset: 2px;
429
426
  }
430
427
 
431
428
  .poster {
@@ -528,11 +525,26 @@
528
525
  color: rgba(255, 255, 255, 0.9);
529
526
  }
530
527
 
531
- @media (max-width: 600px) {
532
- .scroll-row {
533
- --side-padding: max(20px, calc((100vw - 1000px) / 2));
534
- padding: 32px var(--side-padding) 36px;
535
- }
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);
536
548
  }
537
549
 
538
550
  @media (max-width: 768px) {
@@ -546,13 +558,44 @@
546
558
  height: 400px;
547
559
  }
548
560
 
549
- .video-card {
550
- width: 240px;
561
+ .text-card {
562
+ height: auto;
563
+ min-height: 400px;
551
564
  }
565
+ }
552
566
 
553
- .video-card.playing {
554
- width: 280px;
555
- 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;
556
599
  }
557
600
  }
558
601
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyvor/design",
3
- "version": "2.1.15",
3
+ "version": "2.1.16",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "repository": {