@krizaka/ui 3.44.0 → 3.46.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/README.md +1 -1
- package/dist/form.d.ts +28 -28
- package/dist/form.js +12 -12
- package/dist/form.js.map +1 -1
- package/dist/meter.d.ts +14 -12
- package/dist/meter.js +46 -26
- package/dist/meter.js.map +1 -1
- package/dist/motion.css +13 -0
- package/dist/motion.d.ts +15 -2
- package/dist/motion.js +17 -0
- package/dist/motion.js.map +1 -1
- package/dist/native/form.d.ts +28 -28
- package/dist/native/form.js +11 -11
- package/dist/native/form.js.map +1 -1
- package/dist/native/vertical-video.d.ts +38 -30
- package/dist/native/vertical-video.js +5 -1
- package/dist/native/vertical-video.js.map +1 -1
- package/dist/native.d.ts +9 -7
- package/dist/native.js +36 -17
- package/dist/native.js.map +1 -1
- package/dist/nav-bar.d.ts +41 -1
- package/dist/nav-bar.js +26 -1
- package/dist/nav-bar.js.map +1 -1
- package/dist/vertical-video.d.ts +28 -24
- package/dist/vertical-video.js +4 -2
- package/dist/vertical-video.js.map +1 -1
- package/package.json +6 -6
- package/registry/elephant.json +1 -1
- package/registry/form.json +34 -34
- package/registry/index.json +7 -6
- package/registry/map.json +1 -1
- package/registry/meter.json +8 -4
- package/registry/nav-bar.json +92 -4
- package/registry/rail-nav.json +1 -1
- package/registry/site-footer.json +1 -1
- package/registry/vertical-video.json +22 -18
package/registry/nav-bar.json
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"bestPractices": [
|
|
49
|
+
"A product's bar ends with `ProductBarActions`: the primary action is using the app (« Sign in » while sign-up is closed, « Try » once it opens, from the product's `signupOpen` setting), « Sign in » never twice, and no « Contact » or « Talk to us » — contact is a plain link in the footer. krizaka.com, the company site, keeps its « Contact us ».",
|
|
49
50
|
"Five items at most on the bar; group the rest in a menu, each link with a name and one short line (`NavBar.MenuLink`, one line, ≤ 45 characters).",
|
|
50
51
|
"A product's site (dev.<product>, see Navigation in the family): the product's mark alone (no `NavBar.Family`, no badge — the version goes in the footer), then **Product ▾** (what it does: how it works, its features, the demo) · **Platforms ▾** (web, mobile, desktop, self-host — omitted for a single-platform product) · **Docs** · Pricing when it exists; never a lone entry that belongs to a group (no top-level Demo, Mobile or Mac). The phone panel repeats the groups with `NavBar.MobileGroup`, in the same order.",
|
|
51
52
|
"A menu of three links or fewer is not a menu: `NavBar.Menu` puts them in the bar as plain links (the current one marked, no dropdown) — `inlineUpTo`, 3 by default, `0` to keep a dropdown. Above three, the dropdown. The phone panel groups them with `NavBar.MobileGroup` either way.",
|
|
@@ -111,7 +112,7 @@
|
|
|
111
112
|
],
|
|
112
113
|
"web": {
|
|
113
114
|
"entry": "@krizaka/ui/nav-bar",
|
|
114
|
-
"import": "import { NavBar } from \"@krizaka/ui/nav-bar\";",
|
|
115
|
+
"import": "import { NavBar, ProductBarActions, productBarActions } from \"@krizaka/ui/nav-bar\";",
|
|
115
116
|
"props": [
|
|
116
117
|
{
|
|
117
118
|
"component": "NavBarRoot",
|
|
@@ -445,6 +446,47 @@
|
|
|
445
446
|
"required": false
|
|
446
447
|
}
|
|
447
448
|
]
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
"component": "ProductBarActions",
|
|
452
|
+
"description": "The product bar's actions: use the app. Put it in `NavBar.Actions` (`placement=\"bar\"`) or in `NavBar.Mobile` (`placement=\"panel\"`).",
|
|
453
|
+
"props": [
|
|
454
|
+
{
|
|
455
|
+
"name": "signIn",
|
|
456
|
+
"type": "ProductBarLink",
|
|
457
|
+
"default": null,
|
|
458
|
+
"description": "Where « Sign in » leads, and its words.",
|
|
459
|
+
"required": true
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
"name": "signupOpen",
|
|
463
|
+
"type": "boolean",
|
|
464
|
+
"default": null,
|
|
465
|
+
"description": "Whether the product accepts new accounts (its `signupOpen` setting): « Try » is the primary action only then.",
|
|
466
|
+
"required": true
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
"name": "linkAs",
|
|
470
|
+
"type": "ElementType<any, keyof IntrinsicElements>",
|
|
471
|
+
"default": "a",
|
|
472
|
+
"description": "The app's link component (Next's `Link`); an `<a>` otherwise.",
|
|
473
|
+
"required": false
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
"name": "placement",
|
|
477
|
+
"type": "\"bar\" | \"panel\"",
|
|
478
|
+
"default": "bar",
|
|
479
|
+
"description": "`bar` (from `md`, the buttons of the bar) or `panel` (the mobile panel: full-width, the primary last).",
|
|
480
|
+
"required": false
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
"name": "tryIt",
|
|
484
|
+
"type": "ProductBarLink",
|
|
485
|
+
"default": null,
|
|
486
|
+
"description": "« Try for free » (the first audit is free), the founder offer: absent when the product has none.",
|
|
487
|
+
"required": false
|
|
488
|
+
}
|
|
489
|
+
]
|
|
448
490
|
}
|
|
449
491
|
],
|
|
450
492
|
"examples": [
|
|
@@ -598,11 +640,11 @@
|
|
|
598
640
|
},
|
|
599
641
|
{
|
|
600
642
|
"path": "nav-bar/index.ts",
|
|
601
|
-
"content": "/** @krizaka/ui/nav-bar — `NavBar` (Root, Brand, Family, Badge, Items, Link, Menu, MenuLink, Actions, Repo, Mobile, MobileGroup, MobileLink), `navBar`. Client. */\nexport {\n NavBar,\n navBar,\n NavBarActions,\n NavBarBadge,\n type NavBarBadgeProps,\n NavBarBrand,\n type NavBarBrandProps,\n NavBarFamily,\n type NavBarFamilyProps,\n NavBarItems,\n type NavBarItemsProps,\n NavBarLanguage,\n type NavBarLanguageProps,\n NavBarLink,\n type NavBarLinkProps,\n NavBarMenu,\n NavBarMenuLink,\n type NavBarMenuLinkProps,\n type NavBarMenuProps,\n NavBarMobile,\n NavBarMobileGroup,\n type NavBarMobileGroupProps,\n NavBarMobileLink,\n type NavBarMobileLinkProps,\n NavBarMobilePreferences,\n type NavBarMobilePreferencesProps,\n type NavBarMobileProps,\n NavBarRepo,\n type NavBarRepoProps,\n NavBarRoot,\n type NavBarRootProps,\n} from \"./nav-bar\";\nexport { type PlacePanel,placePanel } from \"./placement\";\n"
|
|
643
|
+
"content": "/** @krizaka/ui/nav-bar — `NavBar` (Root, Brand, Family, Badge, Items, Link, Menu, MenuLink, Actions, Repo, Mobile, MobileGroup, MobileLink), `navBar`. Client. */\nexport {\n NavBar,\n navBar,\n NavBarActions,\n NavBarBadge,\n type NavBarBadgeProps,\n NavBarBrand,\n type NavBarBrandProps,\n NavBarFamily,\n type NavBarFamilyProps,\n NavBarItems,\n type NavBarItemsProps,\n NavBarLanguage,\n type NavBarLanguageProps,\n NavBarLink,\n type NavBarLinkProps,\n NavBarMenu,\n NavBarMenuLink,\n type NavBarMenuLinkProps,\n type NavBarMenuProps,\n NavBarMobile,\n NavBarMobileGroup,\n type NavBarMobileGroupProps,\n NavBarMobileLink,\n type NavBarMobileLinkProps,\n NavBarMobilePreferences,\n type NavBarMobilePreferencesProps,\n type NavBarMobileProps,\n NavBarRepo,\n type NavBarRepoProps,\n NavBarRoot,\n type NavBarRootProps,\n} from \"./nav-bar\";\nexport { type PlacePanel,placePanel } from \"./placement\";\nexport { ProductBarAction, ProductBarActions, productBarActions, type ProductBarActionsProps, type ProductBarLink, type ProductBarState } from \"./product-actions\";\n"
|
|
602
644
|
},
|
|
603
645
|
{
|
|
604
646
|
"path": "nav-bar/meta.ts",
|
|
605
|
-
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Nav bar\",\n summary:\n \"The Krizaka navigation bar, the same on every site of the family: a floating pill that tightens as the page scrolls, the family switcher on the brand, one light that glides to the item under the pointer or the focus and rests on the current page, accessible menus with rich links, and a mobile panel.\",\n status: \"beta\",\n category: \"navigation\",\n platforms: \"web\",\n parity: { native: { status: \"n/a\", reason: \"A site's top bar; an app on a phone navigates with its tab bar.\", use: \"tab-bar\" }, desktop: { status: \"n/a\", reason: \"A public site's page, not a window of the Mac app.\" } },\n whenToUse: [\n \"The top navigation of a site or a product: a brand, a few links, one or two menus, actions on the right.\",\n \"A section bar inside an app (`floating={false}`): videos, earnings, auctions.\",\n \"Every site of the Krizaka family (www, ui., java., dev.<product>, admin.<product>): the brand and `NavBar.Family` on the left, at most four of the site's sections in the middle, the tools on the right.\",\n ],\n whenNotToUse: [\n { when: \"To switch views inside a page without changing the address.\", use: \"tabs\" },\n { when: \"For a list of actions on one object.\", use: \"dropdown-menu\" },\n { when: \"For search and commands across the app.\", use: \"command\" },\n { when: \"To link to another site of the family from the bar's middle: the family switcher holds them.\", use: \"sites\" },\n ],\n bestPractices: [\n \"Five items at most on the bar; group the rest in a menu, each link with a name and one short line (`NavBar.MenuLink`, one line, ≤ 45 characters).\",\n \"A product's site (dev.<product>, see Navigation in the family): the product's mark alone (no `NavBar.Family`, no badge — the version goes in the footer), then **Product ▾** (what it does: how it works, its features, the demo) · **Platforms ▾** (web, mobile, desktop, self-host — omitted for a single-platform product) · **Docs** · Pricing when it exists; never a lone entry that belongs to a group (no top-level Demo, Mobile or Mac). The phone panel repeats the groups with `NavBar.MobileGroup`, in the same order.\",\n \"A menu of three links or fewer is not a menu: `NavBar.Menu` puts them in the bar as plain links (the current one marked, no dropdown) — `inlineUpTo`, 3 by default, `0` to keep a dropdown. Above three, the dropdown. The phone panel groups them with `NavBar.MobileGroup` either way.\",\n \"Light one item at most: the group that holds the page (`active` on its `NavBar.Menu`), never two.\",\n \"Mark the current page with `active`: the light rests on it, `aria-current=\\\"page\\\"` says it.\",\n \"Use your router's link with `asChild` on `NavBar.Link`, `NavBar.MenuLink` and `NavBar.MobileLink`.\",\n \"One primary action at most in `NavBar.Actions` (a `Button` `size=\\\"sm\\\"` `shape=\\\"pill\\\"`).\",\n \"Wrap it in a `sticky top-3` (floating) or `sticky top-0` container to keep it on screen.\",\n \"Three zones on every Krizaka site: who I am (`NavBar.Brand` + `NavBar.Family current=\\\"<site>\\\"`, then `NavBar.Badge` with the version or `DEV · v1.2.3`), this site (at most four sections, never a link to another site), tools (search when there is something to search, `NavBar.Repo`, theme, language, one primary action).\",\n \"The language is `NavBar.Language`, the same on every site: a globe and the current code (`FR`) opening a menu of the languages named in their own language (`languagesFor(pathname)`), never a pill naming the other language. In `NavBar.Mobile` it renders as a row, inside `NavBar.MobilePreferences` with the theme.\",\n \"Menu links: a name and a description that wraps on two lines at most (≤ 90 characters); the panel is 28 rem wide (40 rem in two columns), aligned under its trigger and kept inside the page.\",\n \"Place a second `NavBar.Family` as the last child of `NavBar.Mobile`: below `md` the family is the panel's last group.\",\n \"Pass the family's words translated: `labels={familyLabels[locale]}` from `@krizaka/ui/sites`.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Moves between the brand, the items and the actions; leaving an open menu closes it.\" },\n { keys: \"Enter / Space / ↓\", action: \"Opens a menu and moves into it.\" },\n { keys: \"↑ / ↓ · Home / End\", action: \"Walks the links of an open menu.\" },\n { keys: \"← / →\", action: \"Moves between the items of the bar.\" },\n { keys: \"Esc\", action: \"Closes the menu, the family or the mobile panel and returns the focus to its trigger.\" },\n { keys: \"Click outside\", action: \"Closes the open menu or the mobile panel.\" },\n { keys: \"Enter / Space / ↓ (family)\", action: \"Opens the family from its chevron; ↑ / ↓ walk the sites. It never opens on hover.\" },\n ],\n notes: [\n \"A `nav` named by `label`, a list of links and menu triggers (Radix NavigationMenu): `aria-expanded` and `aria-controls` on each trigger.\",\n \"The mobile panel is a disclosure (no focus trap): its toggle is named by `openLabel` / `closeLabel` and says `aria-expanded`; a press outside closes it. `NavBar.MobileGroup` is a `group` named by its heading.\",\n \"Menus open on click, Enter, Space or ↓, or when the pointer rests on the trigger (`delayDuration`, 200 ms) — passing over the bar opens nothing. While a menu is open the light stays on its trigger; moving on to a plain link closes the menu.\",\n \"The light, the chevron and the tightening do not move under `prefers-reduced-motion`; the current page is tinted before scripts load.\",\n \"The family is its own `nav` named by `labels.family`; its chevron is a button with the same name, `aria-expanded` and `aria-controls`. Each group is a list named by its heading, the current site says `aria-current=\\\"true\\\"` and “You are here”, a site not released says its status in words.\",\n \"`NavBar.Repo` is an icon link named by `label`; `NavBar.Badge` is plain text (`DEV · v1.2.3`), hidden below `sm`.\",\n ],\n },\n related: [\"language-switch\", \"sites\", \"site-footer\", \"tabs\", \"dropdown-menu\", \"command\", \"button\"],\n web: {\n imports: [\"NavBar\"],\n examples: [\n { name: \"family-www\", title: \"www.krizaka.com\", description: \"The company's bar: the mark and the family switcher, Products · Open source · Our story, the repository and Contact. Open the chevron: every site of Krizaka, grouped, the current one marked.\" },\n { name: \"family-ui\", title: \"ui.krizaka.com\", description: \"Krizaka UI: the family, the version of the packages, Get started · Foundations · Components · Templates, the repository.\" },\n { name: \"family-java\", title: \"java.krizaka.com\", description: \"Krizaka Java: the family, the version of the building blocks, Get started · Building blocks · Guides.\" },\n { name: \"family-product\", title: \"dev.<product>\", description: \"A product's site, the standard of every Krizaka product: its mark alone, Product ▾ · Platforms ▾ · Docs with menus (icon · title · a description of two lines), the repository, the theme, the language, Sign in and “Talk to us”; the phone panel groups the same entries and ends with the preferences.\" },\n { name: \"family-admin\", title: \"admin.<product>\", description: \"A console: the mark, the family and `DEV`, no sections, a full-width bar.\" },\n { name: \"site\", title: \"A site's bar\", description: \"Brand, a Products menu, links, a call to action, a mobile panel — hover the items: one light glides between them.\" },\n { name: \"menu-open\", title: \"A rich menu\", description: \"Two columns of links with icons whose node lights up, the current page tinted.\" },\n { name: \"app-bar\", title: \"Inside an app\", description: \"`floating={false}`: a full-width bar with a hairline, for an app's sections.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
|
|
647
|
+
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Nav bar\",\n summary:\n \"The Krizaka navigation bar, the same on every site of the family: a floating pill that tightens as the page scrolls, the family switcher on the brand, one light that glides to the item under the pointer or the focus and rests on the current page, accessible menus with rich links, and a mobile panel.\",\n status: \"beta\",\n category: \"navigation\",\n platforms: \"web\",\n parity: { native: { status: \"n/a\", reason: \"A site's top bar; an app on a phone navigates with its tab bar.\", use: \"tab-bar\" }, desktop: { status: \"n/a\", reason: \"A public site's page, not a window of the Mac app.\" } },\n whenToUse: [\n \"The top navigation of a site or a product: a brand, a few links, one or two menus, actions on the right.\",\n \"A section bar inside an app (`floating={false}`): videos, earnings, auctions.\",\n \"Every site of the Krizaka family (www, ui., java., dev.<product>, admin.<product>): the brand and `NavBar.Family` on the left, at most four of the site's sections in the middle, the tools on the right.\",\n ],\n whenNotToUse: [\n { when: \"To switch views inside a page without changing the address.\", use: \"tabs\" },\n { when: \"For a list of actions on one object.\", use: \"dropdown-menu\" },\n { when: \"For search and commands across the app.\", use: \"command\" },\n { when: \"To link to another site of the family from the bar's middle: the family switcher holds them.\", use: \"sites\" },\n ],\n bestPractices: [\n \"A product's bar ends with `ProductBarActions`: the primary action is using the app (« Sign in » while sign-up is closed, « Try » once it opens, from the product's `signupOpen` setting), « Sign in » never twice, and no « Contact » or « Talk to us » — contact is a plain link in the footer. krizaka.com, the company site, keeps its « Contact us ».\",\n \"Five items at most on the bar; group the rest in a menu, each link with a name and one short line (`NavBar.MenuLink`, one line, ≤ 45 characters).\",\n \"A product's site (dev.<product>, see Navigation in the family): the product's mark alone (no `NavBar.Family`, no badge — the version goes in the footer), then **Product ▾** (what it does: how it works, its features, the demo) · **Platforms ▾** (web, mobile, desktop, self-host — omitted for a single-platform product) · **Docs** · Pricing when it exists; never a lone entry that belongs to a group (no top-level Demo, Mobile or Mac). The phone panel repeats the groups with `NavBar.MobileGroup`, in the same order.\",\n \"A menu of three links or fewer is not a menu: `NavBar.Menu` puts them in the bar as plain links (the current one marked, no dropdown) — `inlineUpTo`, 3 by default, `0` to keep a dropdown. Above three, the dropdown. The phone panel groups them with `NavBar.MobileGroup` either way.\",\n \"Light one item at most: the group that holds the page (`active` on its `NavBar.Menu`), never two.\",\n \"Mark the current page with `active`: the light rests on it, `aria-current=\\\"page\\\"` says it.\",\n \"Use your router's link with `asChild` on `NavBar.Link`, `NavBar.MenuLink` and `NavBar.MobileLink`.\",\n \"One primary action at most in `NavBar.Actions` (a `Button` `size=\\\"sm\\\"` `shape=\\\"pill\\\"`).\",\n \"Wrap it in a `sticky top-3` (floating) or `sticky top-0` container to keep it on screen.\",\n \"Three zones on every Krizaka site: who I am (`NavBar.Brand` + `NavBar.Family current=\\\"<site>\\\"`, then `NavBar.Badge` with the version or `DEV · v1.2.3`), this site (at most four sections, never a link to another site), tools (search when there is something to search, `NavBar.Repo`, theme, language, one primary action).\",\n \"The language is `NavBar.Language`, the same on every site: a globe and the current code (`FR`) opening a menu of the languages named in their own language (`languagesFor(pathname)`), never a pill naming the other language. In `NavBar.Mobile` it renders as a row, inside `NavBar.MobilePreferences` with the theme.\",\n \"Menu links: a name and a description that wraps on two lines at most (≤ 90 characters); the panel is 28 rem wide (40 rem in two columns), aligned under its trigger and kept inside the page.\",\n \"Place a second `NavBar.Family` as the last child of `NavBar.Mobile`: below `md` the family is the panel's last group.\",\n \"Pass the family's words translated: `labels={familyLabels[locale]}` from `@krizaka/ui/sites`.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Moves between the brand, the items and the actions; leaving an open menu closes it.\" },\n { keys: \"Enter / Space / ↓\", action: \"Opens a menu and moves into it.\" },\n { keys: \"↑ / ↓ · Home / End\", action: \"Walks the links of an open menu.\" },\n { keys: \"← / →\", action: \"Moves between the items of the bar.\" },\n { keys: \"Esc\", action: \"Closes the menu, the family or the mobile panel and returns the focus to its trigger.\" },\n { keys: \"Click outside\", action: \"Closes the open menu or the mobile panel.\" },\n { keys: \"Enter / Space / ↓ (family)\", action: \"Opens the family from its chevron; ↑ / ↓ walk the sites. It never opens on hover.\" },\n ],\n notes: [\n \"A `nav` named by `label`, a list of links and menu triggers (Radix NavigationMenu): `aria-expanded` and `aria-controls` on each trigger.\",\n \"The mobile panel is a disclosure (no focus trap): its toggle is named by `openLabel` / `closeLabel` and says `aria-expanded`; a press outside closes it. `NavBar.MobileGroup` is a `group` named by its heading.\",\n \"Menus open on click, Enter, Space or ↓, or when the pointer rests on the trigger (`delayDuration`, 200 ms) — passing over the bar opens nothing. While a menu is open the light stays on its trigger; moving on to a plain link closes the menu.\",\n \"The light, the chevron and the tightening do not move under `prefers-reduced-motion`; the current page is tinted before scripts load.\",\n \"The family is its own `nav` named by `labels.family`; its chevron is a button with the same name, `aria-expanded` and `aria-controls`. Each group is a list named by its heading, the current site says `aria-current=\\\"true\\\"` and “You are here”, a site not released says its status in words.\",\n \"`NavBar.Repo` is an icon link named by `label`; `NavBar.Badge` is plain text (`DEV · v1.2.3`), hidden below `sm`.\",\n ],\n },\n related: [\"language-switch\", \"sites\", \"site-footer\", \"tabs\", \"dropdown-menu\", \"command\", \"button\"],\n web: {\n imports: [\"NavBar\", \"ProductBarActions\", \"productBarActions\"],\n examples: [\n { name: \"family-www\", title: \"www.krizaka.com\", description: \"The company's bar: the mark and the family switcher, Products · Open source · Our story, the repository and Contact. Open the chevron: every site of Krizaka, grouped, the current one marked.\" },\n { name: \"family-ui\", title: \"ui.krizaka.com\", description: \"Krizaka UI: the family, the version of the packages, Get started · Foundations · Components · Templates, the repository.\" },\n { name: \"family-java\", title: \"java.krizaka.com\", description: \"Krizaka Java: the family, the version of the building blocks, Get started · Building blocks · Guides.\" },\n { name: \"family-product\", title: \"dev.<product>\", description: \"A product's site, the standard of every Krizaka product: its mark alone, Product ▾ · Platforms ▾ · Docs with menus (icon · title · a description of two lines), the repository, the theme, the language, Sign in and “Talk to us”; the phone panel groups the same entries and ends with the preferences.\" },\n { name: \"family-admin\", title: \"admin.<product>\", description: \"A console: the mark, the family and `DEV`, no sections, a full-width bar.\" },\n { name: \"site\", title: \"A site's bar\", description: \"Brand, a Products menu, links, a call to action, a mobile panel — hover the items: one light glides between them.\" },\n { name: \"menu-open\", title: \"A rich menu\", description: \"Two columns of links with icons whose node lights up, the current page tinted.\" },\n { name: \"app-bar\", title: \"Inside an app\", description: \"`floating={false}`: a full-width bar with a hairline, for an app's sections.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
|
|
606
648
|
},
|
|
607
649
|
{
|
|
608
650
|
"path": "nav-bar/nav-bar.tsx",
|
|
@@ -612,17 +654,22 @@
|
|
|
612
654
|
"path": "nav-bar/placement.ts",
|
|
613
655
|
"content": "/**\n * Where a menu panel of the bar sits: its left edge under its trigger's, pushed back inside the page when it would run\n * past the right edge, never closer to the left edge than the gutter. All values are pixels of the page (a bounding\n * rect's `left`, a width), the result is the panel's `left` relative to the bar's navigation (`root`). Pure, so a\n * viewport of 360 px and one of 1280 px are tested without a browser.\n */\nexport interface PlacePanel {\n /** The trigger's left edge. */\n trigger: number;\n /** The navigation's left edge (the panel is positioned inside it). */\n root: number;\n /** The panel's width. */\n panel: number;\n /** The window's width. */\n window: number;\n /**\n * The space kept between the panel and the window's edges.\n * @default 16\n */\n gutter?: number;\n}\n\nexport function placePanel({ trigger, root, panel, window, gutter = 16 }: PlacePanel): number {\n const wanted = trigger - root;\n const farthest = window - gutter - panel - root;\n const nearest = gutter - root;\n return Math.round(Math.max(nearest, Math.min(wanted, farthest)));\n}\n"
|
|
614
656
|
},
|
|
657
|
+
{
|
|
658
|
+
"path": "nav-bar/product-actions.tsx",
|
|
659
|
+
"content": "// Client (the Button). The right side of a product's bar, decided once for every product (« the objective is to use the app,\n// not to talk to us »): the primary action is using the app — « Sign in » while sign-up is closed, « Try » once it opens —\n// never a duplicate « Sign in » beside it, never « Contact » (that is a plain link in the footer).\nimport type * as React from \"react\";\n\nimport { Button } from \"../button/button\";\n\n/** The two ways into a product. */\nexport const ProductBarAction = { SignIn: \"sign-in\", Try: \"try\" } as const;\nexport type ProductBarAction = (typeof ProductBarAction)[keyof typeof ProductBarAction];\n\nexport interface ProductBarState {\n /** Whether the product accepts new accounts (its `signupOpen` setting). */\n signupOpen: boolean;\n /** Whether the product has a « try it » action (a free first audit, a founder offer). */\n hasTry: boolean;\n}\n\n/** What the bar shows: one primary, and « Sign in » beside it only when it is not the primary. */\nexport function productBarActions({ signupOpen, hasTry }: ProductBarState): { primary: ProductBarAction; secondary: ProductBarAction | null } {\n return signupOpen && hasTry ? { primary: ProductBarAction.Try, secondary: ProductBarAction.SignIn } : { primary: ProductBarAction.SignIn, secondary: null };\n}\n\nexport interface ProductBarLink {\n href: string;\n /** Passed translated. */\n label: string;\n}\n\nexport type ProductBarActionsProps = {\n /** Where « Sign in » leads, and its words. */\n signIn: ProductBarLink;\n /** « Try for free » (the first audit is free), the founder offer: absent when the product has none. */\n tryIt?: ProductBarLink;\n /** Whether the product accepts new accounts (its `signupOpen` setting): « Try » is the primary action only then. */\n signupOpen: boolean;\n /** The app's link component (Next's `Link`); an `<a>` otherwise. */\n linkAs?: React.ElementType;\n /**\n * `bar` (from `md`, the buttons of the bar) or `panel` (the mobile panel: full-width, the primary last).\n * @default \"bar\"\n */\n placement?: \"bar\" | \"panel\";\n};\n\n/** The product bar's actions: use the app. Put it in `NavBar.Actions` (`placement=\"bar\"`) or in `NavBar.Mobile` (`placement=\"panel\"`). */\nexport function ProductBarActions({ signIn, tryIt, signupOpen, linkAs: Link = \"a\", placement = \"bar\" }: ProductBarActionsProps) {\n const { primary, secondary } = productBarActions({ signupOpen, hasTry: Boolean(tryIt) });\n const link = (kind: ProductBarAction) => (kind === ProductBarAction.Try && tryIt ? tryIt : signIn);\n const panel = placement === \"panel\";\n const button = (kind: ProductBarAction, variant: \"primary\" | \"ghost\") => (\n <Button key={kind} asChild variant={variant} size={panel ? \"md\" : \"sm\"} className={panel ? (variant === \"primary\" ? \"mt-2 w-full font-bold\" : \"w-full\") : variant === \"primary\" ? \"ml-1 hidden font-bold md:inline-flex\" : \"hidden md:inline-flex\"}>\n <Link href={link(kind).href}>{link(kind).label}</Link>\n </Button>\n );\n return (\n <>\n {secondary ? button(secondary, \"ghost\") : null}\n {button(primary, \"primary\")}\n </>\n );\n}\n"
|
|
660
|
+
},
|
|
615
661
|
{
|
|
616
662
|
"path": "sliding-indicator.ts",
|
|
617
663
|
"content": "// The shared sliding indicator (NavBar, Tabs): one element that glides from item to item instead of each item lighting\n// up on its own. It writes the target's box as CSS variables on the list (`--kz-ind-x/y/w/h`) and `data-indicator=\"on\"`\n// — no re-render, no animation frame: the glide is a CSS transition (none under reduced motion). Before hydration the\n// items keep their own active style, so nothing flashes.\nimport * as React from \"react\";\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\n/**\n * `item`: the selector of an item; `active`: of the current one, where the light rests; `prefer`: an item that, when\n * present, holds the light before the current one (the NavBar's open menu); `follow`: the light follows the pointer\n * and the focus — over anything that is not an item (an open menu's panel) it goes back to rest.\n */\nexport function useSlidingIndicator<T extends HTMLElement>({ item, active, prefer, follow = false }: { item: string; active: string; prefer?: string; follow?: boolean }) {\n const ref = React.useRef<T>(null);\n const place = React.useCallback((target: Element | null) => {\n const list = ref.current;\n if (!list) return;\n if (!(target instanceof HTMLElement) || !list.contains(target)) {\n list.removeAttribute(\"data-indicator\");\n return;\n }\n const a = target.getBoundingClientRect();\n const b = list.getBoundingClientRect();\n list.style.setProperty(\"--kz-ind-x\", `${a.left - b.left + list.scrollLeft}px`);\n list.style.setProperty(\"--kz-ind-y\", `${a.top - b.top + list.scrollTop}px`);\n list.style.setProperty(\"--kz-ind-w\", `${a.width}px`);\n list.style.setProperty(\"--kz-ind-h\", `${a.height}px`);\n list.setAttribute(\"data-indicator\", \"on\");\n }, []);\n const rest = React.useCallback(\n () => {\n const list = ref.current;\n // An item re-rendered under a resting pointer (a menu closing) keeps the light the pointer gave it.\n const hovered = follow ? list?.querySelector(`${item}:hover`) : null;\n place((prefer ? list?.querySelector(prefer) : null) ?? hovered ?? list?.querySelector(active) ?? null);\n },\n [place, active, prefer, item, follow],\n );\n const follow_ = React.useCallback((target: EventTarget) => {\n const hit = target instanceof Element ? target.closest(item) : null;\n if (hit && ref.current?.contains(hit)) place(hit);\n else rest();\n }, [item, place, rest]);\n\n useIsoLayoutEffect(() => {\n const list = ref.current;\n if (!list) return;\n rest();\n const resize = new ResizeObserver(rest);\n resize.observe(list);\n const changes = new MutationObserver(rest);\n changes.observe(list, { subtree: true, attributes: true, attributeFilter: [\"data-state\", \"aria-current\", \"data-active\"] });\n return () => {\n resize.disconnect();\n changes.disconnect();\n };\n }, [rest]);\n\n const handlers = follow\n ? {\n onPointerOver: (e: React.PointerEvent) => follow_(e.target),\n onPointerLeave: rest,\n onFocus: (e: React.FocusEvent) => follow_(e.target),\n onBlur: rest,\n }\n : {};\n return { ref, handlers };\n}\n"
|
|
618
664
|
}
|
|
619
665
|
],
|
|
620
666
|
"dependencies": [
|
|
621
|
-
"@krizaka/tokens@^3.
|
|
667
|
+
"@krizaka/tokens@^3.46.0",
|
|
622
668
|
"radix-ui@^1.7.0",
|
|
623
669
|
"tailwind-variants@^3.3.1"
|
|
624
670
|
],
|
|
625
671
|
"registryDependencies": [
|
|
672
|
+
"button",
|
|
626
673
|
"cn",
|
|
627
674
|
"language-switch",
|
|
628
675
|
"sites"
|
|
@@ -964,6 +1011,47 @@
|
|
|
964
1011
|
"required": false
|
|
965
1012
|
}
|
|
966
1013
|
]
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
"component": "ProductBarActions",
|
|
1017
|
+
"description": "The product bar's actions: use the app. Put it in `NavBar.Actions` (`placement=\"bar\"`) or in `NavBar.Mobile` (`placement=\"panel\"`).",
|
|
1018
|
+
"props": [
|
|
1019
|
+
{
|
|
1020
|
+
"name": "signIn",
|
|
1021
|
+
"type": "ProductBarLink",
|
|
1022
|
+
"default": null,
|
|
1023
|
+
"description": "Where « Sign in » leads, and its words.",
|
|
1024
|
+
"required": true
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
"name": "signupOpen",
|
|
1028
|
+
"type": "boolean",
|
|
1029
|
+
"default": null,
|
|
1030
|
+
"description": "Whether the product accepts new accounts (its `signupOpen` setting): « Try » is the primary action only then.",
|
|
1031
|
+
"required": true
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
"name": "linkAs",
|
|
1035
|
+
"type": "ElementType<any, keyof IntrinsicElements>",
|
|
1036
|
+
"default": "a",
|
|
1037
|
+
"description": "The app's link component (Next's `Link`); an `<a>` otherwise.",
|
|
1038
|
+
"required": false
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
"name": "placement",
|
|
1042
|
+
"type": "\"bar\" | \"panel\"",
|
|
1043
|
+
"default": "bar",
|
|
1044
|
+
"description": "`bar` (from `md`, the buttons of the bar) or `panel` (the mobile panel: full-width, the primary last).",
|
|
1045
|
+
"required": false
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
"name": "tryIt",
|
|
1049
|
+
"type": "ProductBarLink",
|
|
1050
|
+
"default": null,
|
|
1051
|
+
"description": "« Try for free » (the first audit is free), the founder offer: absent when the product has none.",
|
|
1052
|
+
"required": false
|
|
1053
|
+
}
|
|
1054
|
+
]
|
|
967
1055
|
}
|
|
968
1056
|
]
|
|
969
1057
|
}
|
package/registry/rail-nav.json
CHANGED
|
@@ -377,49 +377,49 @@
|
|
|
377
377
|
"name": "aboveCaption",
|
|
378
378
|
"type": "ReactNode",
|
|
379
379
|
"default": null,
|
|
380
|
-
"description": "Above the caption: a goal gauge.",
|
|
380
|
+
"description": "Above the caption: a goal gauge, a challenge's progress.",
|
|
381
381
|
"required": false
|
|
382
382
|
},
|
|
383
383
|
{
|
|
384
384
|
"name": "active",
|
|
385
385
|
"type": "boolean",
|
|
386
386
|
"default": "true",
|
|
387
|
-
"description": "Plays while `true
|
|
387
|
+
"description": "Plays while `true`; a `ShortFeed` passes it for the slide in view. Default `true`.",
|
|
388
388
|
"required": false
|
|
389
389
|
},
|
|
390
390
|
{
|
|
391
391
|
"name": "caption",
|
|
392
392
|
"type": "string",
|
|
393
393
|
"default": null,
|
|
394
|
-
"description": "The caption
|
|
394
|
+
"description": "The caption: collapsed to two lines, expandable when long.",
|
|
395
395
|
"required": false
|
|
396
396
|
},
|
|
397
397
|
{
|
|
398
398
|
"name": "comments",
|
|
399
399
|
"type": "number",
|
|
400
400
|
"default": null,
|
|
401
|
-
"description": "The comment count.",
|
|
401
|
+
"description": "The comment count under its button.",
|
|
402
402
|
"required": false
|
|
403
403
|
},
|
|
404
404
|
{
|
|
405
405
|
"name": "credit",
|
|
406
406
|
"type": "string",
|
|
407
407
|
"default": null,
|
|
408
|
-
"description": "The sound or credit line.",
|
|
408
|
+
"description": "The sound or credit line (\"Original sound — Elena\").",
|
|
409
409
|
"required": false
|
|
410
410
|
},
|
|
411
411
|
{
|
|
412
412
|
"name": "disclosure",
|
|
413
413
|
"type": "string",
|
|
414
414
|
"default": null,
|
|
415
|
-
"description": "A pill beside the name
|
|
415
|
+
"description": "A pill beside the name: \"AI-GENERATED\" — said on the picture, never only in a page's footnote.",
|
|
416
416
|
"required": false
|
|
417
417
|
},
|
|
418
418
|
{
|
|
419
419
|
"name": "following",
|
|
420
420
|
"type": "boolean",
|
|
421
421
|
"default": "false",
|
|
422
|
-
"description": "Whether the viewer follows the creator.",
|
|
422
|
+
"description": "Whether the viewer follows the creator: the \"+\" on the avatar becomes a check.",
|
|
423
423
|
"required": false
|
|
424
424
|
},
|
|
425
425
|
{
|
|
@@ -433,14 +433,14 @@
|
|
|
433
433
|
"name": "liked",
|
|
434
434
|
"type": "boolean",
|
|
435
435
|
"default": "false",
|
|
436
|
-
"description": "Whether the viewer likes it.",
|
|
436
|
+
"description": "Whether the viewer likes it: `aria-pressed` on the like button.",
|
|
437
437
|
"required": false
|
|
438
438
|
},
|
|
439
439
|
{
|
|
440
440
|
"name": "likes",
|
|
441
441
|
"type": "number",
|
|
442
442
|
"default": null,
|
|
443
|
-
"description": "The like count, compact in `locale`.",
|
|
443
|
+
"description": "The like count under its button, compact (`12.4K`) in `locale`.",
|
|
444
444
|
"required": false
|
|
445
445
|
},
|
|
446
446
|
{
|
|
@@ -454,28 +454,28 @@
|
|
|
454
454
|
"name": "onComment",
|
|
455
455
|
"type": "(() => void)",
|
|
456
456
|
"default": null,
|
|
457
|
-
"description": "Opens the comments.",
|
|
457
|
+
"description": "Opens the comments; without it the button still shows.",
|
|
458
458
|
"required": false
|
|
459
459
|
},
|
|
460
460
|
{
|
|
461
461
|
"name": "onFollow",
|
|
462
462
|
"type": "((following: boolean) => void)",
|
|
463
463
|
"default": null,
|
|
464
|
-
"description": "
|
|
464
|
+
"description": "The viewer follows or unfollows; without it the avatar has no \"+\".",
|
|
465
465
|
"required": false
|
|
466
466
|
},
|
|
467
467
|
{
|
|
468
468
|
"name": "onLike",
|
|
469
469
|
"type": "((liked: boolean) => void)",
|
|
470
470
|
"default": null,
|
|
471
|
-
"description": "The viewer likes or unlikes.",
|
|
471
|
+
"description": "The viewer likes or unlikes (the button, `L`, a double tap).",
|
|
472
472
|
"required": false
|
|
473
473
|
},
|
|
474
474
|
{
|
|
475
475
|
"name": "onMore",
|
|
476
476
|
"type": "(() => void)",
|
|
477
477
|
"default": null,
|
|
478
|
-
"description": "The \"more\" action; without it the button is not drawn.",
|
|
478
|
+
"description": "The \"more\" button's action (report, hide); without it the button is not drawn.",
|
|
479
479
|
"required": false
|
|
480
480
|
},
|
|
481
481
|
{
|
|
@@ -496,7 +496,7 @@
|
|
|
496
496
|
"name": "shares",
|
|
497
497
|
"type": "number",
|
|
498
498
|
"default": null,
|
|
499
|
-
"description": "The share count.",
|
|
499
|
+
"description": "The share count under its button.",
|
|
500
500
|
"required": false
|
|
501
501
|
},
|
|
502
502
|
{
|
|
@@ -510,21 +510,21 @@
|
|
|
510
510
|
"name": "tags",
|
|
511
511
|
"type": "readonly string[]",
|
|
512
512
|
"default": "[]",
|
|
513
|
-
"description": "Tags
|
|
513
|
+
"description": "Tags under the caption (without `#`).",
|
|
514
514
|
"required": false
|
|
515
515
|
},
|
|
516
516
|
{
|
|
517
517
|
"name": "tipSlot",
|
|
518
518
|
"type": "ReactNode",
|
|
519
519
|
"default": null,
|
|
520
|
-
"description": "The product's own action on the rail
|
|
520
|
+
"description": "The product's own action on the rail: a tip, an unlock — a button of yours (≥ 44 px, named).",
|
|
521
521
|
"required": false
|
|
522
522
|
},
|
|
523
523
|
{
|
|
524
524
|
"name": "topStart",
|
|
525
525
|
"type": "ReactNode",
|
|
526
526
|
"default": null,
|
|
527
|
-
"description": "Top left over the picture
|
|
527
|
+
"description": "Top left: a chip over the picture (an auction's time left).",
|
|
528
528
|
"required": false
|
|
529
529
|
},
|
|
530
530
|
{
|
|
@@ -620,13 +620,17 @@
|
|
|
620
620
|
"path": "vertical-video/meta.ts",
|
|
621
621
|
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Vertical video\",\n summary: \"A 9:16 short video with the action rail of a short-video feed — follow, like, comments, share, your own tip button — and the creator's caption over the picture; `ShortFeed` stacks them as a snap-scrolling feed that plays the one in view.\",\n status: \"beta\",\n category: \"media\",\n platforms: \"both\",\n parity: { desktop: \"web\" },\n whenToUse: [\n \"A creator's short clips: a feed, stories, an explore grid that opens on a video.\",\n \"A phone-first product page where the video is the page (Orochia's feed, the home gallery's phone).\",\n \"With `tipSlot`, `topStart` (an auction's time left) and `aboveCaption` (a goal gauge) for what is the product's own.\",\n ],\n whenNotToUse: [\n { when: \"For a watch page with a seek bar, subtitles and speed: the full player.\", use: \"video-player\" },\n { when: \"For a sound without a picture.\", use: \"audio-player\" },\n ],\n bestPractices: [\n \"Give every word in `labels` and a `title` that names the clip; the rail's buttons are named, 44 px and say their state (`aria-pressed`).\",\n \"Counts are compact in the viewer's `locale` (12.4K): pass numbers, not strings.\",\n \"In a `ShortFeed`, pass `active` and `preload` from `renderItem`: the one in view plays, the next preloads, the rest pause and stay unmounted.\",\n \"Put product actions in slots, never a fork: a tip, an unlock, a report.\",\n \"In a box that has its own size (a bento tile, a grid cell), pass `fit=\\\"fill\\\"`: the clip covers the box edge to edge, never a small 9:16 floating in it.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Space / K\", action: \"Play or pause the focused video.\" },\n { keys: \"L\", action: \"Like it.\" },\n { keys: \"Arrow Up / Arrow Down\", action: \"In a feed: the previous or next video, the focus follows.\" },\n { keys: \"Tab\", action: \"The tap target, then the rail's buttons, then the caption's “More”.\" },\n ],\n notes: [\n \"Each video is an `article` named by `title`; the picture is one button (tap to pause or play, double tap to like) and the rail a named group.\",\n \"The progress hairline is a `progressbar` named by its label.\",\n \"Under reduced motion nothing autoplays: the poster and a play button; the like heart shows without growing.\",\n \"Over the picture everything reads on `scrim` and `fg-on-media`, which do not change with the theme.\",\n ],\n },\n related: [\"video-player\", \"profile-card\", \"avatar\"],\n web: {\n imports: [\"VerticalVideo\", \"ShortFeed\", \"verticalVideoLabels\"],\n examples: [\n { name: \"rail\", title: \"The rail and the caption\", description: \"Follow, like, comments, share and a tip button on the right; creator, caption, tags and credit bottom-left.\" },\n { name: \"feed\", title: \"A feed\", description: \"`ShortFeed`: one video per screen, the one in view plays, the next preloads; ↑ ↓ move.\" },\n ],\n },\n native: {\n entry: \"@krizaka/ui/native/vertical-video\",\n imports: [\"VerticalVideo\", \"ShortFeed\", \"verticalVideoLabels\"],\n differences: [\n \"Its own entry (`@krizaka/ui/native/vertical-video`): it plays on expo-video, an optional peer.\",\n \"`uri` and `posterUri` instead of `src` and `poster`; `creator.avatarUri`; a long press likes (there is no double click).\",\n \"`ShortFeed` pages a FlatList: give it the page's `height`; the slide in view is the viewable one.\",\n ],\n examples: [{ name: \"rail\", title: \"The rail and the caption\", description: \"The same composition on a phone: follow, like, comments, share and your tip button; caption bottom-left.\" }],\n },\n} satisfies ComponentMeta;"
|
|
622
622
|
},
|
|
623
|
+
{
|
|
624
|
+
"path": "vertical-video/model.ts",
|
|
625
|
+
"content": "// Headless: what the web (`vertical-video.tsx`) and the native (`native/vertical-video.tsx`) VerticalVideo share — the words,\n// the content and the callbacks. Each platform adds its media (`src`/`poster` · `uri`/`posterUri`) and its frame.\nimport type * as React from \"react\";\n\nexport interface VerticalVideoLabels {\n /** \"Play\" / \"Pause\" — the tap target over the picture. */\n play: string;\n pause: string;\n /** \"Like\" / \"Unlike\" (the button's name; the state is `aria-pressed`). */\n like: string;\n comment: string;\n share: string;\n more: string;\n /** \"Follow Elena\" — `{name}` is replaced by the creator's name. */\n follow: string;\n /** \"Following Elena\". */\n following: string;\n /** \"More\" / \"Less\" on a long caption. */\n expand: string;\n collapse: string;\n /** The progress bar's name (\"Progress\"). */\n progress: string;\n /** The rail's name (\"Actions\"). */\n actions: string;\n}\n\nexport const verticalVideoLabels: VerticalVideoLabels = {\n play: \"Play\",\n pause: \"Pause\",\n like: \"Like\",\n comment: \"Comments\",\n share: \"Share\",\n more: \"More\",\n follow: \"Follow {name}\",\n following: \"Following {name}\",\n expand: \"More\",\n collapse: \"Less\",\n progress: \"Progress\",\n actions: \"Actions\",\n};\n\n/** The props both platforms read: the same content, counts, state, callbacks and slots. */\nexport interface VerticalVideoShared {\n /** The video's accessible name (\"Video: Night shoot, part 2\"), passed translated. */\n title: string;\n /** Plays while `true`; a `ShortFeed` passes it for the slide in view. Default `true`. */\n active?: boolean;\n /** A pill beside the name: \"AI-GENERATED\" — said on the picture, never only in a page's footnote. */\n disclosure?: string;\n /** The caption: collapsed to two lines, expandable when long. */\n caption?: string;\n /** Tags under the caption (without `#`). */\n tags?: readonly string[];\n /** The sound or credit line (\"Original sound — Elena\"). */\n credit?: string;\n /** The like count under its button, compact (`12.4K`) in `locale`. */\n likes?: number;\n /** The comment count under its button. */\n comments?: number;\n /** The share count under its button. */\n shares?: number;\n /** Whether the viewer likes it: `aria-pressed` on the like button. */\n liked?: boolean;\n /** Whether the viewer follows the creator: the \"+\" on the avatar becomes a check. */\n following?: boolean;\n /** The viewer likes or unlikes (the button, `L`, a double tap). */\n onLike?: (liked: boolean) => void;\n /** Opens the comments; without it the button still shows. */\n onComment?: () => void;\n /** Opens the share sheet. */\n onShare?: () => void;\n /** The viewer follows or unfollows; without it the avatar has no \"+\". */\n onFollow?: (following: boolean) => void;\n /** The \"more\" button's action (report, hide); without it the button is not drawn. */\n onMore?: () => void;\n /** The product's own action on the rail: a tip, an unlock — a button of yours (≥ 44 px, named). */\n tipSlot?: React.ReactNode;\n /** Top left: a chip over the picture (an auction's time left). */\n topStart?: React.ReactNode;\n /** Above the caption: a goal gauge, a challenge's progress. */\n aboveCaption?: React.ReactNode;\n /** The locale of the counts. Default `en`. */\n locale?: string;\n /** Every word, passed translated. */\n labels?: Partial<VerticalVideoLabels>;\n}\n"
|
|
626
|
+
},
|
|
623
627
|
{
|
|
624
628
|
"path": "vertical-video/short-feed.tsx",
|
|
625
629
|
"content": "// Client (an observer, keys). A vertical feed of short videos: one slide per screen, CSS scroll-snap, the slide in view\n// plays and the others pause, the next one preloads, only the slides near the one in view are mounted (the rest keep their\n// height). ↑/↓ (and PageUp/PageDown) go to the previous / next, with the focus following.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\n\nexport interface ShortFeedProps<T> extends Omit<React.ComponentProps<\"section\">, \"children\"> {\n /** The feed's accessible name (\"Videos\"), passed translated. */\n label: string;\n /** The videos, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** One slide: `active` is true for the one in view, `preload` for the next, `near` false for the ones kept empty. */\n renderItem: (item: T, state: { active: boolean; preload: \"none\" | \"metadata\" | \"auto\"; index: number }) => React.ReactNode;\n /** The slides mounted around the one in view (before and after). Default 1; the others keep their height empty. */\n window?: number;\n /** Called when the slide in view changes. */\n onActiveChange?: (index: number, item: T) => void;\n}\n\n/** A vertical snap-scrolling feed that plays the video in view, preloads the next and pauses the rest. */\nexport function ShortFeed<T>({ label, items, getId, renderItem, window: around = 1, onActiveChange, className, onKeyDown, ...props }: ShortFeedProps<T>) {\n const root = React.useRef<HTMLElement>(null);\n const [active, setActive] = React.useState(0);\n const slides = React.useRef<(HTMLDivElement | null)[]>([]);\n\n React.useEffect(() => {\n const host = root.current;\n if (!host || typeof IntersectionObserver === \"undefined\") return;\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting || entry.intersectionRatio < 0.6) continue;\n const index = Number((entry.target as HTMLElement).dataset.index);\n setActive(index);\n }\n },\n { root: host, threshold: [0.6] },\n );\n for (const slide of slides.current) if (slide) observer.observe(slide);\n return () => observer.disconnect();\n }, [items.length]);\n\n const first = React.useRef(true);\n React.useEffect(() => {\n if (first.current) {\n first.current = false;\n return;\n }\n const item = items[active];\n if (item !== undefined) onActiveChange?.(active, item);\n // Only a change of the slide in view is an event.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active]);\n\n const go = (index: number) => {\n const target = slides.current[Math.max(0, Math.min(items.length - 1, index))];\n if (!target) return;\n target.scrollIntoView({ behavior: window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\", block: \"start\" });\n target.querySelector<HTMLElement>(\"article\")?.focus({ preventScroll: true });\n };\n\n return (\n <section\n ref={root}\n aria-label={label}\n aria-roledescription=\"carousel\"\n className={cn(\"h-dvh snap-y snap-mandatory overflow-y-auto overscroll-contain bg-surface-0 [scrollbar-width:none]\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"ArrowDown\" || event.key === \"PageDown\") {\n event.preventDefault();\n go(active + 1);\n } else if (event.key === \"ArrowUp\" || event.key === \"PageUp\") {\n event.preventDefault();\n go(active - 1);\n }\n }}\n {...props}\n >\n {items.map((item, index) => {\n const near = Math.abs(index - active) <= around;\n return (\n <div\n key={getId(item)}\n ref={(el) => {\n slides.current[index] = el;\n }}\n data-index={index}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${index + 1} / ${items.length}`}\n className=\"mx-auto flex h-full max-w-[calc(100dvh*9/16)] snap-start snap-always items-center\"\n >\n {near ? renderItem(item, { active: index === active, preload: index === active + 1 ? \"auto\" : index === active ? \"auto\" : \"none\", index }) : null}\n </div>\n );\n })}\n </section>\n );\n}\n"
|
|
626
630
|
},
|
|
627
631
|
{
|
|
628
632
|
"path": "vertical-video/vertical-video.tsx",
|
|
629
|
-
"content": "// Client (a video element, an observer, keys). A 9:16 clip filling its frame, with the action rail of a short-video feed on\n// the right (avatar + follow, like, comment, share, a slot for tip / unlock, more) and the caption block bottom-left. Every\n// word is a prop; counts are formatted with @krizaka/intl; everything over the picture reads on the invariant `scrim` /\n// `fg-on-media` tokens, so it is legible in both themes. Under reduced motion nothing autoplays: the poster and a play button.\nimport { formatCompact } from \"@krizaka/intl\";\nimport * as React from \"react\";\n\nimport { Avatar } from \"../avatar/avatar\";\nimport { IconButton } from \"../button/button\";\nimport { cn } from \"../cn\";\nimport { VerifiedBadge } from \"../verified-badge/verified-badge\";\n\nexport interface VerticalVideoLabels {\n /** \"Play\" / \"Pause\" — the tap target over the picture. */\n play: string;\n pause: string;\n /** \"Like\" / \"Unlike\" (the button's name; the state is `aria-pressed`). */\n like: string;\n comment: string;\n share: string;\n more: string;\n /** \"Follow Elena\" — `{name}` is replaced by the creator's name. */\n follow: string;\n /** \"Following Elena\". */\n following: string;\n /** \"More\" / \"Less\" on a long caption. */\n expand: string;\n collapse: string;\n /** The progress bar's name (\"Progress\"). */\n progress: string;\n /** The rail's name (\"Actions\"). */\n actions: string;\n}\n\nexport const verticalVideoLabels: VerticalVideoLabels = {\n play: \"Play\",\n pause: \"Pause\",\n like: \"Like\",\n comment: \"Comments\",\n share: \"Share\",\n more: \"More\",\n follow: \"Follow {name}\",\n following: \"Following {name}\",\n expand: \"More\",\n collapse: \"Less\",\n progress: \"Progress\",\n actions: \"Actions\",\n};\n\nexport interface VerticalVideoCreator {\n name: string;\n /** Their portrait. */\n avatarSrc?: string;\n /** The seal's words (\"Verified\"): shows the seal beside the name. */\n verifiedLabel?: string;\n}\n\nexport interface VerticalVideoProps extends Omit<React.ComponentProps<\"article\">, \"title\" | \"children\"> {\n /** The video's accessible name (\"Video: Night shoot, part 2\"), passed translated. */\n title: string;\n /** The clip — absent, the poster alone shows (MP4 or WebM; an HLS address only in Safari — use the player of `@krizaka/ui/video-player` for HLS). */\n src?: string;\n /** The picture until the first frame, and under reduced motion. */\n poster?: string;\n /** Plays while `true`; a `ShortFeed` passes it for the slide in view. Default `true`. */\n active?: boolean;\n /** The next slide's: `auto` loads it ahead, `none` waits. Default `metadata`. */\n preload?: \"none\" | \"metadata\" | \"auto\";\n /** Plays again at the end. Default `true`. */\n loop?: boolean;\n /** Starts muted (autoplay needs it). Default `true`. */\n muted?: boolean;\n /** Who made it: name, portrait, the seal's words. */\n creator: VerticalVideoCreator;\n /** A pill beside the name: \"AI-GENERATED\" — said on the picture, never only in a page's footnote. */\n disclosure?: string;\n /** The caption: collapsed to two lines, expandable when long. */\n caption?: string;\n /** Tags under the caption (without `#`). */\n tags?: readonly string[];\n /** The sound or credit line (\"Original sound — Elena\"). */\n credit?: string;\n /** The like count under its button, compact (`12.4K`) in `locale`. */\n likes?: number;\n /** The comment count under its button. */\n comments?: number;\n /** The share count under its button. */\n shares?: number;\n /** Whether the viewer likes it: `aria-pressed` on the like button. */\n liked?: boolean;\n /** Whether the viewer follows the creator: the \"+\" on the avatar becomes a check. */\n following?: boolean;\n /** The viewer likes or unlikes (the button, `L`, a double tap). */\n onLike?: (liked: boolean) => void;\n /** Opens the comments; without it the button still shows. */\n onComment?: () => void;\n /** Opens the share sheet. */\n onShare?: () => void;\n /** The viewer follows or unfollows; without it the avatar has no \"+\". */\n onFollow?: (following: boolean) => void;\n /** The \"more\" button's action (report, hide); without it the button is not drawn. */\n onMore?: () => void;\n /** The product's own action on the rail: a tip, an unlock — a button of yours (≥ 44 px, named). */\n tipSlot?: React.ReactNode;\n /** Top left: a chip over the picture (an auction's time left). */\n topStart?: React.ReactNode;\n /** Above the caption: a goal gauge, a challenge's progress. */\n aboveCaption?: React.ReactNode;\n /**\n * How it takes its space: `frame` draws its own 9:16 box from its width; `fill` fills the box it is given (a bento\n * tile, a grid cell, a phone frame) — the clip covers it, cropped at the edges, the rail and the caption over it.\n * @default \"frame\"\n */\n fit?: \"frame\" | \"fill\";\n /** The locale of the counts. Default `en`. */\n locale?: string;\n /** Every word, passed translated. */\n labels?: Partial<VerticalVideoLabels>;\n}\n\nconst glyph = (d: string) => (\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d={d} />\n </svg>\n);\nconst HEART = \"M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z\";\nconst COMMENT = \"M4 5h16v11H9l-5 4z\";\nconst SHARE = \"M4 12v7h16v-7M12 3v12M7 8l5-5 5 5\";\nconst MORE = \"M5 12h.01M12 12h.01M19 12h.01\";\n\nconst useReducedMotion = () =>\n React.useSyncExternalStore(\n (notify) => {\n const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n query.addEventListener(\"change\", notify);\n return () => query.removeEventListener(\"change\", notify);\n },\n () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n () => false,\n );\n\n/** One short video: 9:16, the rail of actions on the right, the creator's caption bottom-left, a progress hairline. */\nexport function VerticalVideo({\n title,\n src,\n poster,\n active = true,\n preload = \"metadata\",\n loop = true,\n muted = true,\n creator,\n disclosure,\n caption,\n tags = [],\n credit,\n likes,\n comments,\n shares,\n liked = false,\n following = false,\n onLike,\n onComment,\n onShare,\n onFollow,\n onMore,\n tipSlot,\n topStart,\n aboveCaption,\n fit = \"frame\",\n locale = \"en\",\n labels: custom,\n className,\n onKeyDown,\n ...props\n}: VerticalVideoProps) {\n const labels = { ...verticalVideoLabels, ...custom };\n const reduce = useReducedMotion();\n const video = React.useRef<HTMLVideoElement>(null);\n const [playing, setPlaying] = React.useState(false);\n const [paused, setPaused] = React.useState(false); // the viewer's pause: it holds until they play again\n const [progress, setProgress] = React.useState(0);\n const [open, setOpen] = React.useState(false);\n const [heart, setHeart] = React.useState(0);\n\n const shouldPlay = active && !paused && !reduce;\n React.useEffect(() => {\n const el = video.current;\n if (!el) return;\n if (shouldPlay) void el.play().catch(() => undefined);\n else el.pause();\n }, [shouldPlay, src]);\n\n const press = () => {\n const el = video.current;\n if (!el) return;\n if (el.paused) {\n setPaused(false);\n void el.play().catch(() => undefined);\n } else {\n setPaused(true);\n el.pause();\n }\n };\n const like = () => onLike?.(!liked);\n const doubleTap = () => {\n if (!liked) like();\n setHeart((n) => n + 1); // a heart blooms over the picture (still under reduced motion: it shows and goes)\n };\n const count = (n?: number) => (n === undefined ? undefined : formatCompact(n, { locale }));\n const name = creator.name;\n\n return (\n <article\n aria-label={title}\n tabIndex={0}\n data-playing={playing ? \"\" : undefined}\n data-fit={fit}\n className={cn(\"relative isolate w-full overflow-hidden\", fit === \"fill\" ? \"h-full min-h-0\" : \"aspect-[9/16]\", \"bg-surface-0 text-fg-on-media focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented || event.target !== event.currentTarget) return;\n if (event.key === \" \" || event.key.toLowerCase() === \"k\") {\n event.preventDefault();\n press();\n } else if (event.key.toLowerCase() === \"l\") like();\n }}\n {...props}\n >\n <video\n ref={video}\n src={src}\n poster={poster}\n muted={muted}\n loop={loop}\n playsInline\n preload={preload}\n className=\"absolute inset-0 -z-10 h-full w-full object-cover\"\n onPlay={() => setPlaying(true)}\n onPause={() => setPlaying(false)}\n onTimeUpdate={(event) => {\n const el = event.currentTarget;\n setProgress(el.duration ? el.currentTime / el.duration : 0);\n }}\n />\n {/* The tap target: a tap pauses or plays, a double tap likes. It is a button, so the keyboard has it too. */}\n <button type=\"button\" aria-label={playing ? labels.pause : labels.play} onClick={press} onDoubleClick={doubleTap} className=\"absolute inset-0 cursor-pointer bg-transparent focus-visible:outline-hidden\">\n {!playing && (\n <span aria-hidden className=\"absolute left-1/2 top-1/2 grid h-16 w-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-scrim backdrop-blur-md\">\n <svg viewBox=\"0 0 24 24\" className=\"h-7 w-7\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\" /></svg>\n </span>\n )}\n </button>\n {heart > 0 && (\n <span key={heart} aria-hidden className=\"pointer-events-none absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center text-accent motion-safe:animate-ping\">\n <svg viewBox=\"0 0 24 24\" className=\"h-20 w-20\" fill=\"currentColor\"><path d={HEART} /></svg>\n </span>\n )}\n <div aria-hidden className=\"pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-linear-to-t from-scrim to-transparent\" />\n {topStart && <div className=\"absolute left-3 top-3\">{topStart}</div>}\n\n <div role=\"group\" aria-label={labels.actions} className=\"absolute bottom-16 right-2 flex flex-col items-center gap-3\">\n <div className=\"relative\">\n <Avatar src={creator.avatarSrc} alt={name} fallback={name.slice(0, 1)} size=\"md\" className=\"ring-2 ring-white\" />\n {onFollow && (\n <IconButton\n label={format(following ? labels.following : labels.follow, name)}\n aria-pressed={following}\n size=\"icon\"\n variant={following ? \"secondary\" : \"primary\"}\n shape=\"pill\"\n onClick={() => onFollow(!following)}\n className=\"absolute -bottom-3 left-1/2 h-6 min-h-6 w-6 min-w-6 -translate-x-1/2 p-0\"\n >\n {glyph(following ? \"M5 12l5 5 9-10\" : \"M12 5v14M5 12h14\")}\n </IconButton>\n )}\n </div>\n <Rail label={labels.like} count={count(likes)} pressed={liked} onClick={like} tone={liked ? \"text-accent\" : undefined}>\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill={liked ? \"currentColor\" : \"none\"} stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden><path d={HEART} /></svg>\n </Rail>\n <Rail label={labels.comment} count={count(comments)} onClick={onComment}>{glyph(COMMENT)}</Rail>\n <Rail label={labels.share} count={count(shares)} onClick={onShare}>{glyph(SHARE)}</Rail>\n {tipSlot}\n {onMore && <Rail label={labels.more} onClick={onMore}>{glyph(MORE)}</Rail>}\n </div>\n\n <div className=\"absolute bottom-4 left-3 right-16 flex min-w-0 flex-col gap-1.5\">\n {aboveCaption}\n <p className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-bold\">\n {name}\n {creator.verifiedLabel && <VerifiedBadge label={creator.verifiedLabel} onMedia size=\"sm\" />}\n {disclosure && <span className=\"whitespace-nowrap rounded-full bg-scrim px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-fg-on-media backdrop-blur-md\">{disclosure}</span>}\n </p>\n {caption && (\n <p className={cn(\"text-sm leading-snug\", !open && \"line-clamp-2\")}>\n {caption}\n {tags.length > 0 && <span className=\"font-semibold\"> {tags.map((tag) => `#${tag}`).join(\" \")}</span>}\n </p>\n )}\n {caption && caption.length > 90 && (\n <button type=\"button\" aria-expanded={open} onClick={() => setOpen((v) => !v)} className=\"w-fit rounded text-xs font-semibold underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\">\n {open ? labels.collapse : labels.expand}\n </button>\n )}\n {credit && <p className=\"text-xs opacity-90\">{credit}</p>}\n </div>\n\n <div role=\"progressbar\" aria-label={labels.progress} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)} className=\"absolute inset-x-0 bottom-0 h-1 bg-scrim\">\n <div className=\"h-full bg-white\" style={{ width: `${progress * 100}%` }} />\n </div>\n </article>\n );\n}\n\n/** \"Follow {name}\" → \"Follow Elena\". */\nconst format = (message: string, name: string) => message.replace(\"{name}\", name);\n\nfunction Rail({ label, count, pressed, onClick, tone, children }: { label: string; count?: string | undefined; pressed?: boolean; onClick?: (() => void) | undefined; tone?: string | undefined; children: React.ReactNode }) {\n return (\n <div className=\"flex flex-col items-center gap-0.5\">\n <IconButton label={label} aria-pressed={pressed} variant=\"ghost\" shape=\"pill\" onClick={onClick} className={cn(\"h-11 min-h-11 w-11 min-w-11 bg-scrim text-fg-on-media backdrop-blur-md hover:bg-scrim\", tone)}>\n {children}\n </IconButton>\n {count !== undefined && <span className=\"text-xs font-semibold tabular-nums\">{count}</span>}\n </div>\n );\n}\n"
|
|
633
|
+
"content": "// Client (a video element, an observer, keys). A 9:16 clip filling its frame, with the action rail of a short-video feed on\n// the right (avatar + follow, like, comment, share, a slot for tip / unlock, more) and the caption block bottom-left. Every\n// word is a prop; counts are formatted with @krizaka/intl; everything over the picture reads on the invariant `scrim` /\n// `fg-on-media` tokens, so it is legible in both themes. Under reduced motion nothing autoplays: the poster and a play button.\nimport { formatCompact } from \"@krizaka/intl\";\nimport * as React from \"react\";\n\nimport { Avatar } from \"../avatar/avatar\";\nimport { IconButton } from \"../button/button\";\nimport { cn } from \"../cn\";\nimport { VerifiedBadge } from \"../verified-badge/verified-badge\";\nimport { type VerticalVideoLabels, verticalVideoLabels, type VerticalVideoShared } from \"./model\";\n\nexport { type VerticalVideoLabels, verticalVideoLabels };\n\nexport interface VerticalVideoCreator {\n name: string;\n /** Their portrait. */\n avatarSrc?: string;\n /** The seal's words (\"Verified\"): shows the seal beside the name. */\n verifiedLabel?: string;\n}\n\nexport interface VerticalVideoProps extends Omit<React.ComponentProps<\"article\">, \"title\" | \"children\">, VerticalVideoShared {\n /** The clip — absent, the poster alone shows (MP4 or WebM; an HLS address only in Safari — use the player of `@krizaka/ui/video-player` for HLS). */\n src?: string;\n /** The picture until the first frame, and under reduced motion. */\n poster?: string;\n /** The next slide's: `auto` loads it ahead, `none` waits. Default `metadata`. */\n preload?: \"none\" | \"metadata\" | \"auto\";\n /** Plays again at the end. Default `true`. */\n loop?: boolean;\n /** Starts muted (autoplay needs it). Default `true`. */\n muted?: boolean;\n /** Who made it: name, portrait, the seal's words. */\n creator: VerticalVideoCreator;\n /**\n * How it takes its space: `frame` draws its own 9:16 box from its width; `fill` fills the box it is given (a bento\n * tile, a grid cell, a phone frame) — the clip covers it, cropped at the edges, the rail and the caption over it.\n * @default \"frame\"\n */\n fit?: \"frame\" | \"fill\";\n}\n\nconst glyph = (d: string) => (\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d={d} />\n </svg>\n);\nconst HEART = \"M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z\";\nconst COMMENT = \"M4 5h16v11H9l-5 4z\";\nconst SHARE = \"M4 12v7h16v-7M12 3v12M7 8l5-5 5 5\";\nconst MORE = \"M5 12h.01M12 12h.01M19 12h.01\";\n\nconst useReducedMotion = () =>\n React.useSyncExternalStore(\n (notify) => {\n const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n query.addEventListener(\"change\", notify);\n return () => query.removeEventListener(\"change\", notify);\n },\n () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n () => false,\n );\n\n/** One short video: 9:16, the rail of actions on the right, the creator's caption bottom-left, a progress hairline. */\nexport function VerticalVideo({\n title,\n src,\n poster,\n active = true,\n preload = \"metadata\",\n loop = true,\n muted = true,\n creator,\n disclosure,\n caption,\n tags = [],\n credit,\n likes,\n comments,\n shares,\n liked = false,\n following = false,\n onLike,\n onComment,\n onShare,\n onFollow,\n onMore,\n tipSlot,\n topStart,\n aboveCaption,\n fit = \"frame\",\n locale = \"en\",\n labels: custom,\n className,\n onKeyDown,\n ...props\n}: VerticalVideoProps) {\n const labels = { ...verticalVideoLabels, ...custom };\n const reduce = useReducedMotion();\n const video = React.useRef<HTMLVideoElement>(null);\n const [playing, setPlaying] = React.useState(false);\n const [paused, setPaused] = React.useState(false); // the viewer's pause: it holds until they play again\n const [progress, setProgress] = React.useState(0);\n const [open, setOpen] = React.useState(false);\n const [heart, setHeart] = React.useState(0);\n\n const shouldPlay = active && !paused && !reduce;\n React.useEffect(() => {\n const el = video.current;\n if (!el) return;\n if (shouldPlay) void el.play().catch(() => undefined);\n else el.pause();\n }, [shouldPlay, src]);\n\n const press = () => {\n const el = video.current;\n if (!el) return;\n if (el.paused) {\n setPaused(false);\n void el.play().catch(() => undefined);\n } else {\n setPaused(true);\n el.pause();\n }\n };\n const like = () => onLike?.(!liked);\n const doubleTap = () => {\n if (!liked) like();\n setHeart((n) => n + 1); // a heart blooms over the picture (still under reduced motion: it shows and goes)\n };\n const count = (n?: number) => (n === undefined ? undefined : formatCompact(n, { locale }));\n const name = creator.name;\n\n return (\n <article\n aria-label={title}\n tabIndex={0}\n data-playing={playing ? \"\" : undefined}\n data-fit={fit}\n className={cn(\"relative isolate w-full overflow-hidden\", fit === \"fill\" ? \"h-full min-h-0\" : \"aspect-[9/16]\", \"bg-surface-0 text-fg-on-media focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented || event.target !== event.currentTarget) return;\n if (event.key === \" \" || event.key.toLowerCase() === \"k\") {\n event.preventDefault();\n press();\n } else if (event.key.toLowerCase() === \"l\") like();\n }}\n {...props}\n >\n <video\n ref={video}\n src={src}\n poster={poster}\n muted={muted}\n loop={loop}\n playsInline\n preload={preload}\n className=\"absolute inset-0 -z-10 h-full w-full object-cover\"\n onPlay={() => setPlaying(true)}\n onPause={() => setPlaying(false)}\n onTimeUpdate={(event) => {\n const el = event.currentTarget;\n setProgress(el.duration ? el.currentTime / el.duration : 0);\n }}\n />\n {/* The tap target: a tap pauses or plays, a double tap likes. It is a button, so the keyboard has it too. */}\n <button type=\"button\" aria-label={playing ? labels.pause : labels.play} onClick={press} onDoubleClick={doubleTap} className=\"absolute inset-0 cursor-pointer bg-transparent focus-visible:outline-hidden\">\n {!playing && (\n <span aria-hidden className=\"absolute left-1/2 top-1/2 grid h-16 w-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-scrim backdrop-blur-md\">\n <svg viewBox=\"0 0 24 24\" className=\"h-7 w-7\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\" /></svg>\n </span>\n )}\n </button>\n {heart > 0 && (\n <span key={heart} aria-hidden className=\"pointer-events-none absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center text-accent motion-safe:animate-ping\">\n <svg viewBox=\"0 0 24 24\" className=\"h-20 w-20\" fill=\"currentColor\"><path d={HEART} /></svg>\n </span>\n )}\n <div aria-hidden className=\"pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-linear-to-t from-scrim to-transparent\" />\n {topStart && <div className=\"absolute left-3 top-3\">{topStart}</div>}\n\n <div role=\"group\" aria-label={labels.actions} className=\"absolute bottom-16 right-2 flex flex-col items-center gap-3\">\n <div className=\"relative\">\n <Avatar src={creator.avatarSrc} alt={name} fallback={name.slice(0, 1)} size=\"md\" className=\"ring-2 ring-white\" />\n {onFollow && (\n <IconButton\n label={format(following ? labels.following : labels.follow, name)}\n aria-pressed={following}\n size=\"icon\"\n variant={following ? \"secondary\" : \"primary\"}\n shape=\"pill\"\n onClick={() => onFollow(!following)}\n className=\"absolute -bottom-3 left-1/2 h-6 min-h-6 w-6 min-w-6 -translate-x-1/2 p-0\"\n >\n {glyph(following ? \"M5 12l5 5 9-10\" : \"M12 5v14M5 12h14\")}\n </IconButton>\n )}\n </div>\n <Rail label={labels.like} count={count(likes)} pressed={liked} onClick={like} tone={liked ? \"text-accent\" : undefined}>\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill={liked ? \"currentColor\" : \"none\"} stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden><path d={HEART} /></svg>\n </Rail>\n <Rail label={labels.comment} count={count(comments)} onClick={onComment}>{glyph(COMMENT)}</Rail>\n <Rail label={labels.share} count={count(shares)} onClick={onShare}>{glyph(SHARE)}</Rail>\n {tipSlot}\n {onMore && <Rail label={labels.more} onClick={onMore}>{glyph(MORE)}</Rail>}\n </div>\n\n <div className=\"absolute bottom-4 left-3 right-16 flex min-w-0 flex-col gap-1.5\">\n {aboveCaption}\n <p className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-bold\">\n {name}\n {creator.verifiedLabel && <VerifiedBadge label={creator.verifiedLabel} onMedia size=\"sm\" />}\n {disclosure && <span className=\"whitespace-nowrap rounded-full bg-scrim px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-fg-on-media backdrop-blur-md\">{disclosure}</span>}\n </p>\n {caption && (\n <p className={cn(\"text-sm leading-snug\", !open && \"line-clamp-2\")}>\n {caption}\n {tags.length > 0 && <span className=\"font-semibold\"> {tags.map((tag) => `#${tag}`).join(\" \")}</span>}\n </p>\n )}\n {caption && caption.length > 90 && (\n <button type=\"button\" aria-expanded={open} onClick={() => setOpen((v) => !v)} className=\"w-fit rounded text-xs font-semibold underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\">\n {open ? labels.collapse : labels.expand}\n </button>\n )}\n {credit && <p className=\"text-xs opacity-90\">{credit}</p>}\n </div>\n\n <div role=\"progressbar\" aria-label={labels.progress} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)} className=\"absolute inset-x-0 bottom-0 h-1 bg-scrim\">\n <div className=\"h-full bg-white\" style={{ width: `${progress * 100}%` }} />\n </div>\n </article>\n );\n}\n\n/** \"Follow {name}\" → \"Follow Elena\". */\nconst format = (message: string, name: string) => message.replace(\"{name}\", name);\n\nfunction Rail({ label, count, pressed, onClick, tone, children }: { label: string; count?: string | undefined; pressed?: boolean; onClick?: (() => void) | undefined; tone?: string | undefined; children: React.ReactNode }) {\n return (\n <div className=\"flex flex-col items-center gap-0.5\">\n <IconButton label={label} aria-pressed={pressed} variant=\"ghost\" shape=\"pill\" onClick={onClick} className={cn(\"h-11 min-h-11 w-11 min-w-11 bg-scrim text-fg-on-media backdrop-blur-md hover:bg-scrim\", tone)}>\n {children}\n </IconButton>\n {count !== undefined && <span className=\"text-xs font-semibold tabular-nums\">{count}</span>}\n </div>\n );\n}\n"
|
|
630
634
|
}
|
|
631
635
|
],
|
|
632
636
|
"dependencies": [
|