@hyvor/design 2.1.15 → 2.1.17

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}>
@@ -126,11 +126,7 @@
126
126
  </Modal>
127
127
  </div>
128
128
 
129
- <style>.image-uploader :global(.wrap) {
130
- z-index: 1000 !important;
131
- }
132
-
133
- .image-uploader :global(.inner) {
129
+ <style>.image-uploader :global(.inner) {
134
130
  height: 100%;
135
131
  width: 1100px !important;
136
132
  display: flex;
@@ -38,36 +38,14 @@
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([]);
44
+ let currentTime: number[] = $state(reviews.map(() => 0));
45
+ let duration: number[] = $state(reviews.map(() => 0));
46
+
47
+ let rowEl: HTMLDivElement | undefined = $state();
48
+ let activeIndex = $state(0);
71
49
 
72
50
  function play(i: number) {
73
51
  started[i] = true;
@@ -83,23 +61,74 @@
83
61
  playing[i] = false;
84
62
  }
85
63
 
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);
64
+ function onCardClick(e: MouseEvent, i: number) {
65
+ if ((e.target as HTMLElement).closest('a')) return;
66
+ if (!started[i]) {
67
+ play(i);
68
+ return;
69
+ }
70
+ const video = videoEls[i];
71
+ if (!video) return;
72
+ if (playing[i]) {
73
+ video.pause();
74
+ } else {
75
+ video.play().catch(() => {});
76
+ }
77
+ }
78
+
79
+ function onCardKeydown(e: KeyboardEvent, i: number) {
80
+ if (e.key !== 'Enter' && e.key !== ' ') return;
81
+ e.preventDefault();
82
+ onCardClick(e as unknown as MouseEvent, i);
94
83
  }
95
84
 
96
- function onPointerMove(e: PointerEvent) {
97
- if (!dragging || !scrollEl) return;
98
- scrollEl.scrollLeft = dragStartScroll - (e.clientX - dragStartX);
85
+ function seekFromEvent(e: MouseEvent, i: number) {
86
+ const video = videoEls[i];
87
+ if (!video || !duration[i]) return;
88
+ const track = e.currentTarget as HTMLElement;
89
+ const rect = track.getBoundingClientRect();
90
+ const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
91
+ video.currentTime = ratio * duration[i];
99
92
  }
100
93
 
101
- function onPointerUp() {
102
- dragging = false;
94
+ function onSeekClick(e: MouseEvent, i: number) {
95
+ e.stopPropagation();
96
+ seekFromEvent(e, i);
97
+ }
98
+
99
+ function onSeekKeydown(e: KeyboardEvent, i: number) {
100
+ const video = videoEls[i];
101
+ if (!video) return;
102
+ if (e.key === 'ArrowRight') {
103
+ e.preventDefault();
104
+ video.currentTime = Math.min(duration[i], video.currentTime + 5);
105
+ } else if (e.key === 'ArrowLeft') {
106
+ e.preventDefault();
107
+ video.currentTime = Math.max(0, video.currentTime - 5);
108
+ }
109
+ }
110
+
111
+ function goTo(i: number) {
112
+ if (!rowEl) return;
113
+ const card = rowEl.children[i] as HTMLElement | undefined;
114
+ card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
115
+ activeIndex = i;
116
+ }
117
+
118
+ function onRowScroll() {
119
+ if (!rowEl) return;
120
+ const rowCenter = rowEl.getBoundingClientRect().left + rowEl.clientWidth / 2;
121
+ let closest = 0;
122
+ let closestDist = Infinity;
123
+ Array.from(rowEl.children).forEach((child, i) => {
124
+ const rect = (child as HTMLElement).getBoundingClientRect();
125
+ const dist = Math.abs(rect.left + rect.width / 2 - rowCenter);
126
+ if (dist < closestDist) {
127
+ closestDist = dist;
128
+ closest = i;
129
+ }
130
+ });
131
+ activeIndex = closest;
103
132
  }
104
133
  </script>
105
134
 
@@ -133,110 +162,140 @@
133
162
  <h2>{@html title}</h2>
134
163
  </div>
135
164
 
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}
165
+ <div class="hds-container-max cards-wrap">
166
+ <div
167
+ class="cards-row"
168
+ bind:this={rowEl}
169
+ onscroll={onRowScroll}
170
+ role="region"
171
+ aria-label={label}
172
+ >
173
+ {#each reviews as review, i}
174
+ {#if review.type === 'text'}
175
+ <figure class="card text-card hds-box">
176
+ {#if review.imageUrl}
177
+ <img class="avatar photo card-avatar" src={review.imageUrl} alt={review.name} />
178
+ {/if}
179
+ <svg class="quote-mark" viewBox="0 0 24 24" aria-hidden="true">
180
+ <path
181
+ 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"
182
+ />
164
183
  </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
184
  {#if review.summary}
208
- <p class="video-summary">“{review.summary}”</p>
185
+ <p class="text-summary">{review.summary}</p>
209
186
  {/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
-
187
+ <blockquote>&ldquo;{review.quote}&rdquo;</blockquote>
227
188
  <figcaption>
228
- {#if review.imageUrl}
229
- <img class="avatar photo" src={review.imageUrl} alt={review.name} />
230
- {/if}
231
189
  <span class="caption-text">
232
190
  <span class="name">{review.name}</span>
233
191
  {@render meta(review)}
234
192
  </span>
235
193
  </figcaption>
236
- {/if}
237
- </figure>
238
- {/if}
239
- {/each}
194
+ </figure>
195
+ {:else}
196
+ <!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
197
+ <figure
198
+ class="card video-card hds-box"
199
+ class:playing={playing[i]}
200
+ role="button"
201
+ tabindex="0"
202
+ aria-label={playing[i]
203
+ ? 'Pause video testimonial'
204
+ : started[i]
205
+ ? 'Resume video testimonial'
206
+ : 'Play video testimonial'}
207
+ onclick={(e) => onCardClick(e, i)}
208
+ onkeydown={(e) => onCardKeydown(e, i)}
209
+ >
210
+ {#if started[i]}
211
+ <!-- svelte-ignore a11y_media_has_caption -->
212
+ <video
213
+ class="video"
214
+ src={review.videoUrl}
215
+ poster={review.posterUrl}
216
+ playsinline
217
+ bind:this={videoEls[i]}
218
+ bind:currentTime={currentTime[i]}
219
+ bind:duration={duration[i]}
220
+ use:autoplayVideo
221
+ onplay={() => (playing[i] = true)}
222
+ onpause={(e) => onVideoPause(e, i)}
223
+ onended={() => (playing[i] = false)}
224
+ ></video>
225
+ {:else}
226
+ <div class="poster">
227
+ <img src={review.posterUrl} alt="" />
228
+ </div>
229
+ {/if}
230
+
231
+ {#if started[i]}
232
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
233
+ <div
234
+ class="progress-track"
235
+ role="slider"
236
+ aria-label="Seek video"
237
+ aria-valuemin={0}
238
+ aria-valuemax={100}
239
+ aria-valuenow={duration[i] ? Math.round((currentTime[i] / duration[i]) * 100) : 0}
240
+ tabindex="0"
241
+ onclick={(e) => onSeekClick(e, i)}
242
+ onkeydown={(e) => onSeekKeydown(e, i)}
243
+ >
244
+ <div
245
+ class="progress-fill"
246
+ style="width: {duration[i] ? (currentTime[i] / duration[i]) * 100 : 0}%"
247
+ ></div>
248
+ </div>
249
+ {/if}
250
+
251
+ {#if !playing[i]}
252
+ <div class="poster-scrim"></div>
253
+
254
+ {#if review.summary}
255
+ <p class="video-summary">“{review.summary}”</p>
256
+ {/if}
257
+
258
+ <div class="play-btn" aria-hidden="true">
259
+ <svg
260
+ width="20"
261
+ height="20"
262
+ viewBox="0 0 16 16"
263
+ fill="currentColor"
264
+ aria-hidden="true"
265
+ >
266
+ <path d="M5 3.5v9l8-4.5-8-4.5z" />
267
+ </svg>
268
+ </div>
269
+
270
+ <figcaption>
271
+ {#if review.imageUrl}
272
+ <img class="avatar photo" src={review.imageUrl} alt={review.name} />
273
+ {/if}
274
+ <span class="caption-text">
275
+ <span class="name">{review.name}</span>
276
+ {@render meta(review)}
277
+ </span>
278
+ </figcaption>
279
+ {/if}
280
+ </figure>
281
+ {/if}
282
+ {/each}
283
+ </div>
284
+
285
+ {#if reviews.length > 1}
286
+ <div class="dots" role="tablist" aria-label="Testimonials navigation">
287
+ {#each reviews as _, i}
288
+ <button
289
+ class="dot"
290
+ class:active={i === activeIndex}
291
+ role="tab"
292
+ aria-selected={i === activeIndex}
293
+ aria-label={`Go to testimonial ${i + 1}`}
294
+ onclick={() => goTo(i)}
295
+ ></button>
296
+ {/each}
297
+ </div>
298
+ {/if}
240
299
  </div>
241
300
  </section>
242
301
 
@@ -245,6 +304,7 @@
245
304
  background: var(--accent-lightest);
246
305
  padding: 100px 0 96px;
247
306
  overflow: hidden;
307
+ scroll-margin-top: calc(var(--header-height, 55px) + 20px);
248
308
  }
249
309
 
250
310
  .head {
@@ -270,31 +330,17 @@
270
330
  font-family: var(--font-serif);
271
331
  }
272
332
 
273
- .scroll-row {
274
- --side-padding: max(15px, calc((100vw - 1000px) / 2));
333
+ .cards-wrap {
334
+ padding: 32px 15px 4px;
335
+ box-sizing: border-box;
336
+ }
275
337
 
338
+ .cards-row {
276
339
  display: flex;
340
+ flex-wrap: wrap;
277
341
  align-items: stretch;
342
+ justify-content: center;
278
343
  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
344
  }
299
345
 
300
346
  .card {
@@ -310,6 +356,8 @@
310
356
  }
311
357
 
312
358
  .text-card {
359
+ height: auto;
360
+ min-height: 440px;
313
361
  padding: 28px;
314
362
  display: flex;
315
363
  flex-direction: column;
@@ -417,15 +465,27 @@
417
465
  }
418
466
 
419
467
  .video-card {
420
- width: 280px;
421
468
  overflow: hidden;
422
469
  padding: 0;
470
+ cursor: pointer;
471
+ transition:
472
+ width 0.35s cubic-bezier(0.16, 1, 0.3, 1),
473
+ height 0.35s cubic-bezier(0.16, 1, 0.3, 1),
474
+ transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
475
+ box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1);
476
+ }
477
+
478
+ .video-card:focus-visible {
479
+ outline: 2px solid var(--accent);
480
+ outline-offset: 2px;
423
481
  }
424
482
 
425
483
  .video-card.playing {
426
- width: 340px;
427
- height: 520px;
428
- z-index: 1;
484
+ z-index: 5;
485
+ transform: scale(1.08);
486
+ box-shadow:
487
+ 0 30px 60px -15px rgba(20, 14, 12, 0.4),
488
+ 0 12px 24px -12px rgba(20, 14, 12, 0.3);
429
489
  }
430
490
 
431
491
  .poster {
@@ -500,6 +560,32 @@
500
560
  background: #000;
501
561
  }
502
562
 
563
+ .progress-track {
564
+ position: absolute;
565
+ left: 0;
566
+ right: 0;
567
+ bottom: 0;
568
+ z-index: 4;
569
+ height: 4px;
570
+ background: rgba(255, 255, 255, 0.25);
571
+ cursor: pointer;
572
+ transition: height 0.15s;
573
+ }
574
+
575
+ .progress-track:hover,
576
+ .progress-track:focus-visible {
577
+ height: 7px;
578
+ }
579
+
580
+ .progress-track:focus-visible {
581
+ outline: none;
582
+ }
583
+
584
+ .progress-fill {
585
+ height: 100%;
586
+ background: #fff;
587
+ }
588
+
503
589
  .video-card figcaption {
504
590
  position: absolute;
505
591
  left: 0;
@@ -528,11 +614,26 @@
528
614
  color: rgba(255, 255, 255, 0.9);
529
615
  }
530
616
 
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
- }
617
+ .dots {
618
+ display: none;
619
+ }
620
+
621
+ .dot {
622
+ width: 8px;
623
+ height: 8px;
624
+ padding: 0;
625
+ border: none;
626
+ border-radius: 50%;
627
+ background: var(--border);
628
+ cursor: pointer;
629
+ transition:
630
+ background 0.2s,
631
+ transform 0.2s;
632
+ }
633
+
634
+ .dot.active {
635
+ background: var(--accent);
636
+ transform: scale(1.3);
536
637
  }
537
638
 
538
639
  @media (max-width: 768px) {
@@ -546,13 +647,44 @@
546
647
  height: 400px;
547
648
  }
548
649
 
549
- .video-card {
550
- width: 240px;
650
+ .text-card {
651
+ height: auto;
652
+ min-height: 400px;
653
+ }
654
+ }
655
+
656
+ @media (max-width: 640px) {
657
+ .cards-wrap {
658
+ padding: 24px 0 4px;
551
659
  }
552
660
 
553
- .video-card.playing {
554
- width: 280px;
555
- height: 460px;
661
+ .cards-row {
662
+ flex-wrap: nowrap;
663
+ justify-content: flex-start;
664
+ overflow-x: auto;
665
+ scroll-snap-type: x mandatory;
666
+ -webkit-overflow-scrolling: touch;
667
+ scrollbar-width: none;
668
+ padding: 0 24px;
669
+ }
670
+
671
+ .cards-row::-webkit-scrollbar {
672
+ display: none;
673
+ }
674
+
675
+ .card {
676
+ flex: 0 0 calc(100% - 32px);
677
+ width: calc(100% - 32px);
678
+ max-width: 360px;
679
+ scroll-snap-align: center;
680
+ }
681
+
682
+ .dots {
683
+ display: flex;
684
+ justify-content: center;
685
+ align-items: center;
686
+ gap: 8px;
687
+ margin-top: 20px;
556
688
  }
557
689
  }
558
690
  </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.17",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "repository": {