@krizaka/ui 3.19.0 → 3.20.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.
- package/dist/avatar/defaults.js +6 -1
- package/dist/avatar/defaults.js.map +1 -1
- package/dist/avatar.js +6 -1
- package/dist/avatar.js.map +1 -1
- package/dist/chat.js +1 -1
- package/dist/{chunk-276PTQMQ.js → chunk-3BC4XQ6C.js} +2 -1
- package/dist/{chunk-YIBKJ36L.js → chunk-KB7OLYIJ.js} +7 -2
- package/dist/{chunk-YIBKJ36L.js.map → chunk-KB7OLYIJ.js.map} +1 -1
- package/dist/data-table.js +1 -1
- package/dist/file-upload.js +2 -2
- package/dist/file-upload.js.map +1 -1
- package/dist/match-dialog.js +2 -2
- package/dist/media-grid.d.ts +66 -3
- package/dist/media-grid.js +312 -65
- package/dist/media-grid.js.map +1 -1
- package/dist/native.js +6 -1
- package/dist/native.js.map +1 -1
- package/dist/progress.js +2 -2
- package/dist/progress.js.map +1 -1
- package/dist/vertical-video.d.ts +7 -1
- package/dist/vertical-video.js +7 -5
- package/dist/vertical-video.js.map +1 -1
- package/package.json +6 -6
- package/registry/achievement.json +16 -2
- package/registry/action-bar.json +16 -2
- package/registry/action-sheet.json +15 -0
- package/registry/activity-feed.json +13 -2
- package/registry/alert.json +15 -2
- package/registry/app-shell.json +15 -2
- package/registry/argument-card.json +16 -2
- package/registry/audio-player.json +15 -2
- package/registry/auth-layout.json +15 -2
- package/registry/avatar.json +14 -3
- package/registry/badge.json +13 -2
- package/registry/button.json +13 -2
- package/registry/card-grid.json +16 -2
- package/registry/card.json +13 -2
- package/registry/catalogue-page.json +15 -2
- package/registry/changelog-page.json +15 -2
- package/registry/chat.json +15 -4
- package/registry/checkbox.json +13 -2
- package/registry/chip-link.json +15 -2
- package/registry/chip.json +13 -2
- package/registry/choice-grid.json +16 -2
- package/registry/cn.json +12 -0
- package/registry/code-block.json +15 -2
- package/registry/combobox.json +13 -2
- package/registry/command.json +15 -2
- package/registry/component-page.json +15 -2
- package/registry/confirm-button.json +15 -2
- package/registry/contact-page.json +15 -2
- package/registry/continue-prompt.json +16 -2
- package/registry/countdown.json +13 -2
- package/registry/cta-panel.json +16 -2
- package/registry/dashboard-layout.json +15 -2
- package/registry/data-table.json +15 -2
- package/registry/date-picker.json +13 -2
- package/registry/decision-list.json +16 -2
- package/registry/description-list.json +13 -2
- package/registry/detail-layout.json +15 -2
- package/registry/dialog.json +13 -2
- package/registry/docs-article-page.json +15 -2
- package/registry/docs-hub-page.json +15 -2
- package/registry/docs-layout.json +15 -2
- package/registry/document-viewer.json +15 -2
- package/registry/dropdown-menu.json +15 -2
- package/registry/empty-state.json +13 -2
- package/registry/error-page.json +14 -2
- package/registry/examples/chat/conversation.js +2 -2
- package/registry/examples/chat/conversation.tsx +4 -3
- package/registry/examples/glow-card/plans.js +9 -12
- package/registry/examples/glow-card/plans.tsx +18 -14
- package/registry/examples/media-grid/loading.d.ts +4 -0
- package/registry/examples/media-grid/loading.js +28 -0
- package/registry/examples/media-grid/loading.tsx +25 -0
- package/registry/examples/vertical-video/rail.js +3 -2
- package/registry/examples/vertical-video/rail.tsx +4 -3
- package/registry/fab.json +15 -0
- package/registry/field.json +13 -2
- package/registry/file-upload.json +15 -2
- package/registry/filter-bar.json +15 -2
- package/registry/form.json +13 -2
- package/registry/gallery-page.json +15 -2
- package/registry/glow-card.json +18 -4
- package/registry/goal-gauge.json +13 -2
- package/registry/header.json +15 -0
- package/registry/how-it-works-page.json +15 -2
- package/registry/image-cropper.json +15 -2
- package/registry/image-viewer.json +15 -0
- package/registry/index.json +1771 -8
- package/registry/iso.json +15 -2
- package/registry/kanban.json +15 -2
- package/registry/kbd.json +14 -2
- package/registry/landing-page.json +15 -2
- package/registry/landscape.json +15 -2
- package/registry/legal-page.json +15 -2
- package/registry/link-card.json +16 -2
- package/registry/list.json +13 -2
- package/registry/loader.json +13 -2
- package/registry/longform-page.json +15 -2
- package/registry/map.json +14 -3
- package/registry/markdown.json +13 -2
- package/registry/marketing-layout.json +15 -2
- package/registry/marquee.json +15 -2
- package/registry/mascot-quote.json +16 -2
- package/registry/mascot-state.json +13 -2
- package/registry/mascots.json +13 -2
- package/registry/match-dialog.json +13 -2
- package/registry/media-capture.json +13 -2
- package/registry/media-grid.json +341 -13
- package/registry/media-picker.json +15 -0
- package/registry/meter.json +13 -2
- package/registry/nav-bar.json +17 -3
- package/registry/number-ticker.json +13 -2
- package/registry/onboarding.json +15 -0
- package/registry/otp-input.json +15 -0
- package/registry/page-header.json +15 -2
- package/registry/page-hero.json +16 -2
- package/registry/page.json +16 -2
- package/registry/pagination.json +13 -2
- package/registry/permission-prompt.json +15 -0
- package/registry/photo-grid.json +13 -2
- package/registry/pointer-light.json +15 -2
- package/registry/popover.json +15 -2
- package/registry/pricing-page.json +15 -2
- package/registry/product-page.json +15 -2
- package/registry/profile-card.json +13 -2
- package/registry/progress.json +14 -3
- package/registry/promise-card.json +16 -2
- package/registry/pull-to-refresh.json +15 -0
- package/registry/radio-group.json +13 -2
- package/registry/recipes.json +14 -2
- package/registry/screen.json +15 -0
- package/registry/scroll-stage.json +15 -2
- package/registry/search-field.json +13 -2
- package/registry/section-backdrop.json +15 -2
- package/registry/segmented.json +15 -0
- package/registry/separator.json +15 -2
- package/registry/settings-layout.json +15 -2
- package/registry/showcase.json +13 -2
- package/registry/site-footer.json +16 -3
- package/registry/sites.json +15 -2
- package/registry/skeleton.json +13 -2
- package/registry/skip-link.json +14 -2
- package/registry/slider.json +13 -2
- package/registry/slot.json +14 -2
- package/registry/spinner.json +13 -2
- package/registry/splash-handoff.json +15 -0
- package/registry/split-view.json +15 -2
- package/registry/stage-banner.json +15 -2
- package/registry/stat.json +15 -2
- package/registry/status-page.json +15 -2
- package/registry/step-progress.json +13 -2
- package/registry/stepper.json +13 -2
- package/registry/swipe-deck.json +13 -2
- package/registry/swipeable-row.json +15 -0
- package/registry/switch.json +13 -2
- package/registry/tab-bar.json +15 -0
- package/registry/tabs.json +13 -2
- package/registry/tag-input.json +15 -2
- package/registry/tag-selector.json +13 -2
- package/registry/terminal-replay.json +15 -2
- package/registry/theme.json +13 -2
- package/registry/toast.json +13 -2
- package/registry/toc.json +15 -2
- package/registry/tooltip.json +14 -2
- package/registry/tree-view.json +15 -2
- package/registry/txt.json +13 -0
- package/registry/verified-badge.json +13 -2
- package/registry/vertical-video.json +32 -6
- package/registry/video-player.json +13 -2
- package/registry/video-trimmer.json +15 -2
- package/registry/virtual.json +15 -2
- /package/dist/{chunk-276PTQMQ.js.map → chunk-3BC4XQ6C.js.map} +0 -0
package/registry/media-grid.json
CHANGED
|
@@ -2,12 +2,25 @@
|
|
|
2
2
|
"name": "media-grid",
|
|
3
3
|
"type": "primitive",
|
|
4
4
|
"title": "Media grid",
|
|
5
|
-
"summary": "A feed of videos in mixed orientations that never staggers: `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.",
|
|
6
|
-
"description": "A feed of videos in mixed orientations that never staggers: `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.",
|
|
5
|
+
"summary": "A feed of videos in mixed orientations that never staggers, with its loading states (`MediaGridSkeleton`, `VideoCardSkeleton`, `LoadMore`): `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.",
|
|
6
|
+
"description": "A feed of videos in mixed orientations that never staggers, with its loading states (`MediaGridSkeleton`, `VideoCardSkeleton`, `LoadMore`): `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.",
|
|
7
7
|
"status": "beta",
|
|
8
8
|
"deprecated": null,
|
|
9
9
|
"category": "media",
|
|
10
10
|
"platforms": "web",
|
|
11
|
+
"parity": {
|
|
12
|
+
"web": {
|
|
13
|
+
"state": "yes"
|
|
14
|
+
},
|
|
15
|
+
"native": {
|
|
16
|
+
"state": "gap",
|
|
17
|
+
"reason": "orochia-mobile hand-rolls its tiles and stories rail (Tiles, StoriesRail).",
|
|
18
|
+
"issue": "krizaka/krizaka-ui#228"
|
|
19
|
+
},
|
|
20
|
+
"desktop": {
|
|
21
|
+
"state": "yes"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
11
24
|
"whenToUse": [
|
|
12
25
|
"A home, an explore page or a profile's Videos tab where creators publish both vertical and horizontal video.",
|
|
13
26
|
"A gallery whose items come from different sources (`uniform`: everything in one frame, the odd one drawn whole over a blurred copy)."
|
|
@@ -26,7 +39,10 @@
|
|
|
26
39
|
"Leave `mode` on `split` for a feed: a 9:16 card in a 16:9 grid is what makes a row tower over its neighbours.",
|
|
27
40
|
"Give `orientationOf` the source's width and height, not the card's: a clip of the other shape is drawn with `fit=\"contain\"`, never cropped to a face.",
|
|
28
41
|
"Put what is the product's own in the slots (`badges`, `action`); badges sit on `scrim` and read in both themes.",
|
|
29
|
-
"Pass every word translated: the shelf's name, its arrows and “See all”."
|
|
42
|
+
"Pass every word translated: the shelf's name, its arrows and “See all”.",
|
|
43
|
+
"Give `previewSrc` the small animated WebP, never the stream: it is requested only after the pointer rests (`previewDelay`, 200 ms), fades in over the poster and is removed on leave. On a phone only the card most inside the viewport plays it, and none under Save-Data or reduced motion.",
|
|
44
|
+
"Put `loading=\"eager\"` and `posterPriority=\"high\"` on the first row's posters, sized images from the CDN on the others (they are lazy).",
|
|
45
|
+
"While the first page loads, draw `MediaGridSkeleton` (the same shelf and grid, the same card heights); under a long list use `LoadMore`: the next page is asked a screenful before the end, and the button stays for keyboards and failures."
|
|
30
46
|
],
|
|
31
47
|
"accessibility": {
|
|
32
48
|
"keyboard": [
|
|
@@ -41,8 +57,9 @@
|
|
|
41
57
|
],
|
|
42
58
|
"notes": [
|
|
43
59
|
"The shelf is a labelled section whose row is a list; the arrows are named buttons shown with a mouse, hidden on touch (swipe).",
|
|
44
|
-
"The card's whole surface is the title's link (a stretched link): no nested interactive element.",
|
|
45
|
-
"The animated preview plays on hover or focus
|
|
60
|
+
"The card's whole surface is the title's link (a stretched link over the picture and the text): no nested interactive element. The badges and the duration let the click through; the creator and the action stay their own stops above it.",
|
|
61
|
+
"The animated preview plays on hover or focus (after a short rest), or for the one most visible card of a phone; never under Save-Data, and on a phone never under reduced motion. It is decorative (empty `alt`).",
|
|
62
|
+
"`MediaGridSkeleton` is one polite `role=\"status\"` announcement; its cards are hidden from assistive technology. `LoadMore` names its wait through the `Spinner`."
|
|
46
63
|
]
|
|
47
64
|
},
|
|
48
65
|
"related": [
|
|
@@ -52,8 +69,77 @@
|
|
|
52
69
|
],
|
|
53
70
|
"web": {
|
|
54
71
|
"entry": "@krizaka/ui/media-grid",
|
|
55
|
-
"import": "import { MediaGrid, ShortsShelf, VideoCard } from \"@krizaka/ui/media-grid\";",
|
|
72
|
+
"import": "import { MediaGrid, ShortsShelf, VideoCard, VideoCardSkeleton, MediaGridSkeleton, LoadMore } from \"@krizaka/ui/media-grid\";",
|
|
56
73
|
"props": [
|
|
74
|
+
{
|
|
75
|
+
"component": "LoadMore",
|
|
76
|
+
"description": "Infinite scroll with a visible \"Load more\" under it.",
|
|
77
|
+
"props": [
|
|
78
|
+
{
|
|
79
|
+
"name": "hasMore",
|
|
80
|
+
"type": "boolean",
|
|
81
|
+
"default": null,
|
|
82
|
+
"description": "There is a next page.",
|
|
83
|
+
"required": true
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "label",
|
|
87
|
+
"type": "string",
|
|
88
|
+
"default": null,
|
|
89
|
+
"description": "The button, passed translated (\"Load more\").",
|
|
90
|
+
"required": true
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "loadingLabel",
|
|
94
|
+
"type": "string",
|
|
95
|
+
"default": null,
|
|
96
|
+
"description": "The wait, passed translated (\"Loading more videos\").",
|
|
97
|
+
"required": true
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "onLoadMore",
|
|
101
|
+
"type": "() => void",
|
|
102
|
+
"default": null,
|
|
103
|
+
"description": "Asks for the next page. Not called while `loading`, nor once `hasMore` is false.",
|
|
104
|
+
"required": true
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "endLabel",
|
|
108
|
+
"type": "string",
|
|
109
|
+
"default": null,
|
|
110
|
+
"description": "Shown instead of the button when the list is over, passed translated (\"You have seen everything\"). Nothing if absent.",
|
|
111
|
+
"required": false
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "failed",
|
|
115
|
+
"type": "boolean",
|
|
116
|
+
"default": "false",
|
|
117
|
+
"description": "The last page failed: the button reads `retryLabel`, and nothing loads by itself until it is pressed.",
|
|
118
|
+
"required": false
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "loading",
|
|
122
|
+
"type": "boolean",
|
|
123
|
+
"default": "false",
|
|
124
|
+
"description": "A page is on its way.",
|
|
125
|
+
"required": false
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"name": "retryLabel",
|
|
129
|
+
"type": "string",
|
|
130
|
+
"default": null,
|
|
131
|
+
"description": "The button after a failure, passed translated (\"Try again\"). Defaults to `label`.",
|
|
132
|
+
"required": false
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"name": "rootMargin",
|
|
136
|
+
"type": "string",
|
|
137
|
+
"default": "900px 0px",
|
|
138
|
+
"description": "How far below the viewport the page is already requested (an IntersectionObserver `rootMargin`).",
|
|
139
|
+
"required": false
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
},
|
|
57
143
|
{
|
|
58
144
|
"component": "MediaGrid",
|
|
59
145
|
"description": "A media grid that never mixes heights badly: a shelf for the 9:16, a uniform grid for the rest.",
|
|
@@ -294,11 +380,86 @@
|
|
|
294
380
|
"description": "The picture.",
|
|
295
381
|
"required": false
|
|
296
382
|
},
|
|
383
|
+
{
|
|
384
|
+
"name": "posterPriority",
|
|
385
|
+
"type": "\"auto\" | \"high\" | \"low\"",
|
|
386
|
+
"default": "auto",
|
|
387
|
+
"description": "`fetchPriority` of the poster: `high` for the first row (the largest contentful paint).",
|
|
388
|
+
"required": false
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
"name": "previewDelay",
|
|
392
|
+
"type": "number",
|
|
393
|
+
"default": "200",
|
|
394
|
+
"description": "How long the pointer must rest before the preview is requested, in ms: a pointer crossing the grid loads nothing.",
|
|
395
|
+
"required": false
|
|
396
|
+
},
|
|
297
397
|
{
|
|
298
398
|
"name": "previewSrc",
|
|
299
399
|
"type": "string | null",
|
|
300
400
|
"default": null,
|
|
301
|
-
"description": "An animated picture (WebP)
|
|
401
|
+
"description": "An animated picture (WebP, a few hundred kB) that fades in over the poster once the pointer has rested on the card\n(or it is focused) for `previewDelay`, and is removed again on leave. It is never requested before. On a phone (no\nhover) only the card most inside the viewport plays it, unless the person asked for less data or less motion.",
|
|
402
|
+
"required": false
|
|
403
|
+
}
|
|
404
|
+
]
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
"component": "MediaGridSkeleton",
|
|
408
|
+
"description": "The placeholder of a `MediaGrid`: the same shelf and grid, skeleton cards, one polite announcement.",
|
|
409
|
+
"props": [
|
|
410
|
+
{
|
|
411
|
+
"name": "label",
|
|
412
|
+
"type": "string",
|
|
413
|
+
"default": null,
|
|
414
|
+
"description": "What is loading, passed translated (\"Loading videos\"): announced once, the cards are hidden from assistive technology.",
|
|
415
|
+
"required": true
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
"name": "count",
|
|
419
|
+
"type": "number",
|
|
420
|
+
"default": "8",
|
|
421
|
+
"description": "Cards in the grid.",
|
|
422
|
+
"required": false
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
"name": "frame",
|
|
426
|
+
"type": "VideoCardAspect",
|
|
427
|
+
"default": "landscape",
|
|
428
|
+
"description": "The frame of the grid.",
|
|
429
|
+
"required": false
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
"name": "layout",
|
|
433
|
+
"type": "\"grid\" | \"list\"",
|
|
434
|
+
"default": "grid",
|
|
435
|
+
"description": "`grid` or `list`.",
|
|
436
|
+
"required": false
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
"name": "shelfCount",
|
|
440
|
+
"type": "number",
|
|
441
|
+
"default": "0",
|
|
442
|
+
"description": "Cards on the Shorts shelf above the grid (`split` mode); 0 for none.",
|
|
443
|
+
"required": false
|
|
444
|
+
}
|
|
445
|
+
]
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"component": "VideoCardSkeleton",
|
|
449
|
+
"description": "The placeholder of a `VideoCard` (decorative: announce the wait on the list, with `MediaGridSkeleton`).",
|
|
450
|
+
"props": [
|
|
451
|
+
{
|
|
452
|
+
"name": "aspect",
|
|
453
|
+
"type": "VideoCardAspect",
|
|
454
|
+
"default": "landscape",
|
|
455
|
+
"description": "`landscape` (16:9) or `portrait` (9:16), as the card it stands for.",
|
|
456
|
+
"required": false
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
"name": "layout",
|
|
460
|
+
"type": "\"row\" | \"stack\"",
|
|
461
|
+
"default": "stack",
|
|
462
|
+
"description": "`stack` or `row`, as the card it stands for.",
|
|
302
463
|
"required": false
|
|
303
464
|
}
|
|
304
465
|
]
|
|
@@ -313,6 +474,14 @@
|
|
|
313
474
|
"module": "@krizaka/ui/registry/examples/media-grid/feed",
|
|
314
475
|
"code": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { MediaGrid, VideoCard } from \"@krizaka/ui/media-grid\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site\";\nconst WIDE = `${CDN}/products/orazaka/gallery`;\nconst TALL = `${CDN}/docs/orochia/mobile`;\nconst CLIPS = [\n { id: \"tea\", title: \"Pouring the mint tea, slow\", poster: `${WIDE}/tea-pour.d69c3a5700.webp`, tall: false },\n { id: \"story\", title: \"Backstage, a minute before\", poster: `${TALL}/clip-feed-stories-poster.fd7835fa74.webp`, tall: true },\n { id: \"snow\", title: \"First snow on rue Saint-Denis\", poster: `${WIDE}/mtl-snow.e3ecb20e82.webp`, tall: false },\n { id: \"watch\", title: \"The unlock in one tap\", poster: `${TALL}/clip-watch-poster.9fc5d19c39.webp`, tall: true },\n { id: \"desert\", title: \"Desert camp at blue hour\", poster: `${WIDE}/desert-camp.78c6c0d26c.webp`, tall: false },\n { id: \"thanks\", title: \"Three ways to say thanks\", poster: `${TALL}/clip-unlock-poster.57e13fee5f.webp`, tall: true },\n];\n\n// 'split' (the feed default): vertical clips on a Shorts shelf, the others in a 16:9 grid whose cards share one height.\nexport default function MediaGridFeed() {\n return (\n <MediaGrid\n items={CLIPS}\n getId={(c) => c.id}\n orientationOf={(c) => (c.tall ? \"portrait\" : \"landscape\")}\n shelf={{ label: \"Shorts\", previousLabel: \"Previous shorts\", nextLabel: \"Next shorts\", seeAll: { href: \"#shorts\", label: \"See all\" } }}\n renderItem={(c, { aspect, layout }) => (\n <VideoCard href={`#${c.id}`} title={c.title} aspect={aspect} layout={layout} poster={c.poster} duration=\"0:12\" meta=\"12.4K views\" creator={{ name: \"Camille\" }} badges={<Badge tone=\"success\" size=\"sm\" className=\"bg-scrim\">Free</Badge>} />\n )}\n />\n );\n}\n"
|
|
315
476
|
},
|
|
477
|
+
{
|
|
478
|
+
"name": "loading",
|
|
479
|
+
"title": "Loading",
|
|
480
|
+
"description": "The skeleton of the feed in the real frames (a shelf of 9:16, a grid of 16:9) and `LoadMore` under the list.",
|
|
481
|
+
"path": "examples/media-grid/loading.tsx",
|
|
482
|
+
"module": "@krizaka/ui/registry/examples/media-grid/loading",
|
|
483
|
+
"code": "\"use client\";\n\nimport { LoadMore, MediaGridSkeleton } from \"@krizaka/ui/media-grid\";\nimport { useState } from \"react\";\n\n// The wait, in the shape of the feed: skeleton cards of the real frames (a shelf of 9:16, a grid of 16:9) while the first\n// page loads, then `LoadMore` under the list — the next page is asked a screenful before the end, the button never fails.\nexport default function MediaGridLoading() {\n const [loading, setLoading] = useState(false);\n return (\n <div className=\"flex flex-col gap-6\">\n <MediaGridSkeleton label=\"Loading videos\" shelfCount={4} count={4} />\n <LoadMore\n hasMore\n loading={loading}\n onLoadMore={() => {\n setLoading(true);\n setTimeout(() => setLoading(false), 1200);\n }}\n label=\"Load more\"\n loadingLabel=\"Loading more videos\"\n />\n </div>\n );\n}\n"
|
|
484
|
+
},
|
|
316
485
|
{
|
|
317
486
|
"name": "uniform",
|
|
318
487
|
"title": "One frame",
|
|
@@ -331,7 +500,11 @@
|
|
|
331
500
|
},
|
|
332
501
|
{
|
|
333
502
|
"path": "media-grid/index.ts",
|
|
334
|
-
"content": "/** @krizaka/ui/media-grid — `MediaGrid` (a shelf for 9:16, a uniform grid for the rest), `ShortsShelf`, `VideoCard` (aspect-aware, one body height)
|
|
503
|
+
"content": "/** @krizaka/ui/media-grid — `MediaGrid` (a shelf for 9:16, a uniform grid for the rest), `ShortsShelf`, `VideoCard` (aspect-aware, one body height, whole card one link, preview on intent), their skeletons and `LoadMore`. Client. */\nexport { LoadMore, type LoadMoreProps } from \"./load-more\";\nexport { MediaGrid, type MediaGridProps, type MediaGridState } from \"./media-grid\";\nexport { ShortsShelf, type ShortsShelfProps } from \"./shorts-shelf\";\nexport { VideoCard, type VideoCardAspect, type VideoCardCreator, type VideoCardProps } from \"./video-card\";\nexport { MediaGridSkeleton, type MediaGridSkeletonProps, VideoCardSkeleton, type VideoCardSkeletonProps } from \"./video-card-skeleton\";\n"
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
"path": "media-grid/load-more.tsx",
|
|
507
|
+
"content": "// Client (IntersectionObserver). The end of a long list: the next page is requested while the person is still a\n// screenful away (`rootMargin`), so it is there before they reach it; the button stays as the way that never fails\n// (no observer, a failed page, a keyboard).\nimport * as React from \"react\";\n\nimport { Button } from \"../button\";\nimport { cn } from \"../cn\";\nimport { Spinner } from \"../spinner\";\n\nexport interface LoadMoreProps extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onError\"> {\n /** Asks for the next page. Not called while `loading`, nor once `hasMore` is false. */\n onLoadMore: () => void;\n /** There is a next page. */\n hasMore: boolean;\n /** A page is on its way. */\n loading?: boolean;\n /** The last page failed: the button reads `retryLabel`, and nothing loads by itself until it is pressed. */\n failed?: boolean;\n /** The button, passed translated (\"Load more\"). */\n label: string;\n /** The wait, passed translated (\"Loading more videos\"). */\n loadingLabel: string;\n /** The button after a failure, passed translated (\"Try again\"). Defaults to `label`. */\n retryLabel?: string;\n /** Shown instead of the button when the list is over, passed translated (\"You have seen everything\"). Nothing if absent. */\n endLabel?: string;\n /**\n * How far below the viewport the page is already requested (an IntersectionObserver `rootMargin`).\n * @default \"900px 0px\"\n */\n rootMargin?: string;\n}\n\n/** Infinite scroll with a visible \"Load more\" under it. */\nexport function LoadMore({ onLoadMore, hasMore, loading = false, failed = false, label, loadingLabel, retryLabel, endLabel, rootMargin = \"900px 0px\", className, ...props }: LoadMoreProps) {\n const sentinel = React.useRef<HTMLDivElement>(null);\n const ask = React.useRef(onLoadMore);\n React.useEffect(() => {\n ask.current = onLoadMore;\n });\n // Observing again after each page makes the observer report the current state: a short page that leaves the sentinel in view asks for the next.\n React.useEffect(() => {\n const el = sentinel.current;\n if (!el || !hasMore || loading || failed || typeof IntersectionObserver === \"undefined\") return;\n const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && ask.current(), { rootMargin });\n io.observe(el);\n return () => io.disconnect();\n }, [hasMore, loading, failed, rootMargin]);\n return (\n <div data-load-more=\"\" data-state={loading ? \"loading\" : failed ? \"failed\" : hasMore ? \"idle\" : \"end\"} className={cn(\"relative flex min-h-14 items-center justify-center py-6\", className)} {...props}>\n <div ref={sentinel} aria-hidden className=\"pointer-events-none absolute inset-x-0 top-0 h-px\" />\n {loading ? (\n <Spinner label={loadingLabel} size=\"md\" />\n ) : hasMore ? (\n <Button type=\"button\" variant=\"secondary\" size=\"md\" onClick={() => onLoadMore()}>\n {failed ? (retryLabel ?? label) : label}\n </Button>\n ) : endLabel ? (\n <p className=\"m-0 text-sm text-fg-secondary\">{endLabel}</p>\n ) : null}\n </div>\n );\n}\n"
|
|
335
508
|
},
|
|
336
509
|
{
|
|
337
510
|
"path": "media-grid/media-grid.tsx",
|
|
@@ -339,32 +512,112 @@
|
|
|
339
512
|
},
|
|
340
513
|
{
|
|
341
514
|
"path": "media-grid/meta.ts",
|
|
342
|
-
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Media grid\",\n summary: \"A feed of videos in mixed orientations that never staggers: `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.\",\n status: \"beta\",\n category: \"media\",\n platforms: \"web\",\n whenToUse: [\n \"A home, an explore page or a profile's Videos tab where creators publish both vertical and horizontal video.\",\n \"A gallery whose items come from different sources (`uniform`: everything in one frame, the odd one drawn whole over a blurred copy).\",\n ],\n whenNotToUse: [\n { when: \"For a feed that plays one video per screen.\", use: \"vertical-video\" },\n { when: \"For still photos the owner reorders.\", use: \"photo-grid\" },\n ],\n bestPractices: [\n \"Leave `mode` on `split` for a feed: a 9:16 card in a 16:9 grid is what makes a row tower over its neighbours.\",\n \"Give `orientationOf` the source's width and height, not the card's: a clip of the other shape is drawn with `fit=\\\"contain\\\"`, never cropped to a face.\",\n \"Put what is the product's own in the slots (`badges`, `action`); badges sit on `scrim` and read in both themes.\",\n \"Pass every word translated: the shelf's name, its arrows and “See all”.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Each card is one link (its title); the creator and the action are their own stops; the shelf takes the focus to scroll with the arrow keys.\" },\n { keys: \"Arrow Left / Arrow Right\", action: \"Scrolls the focused shelf.\" },\n ],\n notes: [\n \"The shelf is a labelled section whose row is a list; the arrows are named buttons shown with a mouse, hidden on touch (swipe).\",\n \"The card's whole surface is the title's link (a stretched link): no nested interactive element.\",\n \"The animated preview plays on hover or focus
|
|
515
|
+
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Media grid\",\n summary: \"A feed of videos in mixed orientations that never staggers, with its loading states (`MediaGridSkeleton`, `VideoCardSkeleton`, `LoadMore`): `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.\",\n status: \"beta\",\n category: \"media\",\n platforms: \"web\",\n parity: { native: { status: \"gap\", issue: \"krizaka/krizaka-ui#228\", reason: \"orochia-mobile hand-rolls its tiles and stories rail (Tiles, StoriesRail).\" }, desktop: \"web\" },\n whenToUse: [\n \"A home, an explore page or a profile's Videos tab where creators publish both vertical and horizontal video.\",\n \"A gallery whose items come from different sources (`uniform`: everything in one frame, the odd one drawn whole over a blurred copy).\",\n ],\n whenNotToUse: [\n { when: \"For a feed that plays one video per screen.\", use: \"vertical-video\" },\n { when: \"For still photos the owner reorders.\", use: \"photo-grid\" },\n ],\n bestPractices: [\n \"Leave `mode` on `split` for a feed: a 9:16 card in a 16:9 grid is what makes a row tower over its neighbours.\",\n \"Give `orientationOf` the source's width and height, not the card's: a clip of the other shape is drawn with `fit=\\\"contain\\\"`, never cropped to a face.\",\n \"Put what is the product's own in the slots (`badges`, `action`); badges sit on `scrim` and read in both themes.\",\n \"Pass every word translated: the shelf's name, its arrows and “See all”.\",\n \"Give `previewSrc` the small animated WebP, never the stream: it is requested only after the pointer rests (`previewDelay`, 200 ms), fades in over the poster and is removed on leave. On a phone only the card most inside the viewport plays it, and none under Save-Data or reduced motion.\",\n \"Put `loading=\\\"eager\\\"` and `posterPriority=\\\"high\\\"` on the first row's posters, sized images from the CDN on the others (they are lazy).\",\n \"While the first page loads, draw `MediaGridSkeleton` (the same shelf and grid, the same card heights); under a long list use `LoadMore`: the next page is asked a screenful before the end, and the button stays for keyboards and failures.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Each card is one link (its title); the creator and the action are their own stops; the shelf takes the focus to scroll with the arrow keys.\" },\n { keys: \"Arrow Left / Arrow Right\", action: \"Scrolls the focused shelf.\" },\n ],\n notes: [\n \"The shelf is a labelled section whose row is a list; the arrows are named buttons shown with a mouse, hidden on touch (swipe).\",\n \"The card's whole surface is the title's link (a stretched link over the picture and the text): no nested interactive element. The badges and the duration let the click through; the creator and the action stay their own stops above it.\",\n \"The animated preview plays on hover or focus (after a short rest), or for the one most visible card of a phone; never under Save-Data, and on a phone never under reduced motion. It is decorative (empty `alt`).\",\n \"`MediaGridSkeleton` is one polite `role=\\\"status\\\"` announcement; its cards are hidden from assistive technology. `LoadMore` names its wait through the `Spinner`.\",\n ],\n },\n related: [\"vertical-video\", \"card\", \"photo-grid\"],\n web: {\n imports: [\"MediaGrid\", \"ShortsShelf\", \"VideoCard\", \"VideoCardSkeleton\", \"MediaGridSkeleton\", \"LoadMore\"],\n examples: [\n { name: \"feed\", title: \"A feed\", description: \"Three horizontal clips in the grid, three vertical on the shelf: every card of a row has one height.\" },\n { name: \"loading\", title: \"Loading\", description: \"The skeleton of the feed in the real frames (a shelf of 9:16, a grid of 16:9) and `LoadMore` under the list.\" },\n { name: \"uniform\", title: \"One frame\", description: \"`mode=\\\"uniform\\\"`: vertical clips drawn whole over a blurred copy inside 16:9 cards.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
"path": "media-grid/preview-focus.ts",
|
|
519
|
+
"content": "// Client only. Which card of the page is the most visible: on a phone there is no hover, so exactly one card at a time\n// (the one most inside the viewport) may play its preview. One IntersectionObserver for the whole page, no per-card\n// observer; the answer is a store read with `useSyncExternalStore`.\nconst MIN_RATIO = 0.6;\nconst ratios = new Map<Element, number>();\nconst listeners = new Set<() => void>();\nlet observer: IntersectionObserver | null = null;\nlet leader: Element | null = null;\n\nfunction elect() {\n let best: Element | null = null;\n let bestRatio = MIN_RATIO - Number.EPSILON;\n for (const [el, ratio] of ratios) {\n if (ratio > bestRatio) {\n best = el;\n bestRatio = ratio;\n }\n }\n if (best === leader) return;\n leader = best;\n for (const l of listeners) l();\n}\n\n/** Starts counting an element's visibility; returns the function that stops it. */\nexport function trackVisibility(el: Element): () => void {\n if (typeof IntersectionObserver === \"undefined\") return () => {};\n observer ??= new IntersectionObserver(\n (entries) => {\n for (const e of entries) ratios.set(e.target, e.isIntersecting ? e.intersectionRatio : 0);\n elect();\n },\n { threshold: [0, 0.25, 0.5, 0.6, 0.75, 1] },\n );\n ratios.set(el, 0);\n observer.observe(el);\n return () => {\n observer?.unobserve(el);\n ratios.delete(el);\n elect();\n };\n}\n\nexport const subscribeLeader = (l: () => void) => (listeners.add(l), () => void listeners.delete(l));\n\n/** Whether this element is the card the page is showing most. */\nexport const isLeader = (el: Element | null) => el !== null && leader === el;\n\n/** Test seam: forget everything. */\nexport function resetPreviewFocus() {\n observer?.disconnect();\n observer = null;\n ratios.clear();\n leader = null;\n}\n"
|
|
343
520
|
},
|
|
344
521
|
{
|
|
345
522
|
"path": "media-grid/shorts-shelf.tsx",
|
|
346
523
|
"content": "// Client (the arrows scroll the row). A horizontal row of 9:16 cards of one height: scroll-snap, arrows with a mouse,\n// a swipe on touch, a \"See all\" link. The row is a labelled list that scrolls with the keyboard (it takes focus).\nimport * as React from \"react\";\n\nimport { BlockLink, type LinkComponent } from \"../block-link\";\nimport { cn } from \"../cn\";\n\nexport interface ShortsShelfProps<T> extends Omit<React.ComponentProps<\"section\">, \"children\" | \"title\"> {\n /** The shelf's accessible name (\"Shorts\"), passed translated; shown as its heading unless `title` is set. */\n label: string;\n /** A heading different from the name. */\n title?: React.ReactNode;\n /** The clips, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** One card: use `VideoCard aspect=\"portrait\"`. */\n renderItem: (item: T, index: number) => React.ReactNode;\n /** Opens the vertical feed. */\n seeAll?: { href: string; label: string; linkAs?: LinkComponent };\n /** The arrows' names, passed translated. */\n previousLabel: string;\n /** The next arrow's name. */\n nextLabel: string;\n}\n\nconst Chevron = ({ flip }: { flip?: boolean }) => (\n <svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className={flip ? \"rotate-180\" : undefined}>\n <path d=\"m9 6 6 6-6 6\" />\n </svg>\n);\n\nconst ARROW =\n \"hidden h-9 w-9 items-center justify-center rounded-full border border-border-default bg-surface-1 text-fg shadow-sm transition-colors hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40 [@media(hover:hover)]:inline-flex\";\n\n/** A Shorts shelf: equal-height 9:16 cards in a snap-scrolling row. */\nexport function ShortsShelf<T>({ label, title, items, getId, renderItem, seeAll, previousLabel, nextLabel, className, ...props }: ShortsShelfProps<T>) {\n const row = React.useRef<HTMLUListElement>(null);\n const [edge, setEdge] = React.useState({ start: true, end: false });\n const measure = React.useCallback(() => {\n const el = row.current;\n if (!el) return;\n setEdge({ start: el.scrollLeft <= 2, end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 2 });\n }, []);\n React.useEffect(measure, [measure, items.length]);\n const page = (dir: 1 | -1) => {\n const el = row.current;\n if (el) el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\" });\n };\n const headingId = React.useId();\n if (items.length === 0) return null;\n return (\n <section aria-labelledby={headingId} data-shorts-shelf className={cn(\"min-w-0\", className)} {...props}>\n <div className=\"mb-3 flex items-center gap-2\">\n <h2 id={headingId} className=\"m-0 mr-auto text-base font-bold text-fg\">\n {title ?? label}\n </h2>\n {seeAll && (\n <BlockLink href={seeAll.href} linkAs={seeAll.linkAs} className=\"text-sm font-semibold text-fg-accent hover:underline\">\n {seeAll.label}\n </BlockLink>\n )}\n <button type=\"button\" aria-label={previousLabel} disabled={edge.start} onClick={() => page(-1)} className={ARROW}>\n <Chevron flip />\n </button>\n <button type=\"button\" aria-label={nextLabel} disabled={edge.end} onClick={() => page(1)} className={ARROW}>\n <Chevron />\n </button>\n </div>\n <ul\n ref={row}\n onScroll={measure}\n // biome-ignore: a scroll container must take the keyboard focus to scroll with the arrow keys\n tabIndex={0}\n aria-labelledby={headingId}\n className=\"m-0 flex list-none snap-x snap-mandatory items-stretch gap-3 overflow-x-auto overscroll-x-contain p-0 pb-2 [scrollbar-width:none] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-scrollbar]:hidden\"\n >\n {items.map((item, i) => (\n <li key={getId(item)} className=\"w-[9.5rem] shrink-0 snap-start sm:w-44\">\n {renderItem(item, i)}\n </li>\n ))}\n </ul>\n </section>\n );\n}\n"
|
|
347
524
|
},
|
|
525
|
+
{
|
|
526
|
+
"path": "media-grid/video-card-skeleton.tsx",
|
|
527
|
+
"content": "// A video card on its way: the same frame, body and footer heights as `VideoCard`, so nothing moves when it arrives.\nimport * as React from \"react\";\n\nimport { Card } from \"../card\";\nimport { cn } from \"../cn\";\nimport { Skeleton } from \"../skeleton\";\nimport type { VideoCardAspect } from \"./video-card\";\n\nexport interface VideoCardSkeletonProps extends React.ComponentProps<\"div\"> {\n /** `landscape` (16:9) or `portrait` (9:16), as the card it stands for. @default \"landscape\" */\n aspect?: VideoCardAspect;\n /** `stack` or `row`, as the card it stands for. @default \"stack\" */\n layout?: \"stack\" | \"row\";\n}\n\nconst FRAME = { landscape: \"aspect-video\", portrait: \"aspect-[9/16]\" } as const;\n\n/** The placeholder of a `VideoCard` (decorative: announce the wait on the list, with `MediaGridSkeleton`). */\nexport function VideoCardSkeleton({ aspect = \"landscape\", layout = \"stack\", className, ...props }: VideoCardSkeletonProps) {\n const row = layout === \"row\";\n const tall = aspect === \"portrait\" && !row;\n return (\n <div aria-hidden data-skeleton=\"video-card\" data-aspect={aspect} data-layout={layout} className={cn(\"h-full min-w-0\", className)} {...props}>\n <Card.Root tone=\"glass\" className={cn(\"h-full w-full max-w-full\", row ? \"flex-row\" : \"flex-col\")}>\n <Card.Media aspect=\"auto\" className={cn(\"shrink-0\", FRAME[aspect], row && (aspect === \"portrait\" ? \"w-24 sm:w-28\" : \"w-40 sm:w-56\"))}>\n <Skeleton shape=\"rect\" className=\"h-full w-full rounded-none\" />\n </Card.Media>\n <Card.Body className=\"static min-w-0 flex-1 justify-between gap-2 p-3\">\n <div className=\"flex min-h-[2.5em] flex-col justify-center gap-1.5\">\n <Skeleton className=\"h-3\" />\n <Skeleton className=\"h-3 w-3/5\" />\n </div>\n <div className={cn(\"flex text-xs\", tall ? \"flex-col gap-1\" : \"h-8 items-center gap-2\")}>\n <div className=\"flex h-6 min-w-0 items-center gap-2\">\n <Skeleton shape=\"circle\" className=\"h-6 w-6 shrink-0\" />\n <Skeleton className=\"h-2.5 w-20\" />\n </div>\n <div className={cn(\"flex items-center\", tall ? \"h-7\" : \"ml-auto\")}>\n <Skeleton className=\"h-2.5 w-14\" />\n </div>\n </div>\n </Card.Body>\n </Card.Root>\n </div>\n );\n}\n\nexport interface MediaGridSkeletonProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n /** What is loading, passed translated (\"Loading videos\"): announced once, the cards are hidden from assistive technology. */\n label: string;\n /** Cards in the grid. @default 8 */\n count?: number;\n /** Cards on the Shorts shelf above the grid (`split` mode); 0 for none. @default 0 */\n shelfCount?: number;\n /** `grid` or `list`. @default \"grid\" */\n layout?: \"grid\" | \"list\";\n /** The frame of the grid. @default \"landscape\" */\n frame?: VideoCardAspect;\n}\n\nconst GRID = {\n landscape: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4\",\n portrait: \"grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6\",\n} as const;\n\n/** The placeholder of a `MediaGrid`: the same shelf and grid, skeleton cards, one polite announcement. */\nexport function MediaGridSkeleton({ label, count = 8, shelfCount = 0, layout = \"grid\", frame = \"landscape\", className, ...props }: MediaGridSkeletonProps) {\n const row = layout === \"list\";\n return (\n <div role=\"status\" aria-busy=\"true\" data-media-grid-skeleton=\"\" className={cn(\"flex min-w-0 flex-col gap-8\", className)} {...props}>\n <span className=\"sr-only\">{label}</span>\n {shelfCount > 0 && (\n <div aria-hidden className=\"min-w-0\">\n <Skeleton className=\"mb-3 h-4 w-24\" />\n <ul className=\"m-0 flex list-none items-stretch gap-3 overflow-hidden p-0\">\n {Array.from({ length: shelfCount }, (_, i) => (\n <li key={i} className=\"w-[9.5rem] shrink-0 sm:w-44\">\n <VideoCardSkeleton aspect=\"portrait\" />\n </li>\n ))}\n </ul>\n </div>\n )}\n <ul aria-hidden className={cn(\"m-0 grid list-none auto-rows-fr gap-4 p-0\", row ? \"grid-cols-1\" : GRID[frame])}>\n {Array.from({ length: count }, (_, i) => (\n <li key={i} className=\"min-w-0\">\n <VideoCardSkeleton aspect={frame} layout={row ? \"row\" : \"stack\"} />\n </li>\n ))}\n </ul>\n </div>\n );\n}\n"
|
|
528
|
+
},
|
|
348
529
|
{
|
|
349
530
|
"path": "media-grid/video-card.tsx",
|
|
350
|
-
"content": "// Client (hover / focus swaps the picture for its animated preview). A video in a grid or a shelf: the picture in a 16:9\n// or 9:16 frame (a source of the other shape is shown inside, never stretched), badges, duration, the title on two lines\n// reserved, the creator, a count and one quick action — the same height of body in every card so a row aligns.\nimport * as React from \"react\";\n\nimport { Avatar } from \"../avatar\";\nimport type { DefaultAvatar } from \"../avatar/default-avatar\";\nimport { BlockLink, type LinkComponent } from \"../block-link\";\nimport { Card } from \"../card\";\nimport { cn } from \"../cn\";\n\nexport type VideoCardAspect = \"landscape\" | \"portrait\";\n\nexport interface VideoCardCreator {\n /** The creator's name. */\n name: string;\n /** Their photo. */\n avatarSrc?: string | null;\n /** Their profile. */\n href?: string;\n /** The Krizaka default picture when there is no photo (a brand and a seed). */\n defaultAvatar?: DefaultAvatar;\n}\n\nexport interface VideoCardProps extends Omit<React.ComponentProps<\"article\">, \"title\"> {\n /** The title; it names the card and is its link. */\n title: string;\n /** Where the card opens. */\n href: string;\n /** The app's router link (Next's `Link`). */\n linkAs?: LinkComponent;\n /**\n * The frame: `landscape` (16:9) or `portrait` (9:16).\n * @default \"landscape\"\n */\n aspect?: VideoCardAspect;\n /**\n * `stack` (picture over text) or `row` (picture beside the text: the list mode of a section).\n * @default \"stack\"\n */\n layout?: \"stack\" | \"row\";\n /** The picture. */\n poster?: string | null;\n /** An animated picture (WebP) shown while the card is hovered or focused. */\n previewSrc?: string | null;\n /**\n * `cover` fills the frame; `contain` draws the whole picture over a blurred copy of itself (a source of the other\n * orientation: no cropped face, no black bar).\n * @default \"cover\"\n */\n fit?: \"cover\" | \"contain\";\n /** The length, already formatted (`3:42`). */\n duration?: string;\n /** Badges at the top-left of the picture (Free, a price, 18+). Put them on `scrim` so they read in both themes. */\n badges?: React.ReactNode;\n /** Who made it: name, photo, profile. */\n creator?: VideoCardCreator;\n /** What is counted (`12.4K views`), already formatted. */\n meta?: React.ReactNode;\n /** One quick action at the end of the footer (a tip, a save). Its clicks must not follow the card's link. */\n action?: React.ReactNode;\n /** A sensitive picture: blurred and scaled so nothing shows (put the reveal button in `overlay`). */\n blurred?: boolean;\n /** Over the whole picture, above the card's link (a veil, a reveal button). */\n overlay?: React.ReactNode;\n /** Shown instead of the picture while there is none. */\n fallback?: React.ReactNode;\n /** `eager` for the first cards of a page. */\n loading?: \"lazy\" | \"eager\";\n}\n\nconst FRAME = { landscape: \"aspect-video\", portrait: \"aspect-[9/16]\" } as const;\n\n/** A video card whose frame, body and footer have one height per aspect, so a grid row or a shelf never staggers. */\nexport function VideoCard({\n title,\n href,\n linkAs,\n aspect = \"landscape\",\n layout = \"stack\",\n poster,\n previewSrc,\n fit = \"cover\",\n duration,\n badges,\n creator,\n meta,\n action,\n fallback,\n blurred = false,\n overlay,\n loading = \"lazy\",\n className,\n onMouseEnter,\n onMouseLeave,\n onFocus,\n onBlur,\n ...props\n}: VideoCardProps) {\n const [live, setLive] = React.useState(false);\n const picture = live && previewSrc ? previewSrc : poster;\n const row = layout === \"row\";\n const tall = aspect === \"portrait\" && !row;\n return (\n <article\n data-aspect={aspect}\n data-layout={layout}\n className={cn(\"group relative h-full min-w-0\", className)}\n onMouseEnter={(e) => (setLive(true), onMouseEnter?.(e))}\n onMouseLeave={(e) => (setLive(false), onMouseLeave?.(e))}\n onFocus={(e) => (setLive(true), onFocus?.(e))}\n onBlur={(e) => (setLive(false), onBlur?.(e))}\n {...props}\n >\n <Card.Root interactive tone=\"glass\" className={cn(\"h-full w-full max-w-full\", row ? \"flex-row\" : \"flex-col\")}>\n <Card.Media aspect=\"auto\" className={cn(\"shrink-0\", FRAME[aspect], row && (aspect === \"portrait\" ? \"w-24 sm:w-28\" : \"w-40 sm:w-56\"))}>\n {fit === \"contain\" && picture && <img src={picture} alt=\"\" aria-hidden loading=\"lazy\" className=\"absolute inset-0 h-full w-full scale-125 object-cover opacity-70 blur-2xl\" />}\n <Card.Image src={picture} loading={loading} fallback={fallback} className={cn(fit === \"contain\" && \"relative object-contain\", blurred && \"scale-110 blur-xl\")} />\n {overlay && <div className=\"absolute inset-0 z-20 flex\">{overlay}</div>}\n {badges && (\n <Card.Overlay corner=\"top-left\" className=\"theme-dark flex-wrap\">\n {badges}\n </Card.Overlay>\n )}\n {duration && (\n <Card.Overlay corner=\"bottom-right\">\n <span className=\"rounded-sm bg-scrim px-1.5 py-0.5 font-mono text-[11px] text-fg-on-media\">{duration}</span>\n </Card.Overlay>\n )}\n </Card.Media>\n <Card.Body className=\"min-w-0 flex-1 justify-between gap-2 p-3\">\n <h3 className=\"m-0 min-h-[2.5em] text-sm font-semibold leading-tight text-fg\">\n <BlockLink href={href} linkAs={linkAs} className=\"line-clamp-2 outline-none after:absolute after:inset-0 after:z-0 after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-ring\">\n {title}\n </BlockLink>\n </h3>\n {/* A narrow card (9:16 on a shelf) puts the count and the action on a second line so the name is never cut to an initial. */}\n <div className={cn(\"relative z-10 text-xs text-fg-secondary\", tall ? \"flex flex-col gap-1\" : \"flex h-8 items-center gap-2\")}>\n {creator && (\n <div className=\"flex h-6 min-w-0 items-center gap-2\">\n <Avatar src={creator.avatarSrc} defaultAvatar={creator.defaultAvatar} alt=\"\" fallback={creator.name.charAt(0)} size=\"xs\" className=\"shrink-0\" />\n {creator.href ? (\n <BlockLink href={creator.href} linkAs={linkAs} className=\"min-w-0 truncate hover:text-fg\">\n {creator.name}\n </BlockLink>\n ) : (\n <span className=\"min-w-0 truncate\">{creator.name}</span>\n )}\n </div>\n )}\n {(meta || action) && (\n <div className={cn(\"flex items-center gap-2\", tall ? \"h-7 justify-between\" : \"ml-auto shrink-0\")}>\n {meta && <span className=\"shrink-0 font-mono text-[11px] text-fg-muted\">{meta}</span>}\n {action && <span className=\"shrink-0\">{action}</span>}\n </div>\n )}\n </div>\n </Card.Body>\n </Card.Root>\n </article>\n );\n}\n"
|
|
531
|
+
"content": "// Client (hover / focus swaps the picture for its animated preview). A video in a grid or a shelf: the picture in a 16:9\n// or 9:16 frame (a source of the other shape is shown inside, never stretched), badges, duration, the title on two lines\n// reserved, the creator, a count and one quick action — the same height of body in every card so a row aligns.\nimport * as React from \"react\";\n\nimport { Avatar } from \"../avatar\";\nimport type { DefaultAvatar } from \"../avatar/default-avatar\";\nimport { BlockLink, type LinkComponent } from \"../block-link\";\nimport { Card } from \"../card\";\nimport { cn } from \"../cn\";\nimport { isLeader, subscribeLeader, trackVisibility } from \"./preview-focus\";\n\nexport type VideoCardAspect = \"landscape\" | \"portrait\";\n\nexport interface VideoCardCreator {\n /** The creator's name. */\n name: string;\n /** Their photo. */\n avatarSrc?: string | null;\n /** Their profile. */\n href?: string;\n /** The Krizaka default picture when there is no photo (a brand and a seed). */\n defaultAvatar?: DefaultAvatar;\n}\n\nexport interface VideoCardProps extends Omit<React.ComponentProps<\"article\">, \"title\"> {\n /** The title; it names the card and is its link. */\n title: string;\n /** Where the card opens. */\n href: string;\n /** The app's router link (Next's `Link`). */\n linkAs?: LinkComponent;\n /**\n * The frame: `landscape` (16:9) or `portrait` (9:16).\n * @default \"landscape\"\n */\n aspect?: VideoCardAspect;\n /**\n * `stack` (picture over text) or `row` (picture beside the text: the list mode of a section).\n * @default \"stack\"\n */\n layout?: \"stack\" | \"row\";\n /** The picture. */\n poster?: string | null;\n /**\n * An animated picture (WebP, a few hundred kB) that fades in over the poster once the pointer has rested on the card\n * (or it is focused) for `previewDelay`, and is removed again on leave. It is never requested before. On a phone (no\n * hover) only the card most inside the viewport plays it, unless the person asked for less data or less motion.\n */\n previewSrc?: string | null;\n /**\n * How long the pointer must rest before the preview is requested, in ms: a pointer crossing the grid loads nothing.\n * @default 200\n */\n previewDelay?: number;\n /**\n * `fetchPriority` of the poster: `high` for the first row (the largest contentful paint).\n * @default \"auto\"\n */\n posterPriority?: \"high\" | \"low\" | \"auto\";\n /**\n * `cover` fills the frame; `contain` draws the whole picture over a blurred copy of itself (a source of the other\n * orientation: no cropped face, no black bar).\n * @default \"cover\"\n */\n fit?: \"cover\" | \"contain\";\n /** The length, already formatted (`3:42`). */\n duration?: string;\n /** Badges at the top-left of the picture (Free, a price, 18+). Put them on `scrim` so they read in both themes. */\n badges?: React.ReactNode;\n /** Who made it: name, photo, profile. */\n creator?: VideoCardCreator;\n /** What is counted (`12.4K views`), already formatted. */\n meta?: React.ReactNode;\n /** One quick action at the end of the footer (a tip, a save). Its clicks must not follow the card's link. */\n action?: React.ReactNode;\n /** A sensitive picture: blurred and scaled so nothing shows (put the reveal button in `overlay`). */\n blurred?: boolean;\n /** Over the whole picture, above the card's link (a veil, a reveal button). */\n overlay?: React.ReactNode;\n /** Shown instead of the picture while there is none. */\n fallback?: React.ReactNode;\n /** `eager` for the first cards of a page. */\n loading?: \"lazy\" | \"eager\";\n}\n\nconst FRAME = { landscape: \"aspect-video\", portrait: \"aspect-[9/16]\" } as const;\n\n/** A video card whose frame, body and footer have one height per aspect, so a grid row or a shelf never staggers. */\nexport function VideoCard({\n title,\n href,\n linkAs,\n aspect = \"landscape\",\n layout = \"stack\",\n poster,\n previewSrc,\n previewDelay = 200,\n posterPriority = \"auto\",\n fit = \"cover\",\n duration,\n badges,\n creator,\n meta,\n action,\n fallback,\n blurred = false,\n overlay,\n loading = \"lazy\",\n className,\n onMouseEnter,\n onMouseLeave,\n onFocus,\n onBlur,\n ref,\n ...props\n}: VideoCardProps) {\n const [node, setNode] = React.useState<HTMLElement | null>(null);\n const setRef = React.useCallback(\n (el: HTMLElement | null) => {\n setNode(el);\n if (typeof ref === \"function\") ref(el);\n else if (ref) (ref as React.RefObject<HTMLElement | null>).current = el;\n },\n [ref],\n );\n const canPreview = Boolean(previewSrc) && !blurred;\n const { live, arm, disarm } = usePreviewIntent(node, canPreview, previewDelay);\n const row = layout === \"row\";\n const tall = aspect === \"portrait\" && !row;\n return (\n <article\n ref={setRef}\n data-aspect={aspect}\n data-layout={layout}\n className={cn(\"group relative h-full min-w-0\", className)}\n data-preview={live ? \"on\" : undefined}\n onMouseEnter={(e) => (arm(\"pointer\"), onMouseEnter?.(e))}\n onMouseLeave={(e) => (disarm(\"pointer\"), onMouseLeave?.(e))}\n onFocus={(e) => (arm(\"focus\"), onFocus?.(e))}\n onBlur={(e) => (disarm(\"focus\"), onBlur?.(e))}\n {...props}\n >\n <Card.Root interactive tone=\"glass\" className={cn(\"h-full w-full max-w-full\", row ? \"flex-row\" : \"flex-col\")}>\n <Card.Media aspect=\"auto\" className={cn(\"shrink-0\", FRAME[aspect], row && (aspect === \"portrait\" ? \"w-24 sm:w-28\" : \"w-40 sm:w-56\"))}>\n {fit === \"contain\" && poster && <img src={poster} alt=\"\" aria-hidden loading=\"lazy\" className=\"absolute inset-0 h-full w-full scale-125 object-cover opacity-70 blur-2xl\" />}\n <Card.Image\n src={poster}\n loading={loading}\n fetchPriority={posterPriority === \"auto\" ? undefined : posterPriority}\n fallback={fallback}\n className={cn(fit === \"contain\" && \"relative object-contain\", blurred && \"scale-110 blur-xl\")}\n />\n {live && previewSrc && <PreviewLayer src={previewSrc} contain={fit === \"contain\"} />}\n {overlay && <div className=\"absolute inset-0 z-20 flex\">{overlay}</div>}\n {badges && (\n <Card.Overlay corner=\"top-left\" className=\"theme-dark pointer-events-none flex-wrap\">\n {badges}\n </Card.Overlay>\n )}\n {duration && (\n <Card.Overlay corner=\"bottom-right\" className=\"pointer-events-none\">\n <span className=\"rounded-sm bg-scrim px-1.5 py-0.5 font-mono text-[11px] text-fg-on-media\">{duration}</span>\n </Card.Overlay>\n )}\n </Card.Media>\n {/* `static`: the body must not be the containing block of the title link's ::after, or the link would cover the text only and the picture would not open the video. */}\n <Card.Body className=\"static min-w-0 flex-1 justify-between gap-2 p-3\">\n <h3 className=\"m-0 min-h-[2.5em] text-sm font-semibold leading-tight text-fg\">\n <BlockLink href={href} linkAs={linkAs} className=\"line-clamp-2 outline-none after:absolute after:inset-0 after:z-0 after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-ring\">\n {title}\n </BlockLink>\n </h3>\n {/* A narrow card (9:16 on a shelf) puts the count and the action on a second line so the name is never cut to an initial. */}\n <div className={cn(\"relative z-10 text-xs text-fg-secondary\", tall ? \"flex flex-col gap-1\" : \"flex h-8 items-center gap-2\")}>\n {creator && (\n <div className=\"flex h-6 min-w-0 items-center gap-2\">\n <Avatar src={creator.avatarSrc} defaultAvatar={creator.defaultAvatar} alt=\"\" fallback={creator.name.charAt(0)} size=\"xs\" className=\"shrink-0\" />\n {creator.href ? (\n <BlockLink href={creator.href} linkAs={linkAs} className=\"min-w-0 truncate hover:text-fg\">\n {creator.name}\n </BlockLink>\n ) : (\n <span className=\"min-w-0 truncate\">{creator.name}</span>\n )}\n </div>\n )}\n {(meta || action) && (\n <div className={cn(\"flex items-center gap-2\", tall ? \"h-7 justify-between\" : \"ml-auto shrink-0\")}>\n {meta && <span className=\"shrink-0 font-mono text-[11px] text-fg-muted\">{meta}</span>}\n {action && <span className=\"shrink-0\">{action}</span>}\n </div>\n )}\n </div>\n </Card.Body>\n </Card.Root>\n </article>\n );\n}\n\n/** The animated preview, fading in over the poster once it has loaded; unmounted (so unloaded) on leave. */\nfunction PreviewLayer({ src, contain }: { src: string; contain: boolean }) {\n const [ready, setReady] = React.useState(false);\n return (\n <img\n src={src}\n alt=\"\"\n aria-hidden\n decoding=\"async\"\n data-preview-layer=\"\"\n data-ready={ready ? \"\" : undefined}\n onLoad={() => setReady(true)}\n className={cn(\"pointer-events-none absolute inset-0 h-full w-full opacity-0 transition-opacity duration-300 data-[ready]:opacity-100 motion-reduce:transition-none\", contain ? \"object-contain\" : \"object-cover\")}\n />\n );\n}\n\ntype Cause = \"pointer\" | \"focus\";\n\nconst matches = (query: string) => typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(query).matches;\nconst hoverCapable = () => matches(\"(hover: hover)\");\nconst saveData = () => typeof navigator !== \"undefined\" && (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData === true;\n\n/**\n * When the preview is wanted. A desktop pointer or the keyboard focus must rest `delay` ms first; on a phone the card\n * the page shows most (and only one) wants it, never under Save-Data or reduced motion. Leaving clears everything.\n */\nfunction usePreviewIntent(node: HTMLElement | null, enabled: boolean, delay: number) {\n const [causes, setCauses] = React.useState<ReadonlySet<Cause>>(new Set());\n const timers = React.useRef(new Map<Cause, ReturnType<typeof setTimeout>>());\n const arm = React.useCallback(\n (cause: Cause) => {\n if (!enabled || saveData()) return;\n if (cause === \"pointer\" && !hoverCapable()) return;\n clearTimeout(timers.current.get(cause));\n timers.current.set(\n cause,\n setTimeout(() => setCauses((c) => new Set(c).add(cause)), delay),\n );\n },\n [enabled, delay],\n );\n const disarm = React.useCallback((cause: Cause) => {\n clearTimeout(timers.current.get(cause));\n timers.current.delete(cause);\n setCauses((c) => {\n if (!c.has(cause)) return c;\n const next = new Set(c);\n next.delete(cause);\n return next;\n });\n }, []);\n React.useEffect(() => {\n const pending = timers.current;\n return () => {\n for (const t of pending.values()) clearTimeout(t);\n pending.clear();\n };\n }, []);\n\n // A phone: the single most visible card, after the same rest.\n const touch = enabled && !hoverCapable();\n const leading = React.useSyncExternalStore(\n subscribeLeader,\n () => isLeader(node),\n () => false,\n );\n React.useEffect(() => {\n if (!node || !touch) return;\n return trackVisibility(node);\n }, [node, touch]);\n const [ready, setReady] = React.useState(false);\n const [wasLeading, setWasLeading] = React.useState(false);\n if (leading !== wasLeading) {\n setWasLeading(leading);\n if (!leading) setReady(false);\n }\n const wanted = touch && leading && !saveData() && !matches(\"(prefers-reduced-motion: reduce)\");\n React.useEffect(() => {\n if (!wanted) return;\n const t = setTimeout(() => setReady(true), delay);\n return () => clearTimeout(t);\n }, [wanted, delay]);\n\n return { live: enabled && (causes.size > 0 || (wanted && ready)), arm, disarm };\n}\n"
|
|
351
532
|
},
|
|
352
533
|
{
|
|
353
534
|
"path": "meta.ts",
|
|
354
|
-
"content": "// The documentation of a component, carried by its code: each primitive has a `meta.ts` beside its source\n// (`src/<name>/meta.ts`; a component that exists only in React Native: `src/native/meta/<name>.ts`). Never built into\n// the package: `scripts/build-registry.mjs` reads them into `registry/<name>.json`, which ui.krizaka.com (apps/site) renders as\n// ui.krizaka.com/components/<name>, and `scripts/registry.test.mjs` fails when one is missing or incomplete.\n//\n// Why a typed module and not JSDoc tags: the fields are lists and links (`related`, `whenNotToUse[].use`, examples),\n// which a type checks and a comment does not; the file is plain data (erasable TypeScript), so Node reads it without a\n// compiler, and an editor completes it.\n\n/** Every documented component: the web primitives (one entry of the package each) and the native-only ones. */\nexport type ComponentName =\n | \"achievement\"\n | \"action-bar\"\n | \"activity-feed\"\n | \"action-sheet\"\n | \"alert\"\n | \"app-shell\"\n | \"argument-card\"\n | \"audio-player\"\n | \"auth-layout\"\n | \"avatar\"\n | \"badge\"\n | \"button\"\n | \"card\"\n | \"card-grid\"\n | \"catalogue-page\"\n | \"changelog-page\"\n | \"chat\"\n | \"checkbox\"\n | \"chip\"\n | \"chip-link\"\n | \"choice-grid\"\n | \"cn\"\n | \"code-block\"\n | \"combobox\"\n | \"command\"\n | \"component-page\"\n | \"confirm-button\"\n | \"contact-page\"\n | \"continue-prompt\"\n | \"countdown\"\n | \"cta-panel\"\n | \"dashboard-layout\"\n | \"data-table\"\n | \"date-picker\"\n | \"decision-list\"\n | \"description-list\"\n | \"detail-layout\"\n | \"dialog\"\n | \"docs-article-page\"\n | \"docs-hub-page\"\n | \"docs-layout\"\n | \"dropdown-menu\"\n | \"empty-state\"\n | \"error-page\"\n | \"fab\"\n | \"field\"\n | \"file-upload\"\n | \"filter-bar\"\n | \"form\"\n | \"gallery-page\"\n | \"glow-card\"\n | \"goal-gauge\"\n | \"header\"\n | \"how-it-works-page\"\n | \"document-viewer\"\n | \"image-cropper\"\n | \"image-viewer\"\n | \"iso\"\n | \"kanban\"\n | \"kbd\"\n | \"landing-page\"\n | \"landscape\"\n | \"legal-page\"\n | \"link-card\"\n | \"list\"\n | \"loader\"\n | \"map\"\n | \"markdown\"\n | \"longform-page\"\n | \"marketing-layout\"\n | \"marquee\"\n | \"mascot-quote\"\n | \"mascot-state\"\n | \"mascots\"\n | \"match-dialog\"\n | \"media-capture\"\n | \"media-grid\"\n | \"media-picker\"\n | \"meter\"\n | \"nav-bar\"\n | \"number-ticker\"\n | \"onboarding\"\n | \"otp-input\"\n | \"page\"\n | \"page-header\"\n | \"page-hero\"\n | \"pagination\"\n | \"permission-prompt\"\n | \"photo-grid\"\n | \"pointer-light\"\n | \"popover\"\n | \"pricing-page\"\n | \"product-page\"\n | \"profile-card\"\n | \"progress\"\n | \"promise-card\"\n | \"pull-to-refresh\"\n | \"radio-group\"\n | \"recipes\"\n | \"screen\"\n | \"scroll-stage\"\n | \"search-field\"\n | \"section-backdrop\"\n | \"segmented\"\n | \"separator\"\n | \"settings-layout\"\n | \"showcase\"\n | \"site-footer\"\n | \"sites\"\n | \"skeleton\"\n | \"skip-link\"\n | \"slider\"\n | \"slot\"\n | \"spinner\"\n | \"split-view\"\n | \"splash-handoff\"\n | \"stage-banner\"\n | \"stat\"\n | \"status-page\"\n | \"step-progress\"\n | \"stepper\"\n | \"swipe-deck\"\n | \"swipeable-row\"\n | \"switch\"\n | \"tab-bar\"\n | \"tabs\"\n | \"tag-input\"\n | \"tag-selector\"\n | \"terminal-replay\"\n | \"theme\"\n | \"toast\"\n | \"toc\"\n | \"tooltip\"\n | \"tree-view\"\n | \"txt\"\n | \"verified-badge\"\n | \"vertical-video\"\n | \"video-player\"\n | \"video-trimmer\"\n | \"virtual\";\n\n/** Where a component exists: `@krizaka/ui/<name>` (web), `@krizaka/ui/native` (React Native), or both. */\nexport type Platform = \"web\" | \"native\" | \"both\";\n\n/**\n * `stable`: its API only changes in a major. `beta`: it may still change in a minor. `deprecated`: no product uses it and\n * no work plans to — it still works, says what replaces it, and goes in the major named by `deprecated.removedIn`.\n */\nexport type Status = \"stable\" | \"beta\" | \"deprecated\";\n\n/** What a deprecated component says (required when `status` is `deprecated`, absent otherwise). */\nexport interface Deprecation {\n /** The version that deprecated it (`3.6.0`). */\n readonly since: string;\n /** The major that removes it (`4.0.0`). */\n readonly removedIn: string;\n /** What replaces it, when a component does. */\n readonly use?: ComponentName;\n /** Why it goes: no product uses it, no planned work needs it. */\n readonly reason: string;\n}\n\n/**\n * The group of the catalogue. `media`: video, sound, camera, files, cropping. `blocks`: the page blocks — the frame,\n * the hero, sections, cards that lead or argue, decisions, code, calls to act — that every page of a site is made of.\n * `layouts`: the shells of whole screens — an application, a dashboard, settings, sign-in, an object, a list and its\n * item, a site's page, a documentation page — each with its screens. `templates`: the page templates — whole pages of a\n * site (a landing, a product, a docs hub, a long read, a changelog, pricing, status, an error…), each a distinct\n * composition of the blocks, words and content as props.\n */\nexport type Category = \"actions\" | \"forms\" | \"navigation\" | \"overlays\" | \"feedback\" | \"data-display\" | \"media\" | \"blocks\" | \"layouts\" | \"templates\" | \"layout\" | \"foundations\";\n\n/** Why a reader needs a template: the seven intents the templates are grouped by (sidebar sub-headings, catalogue filter). */\nexport type TemplatePurpose = \"convince\" | \"explain\" | \"show\" | \"act\" | \"operate\" | \"converse\" | \"recover\";\n\n/** The groups of templates, in reading order: the title, and the one line that says when to come to that group. */\nexport const templatePurposes = {\n convince: { title: \"Convince\", useWhen: \"You come here when a visitor must decide: a landing, a product, a price, a comparison, a proof.\" },\n explain: { title: \"Explain\", useWhen: \"You come here when a reader must understand: how it works, a documentation hub or article, a changelog, a FAQ, a policy.\" },\n show: { title: \"Show\", useWhen: \"You come here when the work itself is the argument: a gallery, a story, a case study, an object and its details.\" },\n act: { title: \"Act\", useWhen: \"You come here when a person must do something once: reach the team, sign in or up, set up, pay.\" },\n operate: { title: \"Operate\", useWhen: \"You come here when a person works every day: a dashboard, a console with a table, settings, an app shell.\" },\n converse: { title: \"Converse\", useWhen: \"You come here when people talk: an inbox, a chat, comments, a notifications centre.\" },\n recover: { title: \"Recover\", useWhen: \"You come here when the way is blocked: an empty list, an error, a missing page, maintenance, a product still being built.\" },\n} as const satisfies Record<TemplatePurpose, { readonly title: string; readonly useWhen: string }>;\n\n/**\n * A named example: the file `registry/examples/<component>/<name>.tsx` (web) or\n * `registry/examples/<component>/native/<name>.tsx` (React Native) — at most 40 lines, imported by the stories and\n * shown (live, with its code) by the documentation.\n */\ninterface ExampleBase {\n /** The file name, kebab-case (`primary`, `icon-button`). */\n readonly name: string;\n /** A short title (`Primary`, `Icon button`). */\n readonly title: string;\n /** One or two sentences: when this variant is the right one. */\n readonly description: string;\n}\n\n/** A plain example, or a template: a whole screen with its intent (`purpose`) and the user goal its layout serves (`why`). */\nexport type Example =\n | (ExampleBase & { readonly template?: false; readonly purpose?: undefined; readonly why?: undefined })\n | (ExampleBase & {\n /**\n * A template: a whole screen of a product composed from the platform (a layout and its blocks, real words, its\n * phone version thought out), copied as a starting point — at most 160 lines, listed on ui.krizaka.com/templates.\n */\n readonly template: true;\n /** The intent that groups it: why you need this block. */\n readonly purpose: TemplatePurpose;\n /** Why this layout: the user goal it serves, in one sentence. */\n readonly why: string;\n });\n\n/** One platform's side of a component. */\nexport interface PlatformDocs {\n /** The names to import (`[\"Button\", \"IconButton\"]`): from `@krizaka/ui/<name>` on the web, `@krizaka/ui/native` in React Native. */\n readonly imports: readonly string[];\n /** The examples, in reading order; the first one is the component's preview. */\n readonly examples: readonly Example[];\n}\n\n/** The React Native side: the same concept, and what differs from the web API. */\nexport interface NativeDocs extends PlatformDocs {\n /**\n * The entry to import from when it is not `@krizaka/ui/native`: a component that needs an Expo module (an optional\n * peer) has its own, `@krizaka/ui/native/<name>`, so an app that does not use it never installs the module.\n */\n readonly entry?: string;\n /** What differs from the web API (`label` instead of children…); empty for a native-only component. */\n readonly differences: readonly string[];\n}\n\nexport interface KeyboardInteraction {\n /** The keys, as written on a keyboard: `Space`, `Enter`, `Arrow keys`, `Esc`, `Tab`. */\n readonly keys: string;\n /** What they do. */\n readonly action: string;\n}\n\nexport interface ComponentMeta {\n /** The display name: `Button`, `Dropdown menu`. */\n readonly title: string;\n /** One sentence: what it is. */\n readonly summary: string;\n readonly status: Status;\n /** Required for, and only for, a `deprecated` component. */\n readonly deprecated?: Deprecation;\n readonly category: Category;\n /** Must match the sides below: `both` ⇔ `web` and `native`. */\n readonly platforms: Platform;\n /** When to reach for it: concrete situations, one per item. */\n readonly whenToUse: readonly string[];\n /** When not to — and what to use instead (`use`), when another component is the answer. */\n readonly whenNotToUse: readonly { readonly when: string; readonly use?: ComponentName }[];\n /** How to use it well: wording, placement, composition. */\n readonly bestPractices: readonly string[];\n readonly accessibility: {\n /** The keyboard, when it does more than Tab and Enter. */\n readonly keyboard: readonly KeyboardInteraction[];\n /** Roles, names and announcements: what the component does, what the app must provide. */\n readonly notes: readonly string[];\n };\n /** The components a reader of this page should know about. */\n readonly related: readonly ComponentName[];\n readonly web?: PlatformDocs;\n readonly native?: NativeDocs;\n}\n"
|
|
535
|
+
"content": "// The documentation of a component, carried by its code: each primitive has a `meta.ts` beside its source\n// (`src/<name>/meta.ts`; a component that exists only in React Native: `src/native/meta/<name>.ts`). Never built into\n// the package: `scripts/build-registry.mjs` reads them into `registry/<name>.json`, which ui.krizaka.com (apps/site) renders as\n// ui.krizaka.com/components/<name>, and `scripts/registry.test.mjs` fails when one is missing or incomplete.\n//\n// Why a typed module and not JSDoc tags: the fields are lists and links (`related`, `whenNotToUse[].use`, examples),\n// which a type checks and a comment does not; the file is plain data (erasable TypeScript), so Node reads it without a\n// compiler, and an editor completes it.\n\n/** Every documented component: the web primitives (one entry of the package each) and the native-only ones. */\nexport type ComponentName =\n | \"achievement\"\n | \"action-bar\"\n | \"activity-feed\"\n | \"action-sheet\"\n | \"alert\"\n | \"app-shell\"\n | \"argument-card\"\n | \"audio-player\"\n | \"auth-layout\"\n | \"avatar\"\n | \"badge\"\n | \"button\"\n | \"card\"\n | \"card-grid\"\n | \"catalogue-page\"\n | \"changelog-page\"\n | \"chat\"\n | \"checkbox\"\n | \"chip\"\n | \"chip-link\"\n | \"choice-grid\"\n | \"cn\"\n | \"code-block\"\n | \"combobox\"\n | \"command\"\n | \"component-page\"\n | \"confirm-button\"\n | \"contact-page\"\n | \"continue-prompt\"\n | \"countdown\"\n | \"cta-panel\"\n | \"dashboard-layout\"\n | \"data-table\"\n | \"date-picker\"\n | \"decision-list\"\n | \"description-list\"\n | \"detail-layout\"\n | \"dialog\"\n | \"docs-article-page\"\n | \"docs-hub-page\"\n | \"docs-layout\"\n | \"dropdown-menu\"\n | \"empty-state\"\n | \"error-page\"\n | \"fab\"\n | \"field\"\n | \"file-upload\"\n | \"filter-bar\"\n | \"form\"\n | \"gallery-page\"\n | \"glow-card\"\n | \"goal-gauge\"\n | \"header\"\n | \"how-it-works-page\"\n | \"document-viewer\"\n | \"image-cropper\"\n | \"image-viewer\"\n | \"iso\"\n | \"kanban\"\n | \"kbd\"\n | \"landing-page\"\n | \"landscape\"\n | \"legal-page\"\n | \"link-card\"\n | \"list\"\n | \"loader\"\n | \"map\"\n | \"markdown\"\n | \"longform-page\"\n | \"marketing-layout\"\n | \"marquee\"\n | \"mascot-quote\"\n | \"mascot-state\"\n | \"mascots\"\n | \"match-dialog\"\n | \"media-capture\"\n | \"media-grid\"\n | \"media-picker\"\n | \"meter\"\n | \"nav-bar\"\n | \"number-ticker\"\n | \"onboarding\"\n | \"otp-input\"\n | \"page\"\n | \"page-header\"\n | \"page-hero\"\n | \"pagination\"\n | \"permission-prompt\"\n | \"photo-grid\"\n | \"pointer-light\"\n | \"popover\"\n | \"pricing-page\"\n | \"product-page\"\n | \"profile-card\"\n | \"progress\"\n | \"promise-card\"\n | \"pull-to-refresh\"\n | \"radio-group\"\n | \"recipes\"\n | \"screen\"\n | \"scroll-stage\"\n | \"search-field\"\n | \"section-backdrop\"\n | \"segmented\"\n | \"separator\"\n | \"settings-layout\"\n | \"showcase\"\n | \"site-footer\"\n | \"sites\"\n | \"skeleton\"\n | \"skip-link\"\n | \"slider\"\n | \"slot\"\n | \"spinner\"\n | \"split-view\"\n | \"splash-handoff\"\n | \"stage-banner\"\n | \"stat\"\n | \"status-page\"\n | \"step-progress\"\n | \"stepper\"\n | \"swipe-deck\"\n | \"swipeable-row\"\n | \"switch\"\n | \"tab-bar\"\n | \"tabs\"\n | \"tag-input\"\n | \"tag-selector\"\n | \"terminal-replay\"\n | \"theme\"\n | \"toast\"\n | \"toc\"\n | \"tooltip\"\n | \"tree-view\"\n | \"txt\"\n | \"verified-badge\"\n | \"vertical-video\"\n | \"video-player\"\n | \"video-trimmer\"\n | \"virtual\";\n\n/** Where a component exists: `@krizaka/ui/<name>` (web), `@krizaka/ui/native` (React Native), or both. */\nexport type Platform = \"web\" | \"native\" | \"both\";\n\n/**\n * Why a component is not on a platform — a decision, never a silence (the parity matrix of ui.krizaka.com/platforms):\n * - `gap` (✗): it belongs there and is not built yet; `issue` is the issue that tracks it (`krizaka/krizaka-ui#275`);\n * - `n/a`: it is not meant for that platform (a site's page block on a phone, a swipe on a desktop); `use` names the\n * component that answers there, when one does.\n */\nexport type Absence =\n | { readonly status: \"gap\"; readonly issue: string; readonly reason: string; readonly use?: undefined }\n | { readonly status: \"n/a\"; readonly reason: string; readonly use?: ComponentName; readonly issue?: undefined };\n\n/**\n * Desktop is the Orazaka Mac app (Tauri 2: the system WebView renders the web components — ADR-002). `web`: the web\n * component runs there as it is; `desktop`: it also has a desktop form (a window's title bar, a denser layout…), said in\n * its documentation; otherwise the absence and why.\n */\nexport type DesktopSupport = \"web\" | \"desktop\" | Absence;\n\n/**\n * The three platforms of Krizaka UI — web · native (React Native, Expo) · desktop — for one component. The side a\n * component has is `platforms`; the side it lacks says why here: `native` is required for (and only for) a web-only\n * component, `web` for a native-only one; `desktop` always. `scripts/registry.test.mjs` fails on a missing decision.\n */\nexport interface Parity {\n readonly web?: Absence;\n readonly native?: Absence;\n readonly desktop: DesktopSupport;\n}\n\n/**\n * `stable`: its API only changes in a major. `beta`: it may still change in a minor. `deprecated`: no product uses it and\n * no work plans to — it still works, says what replaces it, and goes in the major named by `deprecated.removedIn`.\n */\nexport type Status = \"stable\" | \"beta\" | \"deprecated\";\n\n/** What a deprecated component says (required when `status` is `deprecated`, absent otherwise). */\nexport interface Deprecation {\n /** The version that deprecated it (`3.6.0`). */\n readonly since: string;\n /** The major that removes it (`4.0.0`). */\n readonly removedIn: string;\n /** What replaces it, when a component does. */\n readonly use?: ComponentName;\n /** Why it goes: no product uses it, no planned work needs it. */\n readonly reason: string;\n}\n\n/**\n * The group of the catalogue. `media`: video, sound, camera, files, cropping. `blocks`: the page blocks — the frame,\n * the hero, sections, cards that lead or argue, decisions, code, calls to act — that every page of a site is made of.\n * `layouts`: the shells of whole screens — an application, a dashboard, settings, sign-in, an object, a list and its\n * item, a site's page, a documentation page — each with its screens. `templates`: the page templates — whole pages of a\n * site (a landing, a product, a docs hub, a long read, a changelog, pricing, status, an error…), each a distinct\n * composition of the blocks, words and content as props.\n */\nexport type Category = \"actions\" | \"forms\" | \"navigation\" | \"overlays\" | \"feedback\" | \"data-display\" | \"media\" | \"blocks\" | \"layouts\" | \"templates\" | \"layout\" | \"foundations\";\n\n/** Why a reader needs a template: the seven intents the templates are grouped by (sidebar sub-headings, catalogue filter). */\nexport type TemplatePurpose = \"convince\" | \"explain\" | \"show\" | \"act\" | \"operate\" | \"converse\" | \"recover\";\n\n/** The groups of templates, in reading order: the title, and the one line that says when to come to that group. */\nexport const templatePurposes = {\n convince: { title: \"Convince\", useWhen: \"You come here when a visitor must decide: a landing, a product, a price, a comparison, a proof.\" },\n explain: { title: \"Explain\", useWhen: \"You come here when a reader must understand: how it works, a documentation hub or article, a changelog, a FAQ, a policy.\" },\n show: { title: \"Show\", useWhen: \"You come here when the work itself is the argument: a gallery, a story, a case study, an object and its details.\" },\n act: { title: \"Act\", useWhen: \"You come here when a person must do something once: reach the team, sign in or up, set up, pay.\" },\n operate: { title: \"Operate\", useWhen: \"You come here when a person works every day: a dashboard, a console with a table, settings, an app shell.\" },\n converse: { title: \"Converse\", useWhen: \"You come here when people talk: an inbox, a chat, comments, a notifications centre.\" },\n recover: { title: \"Recover\", useWhen: \"You come here when the way is blocked: an empty list, an error, a missing page, maintenance, a product still being built.\" },\n} as const satisfies Record<TemplatePurpose, { readonly title: string; readonly useWhen: string }>;\n\n/**\n * A named example: the file `registry/examples/<component>/<name>.tsx` (web) or\n * `registry/examples/<component>/native/<name>.tsx` (React Native) — at most 40 lines, imported by the stories and\n * shown (live, with its code) by the documentation.\n */\ninterface ExampleBase {\n /** The file name, kebab-case (`primary`, `icon-button`). */\n readonly name: string;\n /** A short title (`Primary`, `Icon button`). */\n readonly title: string;\n /** One or two sentences: when this variant is the right one. */\n readonly description: string;\n}\n\n/** A plain example, or a template: a whole screen with its intent (`purpose`) and the user goal its layout serves (`why`). */\nexport type Example =\n | (ExampleBase & { readonly template?: false; readonly purpose?: undefined; readonly why?: undefined })\n | (ExampleBase & {\n /**\n * A template: a whole screen of a product composed from the platform (a layout and its blocks, real words, its\n * phone version thought out), copied as a starting point — at most 160 lines, listed on ui.krizaka.com/templates.\n */\n readonly template: true;\n /** The intent that groups it: why you need this block. */\n readonly purpose: TemplatePurpose;\n /** Why this layout: the user goal it serves, in one sentence. */\n readonly why: string;\n });\n\n/** One platform's side of a component. */\nexport interface PlatformDocs {\n /** The names to import (`[\"Button\", \"IconButton\"]`): from `@krizaka/ui/<name>` on the web, `@krizaka/ui/native` in React Native. */\n readonly imports: readonly string[];\n /** The examples, in reading order; the first one is the component's preview. */\n readonly examples: readonly Example[];\n}\n\n/** The React Native side: the same concept, and what differs from the web API. */\nexport interface NativeDocs extends PlatformDocs {\n /**\n * The entry to import from when it is not `@krizaka/ui/native`: a component that needs an Expo module (an optional\n * peer) has its own, `@krizaka/ui/native/<name>`, so an app that does not use it never installs the module.\n */\n readonly entry?: string;\n /** What differs from the web API (`label` instead of children…); empty for a native-only component. */\n readonly differences: readonly string[];\n}\n\nexport interface KeyboardInteraction {\n /** The keys, as written on a keyboard: `Space`, `Enter`, `Arrow keys`, `Esc`, `Tab`. */\n readonly keys: string;\n /** What they do. */\n readonly action: string;\n}\n\nexport interface ComponentMeta {\n /** The display name: `Button`, `Dropdown menu`. */\n readonly title: string;\n /** One sentence: what it is. */\n readonly summary: string;\n readonly status: Status;\n /** Required for, and only for, a `deprecated` component. */\n readonly deprecated?: Deprecation;\n readonly category: Category;\n /** Must match the sides below: `both` ⇔ `web` and `native`. */\n readonly platforms: Platform;\n /** The platforms it is not on, and why; and its desktop form (see `Parity`). */\n readonly parity: Parity;\n /** When to reach for it: concrete situations, one per item. */\n readonly whenToUse: readonly string[];\n /** When not to — and what to use instead (`use`), when another component is the answer. */\n readonly whenNotToUse: readonly { readonly when: string; readonly use?: ComponentName }[];\n /** How to use it well: wording, placement, composition. */\n readonly bestPractices: readonly string[];\n readonly accessibility: {\n /** The keyboard, when it does more than Tab and Enter. */\n readonly keyboard: readonly KeyboardInteraction[];\n /** Roles, names and announcements: what the component does, what the app must provide. */\n readonly notes: readonly string[];\n };\n /** The components a reader of this page should know about. */\n readonly related: readonly ComponentName[];\n readonly web?: PlatformDocs;\n readonly native?: NativeDocs;\n}\n"
|
|
355
536
|
}
|
|
356
537
|
],
|
|
357
538
|
"dependencies": [],
|
|
358
539
|
"registryDependencies": [
|
|
359
540
|
"avatar",
|
|
541
|
+
"button",
|
|
360
542
|
"card",
|
|
361
|
-
"cn"
|
|
543
|
+
"cn",
|
|
544
|
+
"skeleton",
|
|
545
|
+
"spinner"
|
|
362
546
|
],
|
|
363
547
|
"demo": {
|
|
364
548
|
"path": "examples/media-grid/feed.tsx",
|
|
365
549
|
"content": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { MediaGrid, VideoCard } from \"@krizaka/ui/media-grid\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site\";\nconst WIDE = `${CDN}/products/orazaka/gallery`;\nconst TALL = `${CDN}/docs/orochia/mobile`;\nconst CLIPS = [\n { id: \"tea\", title: \"Pouring the mint tea, slow\", poster: `${WIDE}/tea-pour.d69c3a5700.webp`, tall: false },\n { id: \"story\", title: \"Backstage, a minute before\", poster: `${TALL}/clip-feed-stories-poster.fd7835fa74.webp`, tall: true },\n { id: \"snow\", title: \"First snow on rue Saint-Denis\", poster: `${WIDE}/mtl-snow.e3ecb20e82.webp`, tall: false },\n { id: \"watch\", title: \"The unlock in one tap\", poster: `${TALL}/clip-watch-poster.9fc5d19c39.webp`, tall: true },\n { id: \"desert\", title: \"Desert camp at blue hour\", poster: `${WIDE}/desert-camp.78c6c0d26c.webp`, tall: false },\n { id: \"thanks\", title: \"Three ways to say thanks\", poster: `${TALL}/clip-unlock-poster.57e13fee5f.webp`, tall: true },\n];\n\n// 'split' (the feed default): vertical clips on a Shorts shelf, the others in a 16:9 grid whose cards share one height.\nexport default function MediaGridFeed() {\n return (\n <MediaGrid\n items={CLIPS}\n getId={(c) => c.id}\n orientationOf={(c) => (c.tall ? \"portrait\" : \"landscape\")}\n shelf={{ label: \"Shorts\", previousLabel: \"Previous shorts\", nextLabel: \"Next shorts\", seeAll: { href: \"#shorts\", label: \"See all\" } }}\n renderItem={(c, { aspect, layout }) => (\n <VideoCard href={`#${c.id}`} title={c.title} aspect={aspect} layout={layout} poster={c.poster} duration=\"0:12\" meta=\"12.4K views\" creator={{ name: \"Camille\" }} badges={<Badge tone=\"success\" size=\"sm\" className=\"bg-scrim\">Free</Badge>} />\n )}\n />\n );\n}\n"
|
|
366
550
|
},
|
|
367
551
|
"props": [
|
|
552
|
+
{
|
|
553
|
+
"component": "LoadMore",
|
|
554
|
+
"description": "Infinite scroll with a visible \"Load more\" under it.",
|
|
555
|
+
"props": [
|
|
556
|
+
{
|
|
557
|
+
"name": "hasMore",
|
|
558
|
+
"type": "boolean",
|
|
559
|
+
"default": null,
|
|
560
|
+
"description": "There is a next page.",
|
|
561
|
+
"required": true
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
"name": "label",
|
|
565
|
+
"type": "string",
|
|
566
|
+
"default": null,
|
|
567
|
+
"description": "The button, passed translated (\"Load more\").",
|
|
568
|
+
"required": true
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
"name": "loadingLabel",
|
|
572
|
+
"type": "string",
|
|
573
|
+
"default": null,
|
|
574
|
+
"description": "The wait, passed translated (\"Loading more videos\").",
|
|
575
|
+
"required": true
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
"name": "onLoadMore",
|
|
579
|
+
"type": "() => void",
|
|
580
|
+
"default": null,
|
|
581
|
+
"description": "Asks for the next page. Not called while `loading`, nor once `hasMore` is false.",
|
|
582
|
+
"required": true
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
"name": "endLabel",
|
|
586
|
+
"type": "string",
|
|
587
|
+
"default": null,
|
|
588
|
+
"description": "Shown instead of the button when the list is over, passed translated (\"You have seen everything\"). Nothing if absent.",
|
|
589
|
+
"required": false
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
"name": "failed",
|
|
593
|
+
"type": "boolean",
|
|
594
|
+
"default": "false",
|
|
595
|
+
"description": "The last page failed: the button reads `retryLabel`, and nothing loads by itself until it is pressed.",
|
|
596
|
+
"required": false
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
"name": "loading",
|
|
600
|
+
"type": "boolean",
|
|
601
|
+
"default": "false",
|
|
602
|
+
"description": "A page is on its way.",
|
|
603
|
+
"required": false
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
"name": "retryLabel",
|
|
607
|
+
"type": "string",
|
|
608
|
+
"default": null,
|
|
609
|
+
"description": "The button after a failure, passed translated (\"Try again\"). Defaults to `label`.",
|
|
610
|
+
"required": false
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
"name": "rootMargin",
|
|
614
|
+
"type": "string",
|
|
615
|
+
"default": "900px 0px",
|
|
616
|
+
"description": "How far below the viewport the page is already requested (an IntersectionObserver `rootMargin`).",
|
|
617
|
+
"required": false
|
|
618
|
+
}
|
|
619
|
+
]
|
|
620
|
+
},
|
|
368
621
|
{
|
|
369
622
|
"component": "MediaGrid",
|
|
370
623
|
"description": "A media grid that never mixes heights badly: a shelf for the 9:16, a uniform grid for the rest.",
|
|
@@ -605,11 +858,86 @@
|
|
|
605
858
|
"description": "The picture.",
|
|
606
859
|
"required": false
|
|
607
860
|
},
|
|
861
|
+
{
|
|
862
|
+
"name": "posterPriority",
|
|
863
|
+
"type": "\"auto\" | \"high\" | \"low\"",
|
|
864
|
+
"default": "auto",
|
|
865
|
+
"description": "`fetchPriority` of the poster: `high` for the first row (the largest contentful paint).",
|
|
866
|
+
"required": false
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
"name": "previewDelay",
|
|
870
|
+
"type": "number",
|
|
871
|
+
"default": "200",
|
|
872
|
+
"description": "How long the pointer must rest before the preview is requested, in ms: a pointer crossing the grid loads nothing.",
|
|
873
|
+
"required": false
|
|
874
|
+
},
|
|
608
875
|
{
|
|
609
876
|
"name": "previewSrc",
|
|
610
877
|
"type": "string | null",
|
|
611
878
|
"default": null,
|
|
612
|
-
"description": "An animated picture (WebP)
|
|
879
|
+
"description": "An animated picture (WebP, a few hundred kB) that fades in over the poster once the pointer has rested on the card\n(or it is focused) for `previewDelay`, and is removed again on leave. It is never requested before. On a phone (no\nhover) only the card most inside the viewport plays it, unless the person asked for less data or less motion.",
|
|
880
|
+
"required": false
|
|
881
|
+
}
|
|
882
|
+
]
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
"component": "MediaGridSkeleton",
|
|
886
|
+
"description": "The placeholder of a `MediaGrid`: the same shelf and grid, skeleton cards, one polite announcement.",
|
|
887
|
+
"props": [
|
|
888
|
+
{
|
|
889
|
+
"name": "label",
|
|
890
|
+
"type": "string",
|
|
891
|
+
"default": null,
|
|
892
|
+
"description": "What is loading, passed translated (\"Loading videos\"): announced once, the cards are hidden from assistive technology.",
|
|
893
|
+
"required": true
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
"name": "count",
|
|
897
|
+
"type": "number",
|
|
898
|
+
"default": "8",
|
|
899
|
+
"description": "Cards in the grid.",
|
|
900
|
+
"required": false
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
"name": "frame",
|
|
904
|
+
"type": "VideoCardAspect",
|
|
905
|
+
"default": "landscape",
|
|
906
|
+
"description": "The frame of the grid.",
|
|
907
|
+
"required": false
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
"name": "layout",
|
|
911
|
+
"type": "\"grid\" | \"list\"",
|
|
912
|
+
"default": "grid",
|
|
913
|
+
"description": "`grid` or `list`.",
|
|
914
|
+
"required": false
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
"name": "shelfCount",
|
|
918
|
+
"type": "number",
|
|
919
|
+
"default": "0",
|
|
920
|
+
"description": "Cards on the Shorts shelf above the grid (`split` mode); 0 for none.",
|
|
921
|
+
"required": false
|
|
922
|
+
}
|
|
923
|
+
]
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
"component": "VideoCardSkeleton",
|
|
927
|
+
"description": "The placeholder of a `VideoCard` (decorative: announce the wait on the list, with `MediaGridSkeleton`).",
|
|
928
|
+
"props": [
|
|
929
|
+
{
|
|
930
|
+
"name": "aspect",
|
|
931
|
+
"type": "VideoCardAspect",
|
|
932
|
+
"default": "landscape",
|
|
933
|
+
"description": "`landscape` (16:9) or `portrait` (9:16), as the card it stands for.",
|
|
934
|
+
"required": false
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
"name": "layout",
|
|
938
|
+
"type": "\"row\" | \"stack\"",
|
|
939
|
+
"default": "stack",
|
|
940
|
+
"description": "`stack` or `row`, as the card it stands for.",
|
|
613
941
|
"required": false
|
|
614
942
|
}
|
|
615
943
|
]
|