Split Link/Anchor from button navigators
Rename button-styled Link/Anchor to ButtonLink/ ButtonAnchor and add underlined text Link/Anchor so names match look and element. Hero meta uses plain Anchors for contact and custom links. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { EnvelopeIcon, GlobeSimpleIcon, MapPinSimpleIcon } from "@phosphor-icons/react";
|
import { EnvelopeIcon, GlobeSimpleIcon, MapPinSimpleIcon } from "@phosphor-icons/react";
|
||||||
|
import { Anchor } from "@probo/ui/src/v2/Link/Anchor";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
@@ -39,8 +40,8 @@ interface CompliancePortalContactInfoProps {
|
|||||||
compliancePortalKey: CompliancePortalContactInfo_compliancePortal$key;
|
compliancePortalKey: CompliancePortalContactInfo_compliancePortal$key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compliance portal contact details (website, email, HQ) rendered as an icon +
|
// Compliance portal contact details (website, email, HQ) rendered as icon +
|
||||||
// label row. Owns its fragment so it can be reused wherever the portal is in scope.
|
// label items for the shared hero meta row. The parent band owns the divider.
|
||||||
export function CompliancePortalContactInfo({ compliancePortalKey }: CompliancePortalContactInfoProps) {
|
export function CompliancePortalContactInfo({ compliancePortalKey }: CompliancePortalContactInfoProps) {
|
||||||
const compliancePortal = useFragment(compliancePortalContactInfoFragment, compliancePortalKey);
|
const compliancePortal = useFragment(compliancePortalContactInfoFragment, compliancePortalKey);
|
||||||
|
|
||||||
@@ -48,35 +49,39 @@ export function CompliancePortalContactInfo({ compliancePortalKey }: ComplianceP
|
|||||||
const hasEmail = compliancePortal.email != null && compliancePortal.email !== "";
|
const hasEmail = compliancePortal.email != null && compliancePortal.email !== "";
|
||||||
const hasAddress = compliancePortal.headquarterAddress != null && compliancePortal.headquarterAddress !== "";
|
const hasAddress = compliancePortal.headquarterAddress != null && compliancePortal.headquarterAddress !== "";
|
||||||
|
|
||||||
// Nothing to show — render no row (and therefore no divider) at all.
|
|
||||||
if (!hasWebsite && !hasEmail && !hasAddress) {
|
if (!hasWebsite && !hasEmail && !hasAddress) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { root, item, link } = organizationContactInfo();
|
const { item, link } = organizationContactInfo();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={root()}>
|
<>
|
||||||
{hasWebsite && (
|
{hasWebsite && (
|
||||||
<a
|
<Anchor
|
||||||
className={link()}
|
className={link()}
|
||||||
href={externalHref(compliancePortal.websiteUrl)}
|
href={externalHref(compliancePortal.websiteUrl)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
size={2}
|
||||||
|
color="neutral"
|
||||||
|
underline={false}
|
||||||
|
iconStart={<GlobeSimpleIcon />}
|
||||||
>
|
>
|
||||||
<GlobeSimpleIcon />
|
|
||||||
<Text size={2} color="neutral">
|
|
||||||
{hostnameOf(compliancePortal.websiteUrl)}
|
{hostnameOf(compliancePortal.websiteUrl)}
|
||||||
</Text>
|
</Anchor>
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
{hasEmail && (
|
{hasEmail && (
|
||||||
<a className={link()} href={`mailto:${compliancePortal.email}`}>
|
<Anchor
|
||||||
<EnvelopeIcon />
|
className={link()}
|
||||||
<Text size={2} color="neutral">
|
href={`mailto:${compliancePortal.email}`}
|
||||||
|
size={2}
|
||||||
|
color="neutral"
|
||||||
|
underline={false}
|
||||||
|
iconStart={<EnvelopeIcon />}
|
||||||
|
>
|
||||||
{compliancePortal.email}
|
{compliancePortal.email}
|
||||||
</Text>
|
</Anchor>
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
{hasAddress && (
|
{hasAddress && (
|
||||||
<div className={item()}>
|
<div className={item()}>
|
||||||
@@ -86,6 +91,6 @@ export function CompliancePortalContactInfo({ compliancePortalKey }: ComplianceP
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import {
|
||||||
|
FacebookLogoIcon,
|
||||||
|
GlobeSimpleIcon,
|
||||||
|
type Icon,
|
||||||
|
LinkedinLogoIcon,
|
||||||
|
XLogoIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { detectSocialName } from "@probo/helpers";
|
||||||
|
import { Anchor } from "@probo/ui/src/v2/Link/Anchor";
|
||||||
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
import { externalHref } from "#/lib/url/hostname";
|
||||||
|
|
||||||
|
import type { CompliancePortalCustomLinks_compliancePortal$key } from "./__generated__/CompliancePortalCustomLinks_compliancePortal.graphql";
|
||||||
|
import { organizationContactInfo } from "./variants";
|
||||||
|
|
||||||
|
const compliancePortalCustomLinksFragment = graphql`
|
||||||
|
fragment CompliancePortalCustomLinks_compliancePortal on CompliancePortal {
|
||||||
|
customLinks(first: 20) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface CompliancePortalCustomLinksProps {
|
||||||
|
compliancePortalKey: CompliancePortalCustomLinks_compliancePortal$key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconForUrl(url: string): Icon {
|
||||||
|
switch (detectSocialName(url)) {
|
||||||
|
case "LinkedIn":
|
||||||
|
return LinkedinLogoIcon;
|
||||||
|
case "X":
|
||||||
|
return XLogoIcon;
|
||||||
|
case "Facebook":
|
||||||
|
return FacebookLogoIcon;
|
||||||
|
default:
|
||||||
|
return GlobeSimpleIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organization custom links (social / external URLs) as icon + label items,
|
||||||
|
// appended after contact details in the shared hero meta row.
|
||||||
|
export function CompliancePortalCustomLinks({
|
||||||
|
compliancePortalKey,
|
||||||
|
}: CompliancePortalCustomLinksProps) {
|
||||||
|
const compliancePortal = useFragment(
|
||||||
|
compliancePortalCustomLinksFragment,
|
||||||
|
compliancePortalKey,
|
||||||
|
);
|
||||||
|
const links = compliancePortal.customLinks.edges.map(edge => edge.node);
|
||||||
|
|
||||||
|
if (links.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { link } = organizationContactInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{links.map((customLink) => {
|
||||||
|
const Icon = iconForUrl(customLink.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Anchor
|
||||||
|
key={customLink.id}
|
||||||
|
className={link()}
|
||||||
|
href={externalHref(customLink.url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size={2}
|
||||||
|
color="neutral"
|
||||||
|
underline={false}
|
||||||
|
iconStart={<Icon />}
|
||||||
|
>
|
||||||
|
{customLink.name}
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
import type { CompliancePortalHeroMeta_compliancePortal$key } from "./__generated__/CompliancePortalHeroMeta_compliancePortal.graphql";
|
||||||
|
import { CompliancePortalContactInfo } from "./CompliancePortalContactInfo";
|
||||||
|
import { CompliancePortalCustomLinks } from "./CompliancePortalCustomLinks";
|
||||||
|
import { organizationContactInfo } from "./variants";
|
||||||
|
|
||||||
|
const compliancePortalHeroMetaFragment = graphql`
|
||||||
|
fragment CompliancePortalHeroMeta_compliancePortal on CompliancePortal {
|
||||||
|
websiteUrl
|
||||||
|
email
|
||||||
|
headquarterAddress
|
||||||
|
customLinks(first: 20) {
|
||||||
|
edges {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
...CompliancePortalContactInfo_compliancePortal
|
||||||
|
...CompliancePortalCustomLinks_compliancePortal
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface CompliancePortalHeroMetaProps {
|
||||||
|
compliancePortalKey: CompliancePortalHeroMeta_compliancePortal$key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared hero bottom band: contact details and custom links in one row with a
|
||||||
|
// single top divider. Hidden entirely when both are empty.
|
||||||
|
export function CompliancePortalHeroMeta({
|
||||||
|
compliancePortalKey,
|
||||||
|
}: CompliancePortalHeroMetaProps) {
|
||||||
|
const compliancePortal = useFragment(
|
||||||
|
compliancePortalHeroMetaFragment,
|
||||||
|
compliancePortalKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasWebsite = compliancePortal.websiteUrl != null && compliancePortal.websiteUrl !== "";
|
||||||
|
const hasEmail = compliancePortal.email != null && compliancePortal.email !== "";
|
||||||
|
const hasAddress
|
||||||
|
= compliancePortal.headquarterAddress != null && compliancePortal.headquarterAddress !== "";
|
||||||
|
const hasContact = hasWebsite || hasEmail || hasAddress;
|
||||||
|
const hasCustomLinks = compliancePortal.customLinks.edges.length > 0;
|
||||||
|
|
||||||
|
if (!hasContact && !hasCustomLinks) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { root } = organizationContactInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={root()}>
|
||||||
|
<CompliancePortalContactInfo compliancePortalKey={compliancePortal} />
|
||||||
|
<CompliancePortalCustomLinks compliancePortalKey={compliancePortal} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,8 +34,8 @@ export interface HeroProps {
|
|||||||
|
|
||||||
// Landing hero (home): a size-8 title (+ optional description) in the shared
|
// Landing hero (home): a size-8 title (+ optional description) in the shared
|
||||||
// white band, plus an optional bottom slot for page-specific content (the org
|
// white band, plus an optional bottom slot for page-specific content (the org
|
||||||
// contact row). The slot content owns its own divider/spacing so it disappears
|
// meta row: contact + custom links). The slot content owns its own
|
||||||
// cleanly when empty.
|
// divider/spacing so it disappears cleanly when empty.
|
||||||
export function Hero({ title, description, children }: HeroProps) {
|
export function Hero({ title, description, children }: HeroProps) {
|
||||||
const { content, section } = hero();
|
const { content, section } = hero();
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
|||||||
|
|
||||||
import { hero, organizationContactInfo } from "./variants";
|
import { hero, organizationContactInfo } from "./variants";
|
||||||
|
|
||||||
// Width per contact item, roughly sized to its typical content (hostname /
|
// Width per meta item, roughly sized to its typical content (hostname /
|
||||||
// email / address). See .cursor/rules/skeleton-width-sync.mdc.
|
// email / address / custom link). See .cursor/rules/skeleton-width-sync.mdc.
|
||||||
const CONTACT_ITEMS = [
|
const META_ITEMS = [
|
||||||
{ key: "website", width: "w-28" },
|
{ key: "website", width: "w-28" },
|
||||||
{ key: "email", width: "w-40" },
|
{ key: "email", width: "w-40" },
|
||||||
{ key: "location", width: "w-36" },
|
{ key: "location", width: "w-36" },
|
||||||
|
{ key: "customLink1", width: "w-24" },
|
||||||
|
{ key: "customLink2", width: "w-16" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Loading placeholder paired with Hero: reuses the same layout slots with
|
// Loading placeholder paired with Hero: reuses the same layout slots with
|
||||||
@@ -47,9 +49,9 @@ export function HeroSkeleton() {
|
|||||||
<TextSkeleton size={2} className="w-full max-w-2xl" />
|
<TextSkeleton size={2} className="w-full max-w-2xl" />
|
||||||
</div>
|
</div>
|
||||||
<div className={root()}>
|
<div className={root()}>
|
||||||
{CONTACT_ITEMS.map(contact => (
|
{META_ITEMS.map(meta => (
|
||||||
<div key={contact.key} className={item()}>
|
<div key={meta.key} className={item()}>
|
||||||
<TextSkeleton size={2} className={contact.width} />
|
<TextSkeleton size={2} className={meta.width} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
import { tv } from "tailwind-variants/lite";
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
// Landing hero content rendered inside the shared HeaderBand: a centered
|
// Landing hero content rendered inside the shared HeaderBand: a centered
|
||||||
// title/description section above an optional bottom slot (the contact row).
|
// title/description section above an optional bottom slot (contact + custom
|
||||||
// Slots are shared by the live Hero and its skeleton.
|
// links). Slots are shared by the live Hero and its skeleton.
|
||||||
export const hero = tv({
|
export const hero = tv({
|
||||||
slots: {
|
slots: {
|
||||||
content: "flex w-full flex-col gap-10",
|
content: "flex w-full flex-col gap-10",
|
||||||
@@ -30,14 +30,15 @@ export const hero = tv({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Organization contact block: a horizontal row of icon + label items, with a
|
// Hero meta band: contact details and custom links as one icon + label row,
|
||||||
// top divider so it reads as the hero's bottom section. Self-contained so the
|
// with a single top divider. Link chrome comes from v2 Anchor; these slots
|
||||||
// divider only appears when there is contact info to show.
|
// only own the band layout and non-link items (HQ address).
|
||||||
export const organizationContactInfo = tv({
|
export const organizationContactInfo = tv({
|
||||||
slots: {
|
slots: {
|
||||||
// Only a top divider gap; the band's py-8 provides the bottom spacing.
|
// Only a top divider gap; the band's py-8 provides the bottom spacing.
|
||||||
root: "flex w-full flex-wrap items-center gap-x-6 gap-y-2 border-t border-sand-a2 pt-4 max-md:flex-col max-md:items-start",
|
root: "flex w-full flex-wrap items-center gap-x-6 gap-y-2 border-t border-sand-a2 pt-4 max-md:flex-col max-md:items-start",
|
||||||
item: "flex min-w-0 items-center gap-2 text-sand-11 [&_svg]:size-5 [&_svg]:shrink-0 [&>*]:min-w-0 [&>*:not(svg)]:break-words",
|
item: "flex min-w-0 items-center gap-2 text-sand-11 [&_svg]:size-4 [&_svg]:shrink-0 *:min-w-0 [&>*:not(svg)]:wrap-break-word",
|
||||||
link: "flex min-w-0 items-center gap-2 text-sand-11 hover:underline [&_svg]:size-5 [&_svg]:shrink-0 [&>*]:min-w-0 [&>*:not(svg)]:break-all",
|
// Layout extras on top of Anchor (underline/color/size live on Anchor).
|
||||||
|
link: "min-w-0 max-w-full [&>:not(svg)]:break-all",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
|
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
@@ -84,9 +84,9 @@ function RecentUpdatesSectionContent({ compliancePortalKey }: RecentUpdatesSecti
|
|||||||
<HomeSection
|
<HomeSection
|
||||||
title={t("home.sections.recentUpdates")}
|
title={t("home.sections.recentUpdates")}
|
||||||
action={(
|
action={(
|
||||||
<Link to={localizedPath("/updates")} variant="ghost" color="neutral" size={2}>
|
<ButtonLink to={localizedPath("/updates")} variant="ghost" color="neutral" size={2}>
|
||||||
{t("home.recentUpdates.viewAll")}
|
{t("home.recentUpdates.viewAll")}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
import { LockSimpleIcon } from "@phosphor-icons/react";
|
import { LockSimpleIcon } from "@phosphor-icons/react";
|
||||||
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
|
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
|
||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
@@ -103,7 +103,7 @@ export function TopBar({ queryKey }: TopBarProps) {
|
|||||||
{TOP_BAR_NAV_ITEMS.map((item) => {
|
{TOP_BAR_NAV_ITEMS.map((item) => {
|
||||||
const to = localizedPath(item.to);
|
const to = localizedPath(item.to);
|
||||||
return (
|
return (
|
||||||
<Link
|
<ButtonLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={to}
|
to={to}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -112,7 +112,7 @@ export function TopBar({ queryKey }: TopBarProps) {
|
|||||||
active={isActive(pathname, to)}
|
active={isActive(pathname, to)}
|
||||||
>
|
>
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{data.viewer == null
|
{data.viewer == null
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { Drawer } from "@probo/ui/src/v2/Drawer/Drawer";
|
import { Drawer } from "@probo/ui/src/v2/Drawer/Drawer";
|
||||||
import { DrawerBody } from "@probo/ui/src/v2/Drawer/DrawerBody";
|
import { DrawerBody } from "@probo/ui/src/v2/Drawer/DrawerBody";
|
||||||
import { DrawerClose } from "@probo/ui/src/v2/Drawer/DrawerClose";
|
import { DrawerClose } from "@probo/ui/src/v2/Drawer/DrawerClose";
|
||||||
@@ -133,7 +133,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
|||||||
{TOP_BAR_NAV_ITEMS.map((item) => {
|
{TOP_BAR_NAV_ITEMS.map((item) => {
|
||||||
const to = localizedPath(item.to);
|
const to = localizedPath(item.to);
|
||||||
return (
|
return (
|
||||||
<Link
|
<ButtonLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={to}
|
to={to}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -144,7 +144,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
|||||||
onClick={close}
|
onClick={close}
|
||||||
>
|
>
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { FileTextIcon } from "@phosphor-icons/react";
|
import { FileTextIcon } from "@phosphor-icons/react";
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -72,14 +72,14 @@ export function UnsignedNDABanner({ compliancePortalKey }: UnsignedNDABannerProp
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
actions={(
|
actions={(
|
||||||
<Link
|
<ButtonLink
|
||||||
to={ndaHref}
|
to={ndaHref}
|
||||||
size={1}
|
size={1}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="amber"
|
color="amber"
|
||||||
>
|
>
|
||||||
{t("nda.unsignedBanner.sign")}
|
{t("nda.unsignedBanner.sign")}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
)}
|
)}
|
||||||
dismissLabel={t("nda.unsignedBanner.dismiss")}
|
dismissLabel={t("nda.unsignedBanner.dismiss")}
|
||||||
onDismiss={() => setDismissed(true)}
|
onDismiss={() => setDismissed(true)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
|
import { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
|
||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
|
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -76,9 +76,9 @@ export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorPro
|
|||||||
description={t(descriptionKey)}
|
description={t(descriptionKey)}
|
||||||
actions={(
|
actions={(
|
||||||
<>
|
<>
|
||||||
<Link to={localizedPath("/")} variant="solid" color="neutral" highContrast size={2}>
|
<ButtonLink to={localizedPath("/")} variant="solid" color="neutral" highContrast size={2}>
|
||||||
{t("errors.actions.backToCompliancePortal")}
|
{t("errors.actions.backToCompliancePortal")}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button variant="soft" color="neutral" size={2} onClick={onRetry}>
|
<Button variant="soft" color="neutral" size={2} onClick={onRetry}>
|
||||||
{t("errors.actions.tryAgain")}
|
{t("errors.actions.tryAgain")}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type { PreloadedQuery } from "react-relay";
|
|||||||
import { graphql, usePreloadedQuery } from "react-relay";
|
import { graphql, usePreloadedQuery } from "react-relay";
|
||||||
|
|
||||||
import { ComplianceFrameworksSection } from "#/components/ComplianceFrameworks/ComplianceFrameworksSection";
|
import { ComplianceFrameworksSection } from "#/components/ComplianceFrameworks/ComplianceFrameworksSection";
|
||||||
import { CompliancePortalContactInfo } from "#/components/Hero/CompliancePortalContactInfo";
|
import { CompliancePortalHeroMeta } from "#/components/Hero/CompliancePortalHeroMeta";
|
||||||
import { Hero } from "#/components/Hero/Hero";
|
import { Hero } from "#/components/Hero/Hero";
|
||||||
import { RecentUpdatesSection } from "#/components/RecentUpdates/RecentUpdatesSection";
|
import { RecentUpdatesSection } from "#/components/RecentUpdates/RecentUpdatesSection";
|
||||||
import { SecurityCommitmentsSection } from "#/components/SecurityCommitments/SecurityCommitmentsSection";
|
import { SecurityCommitmentsSection } from "#/components/SecurityCommitments/SecurityCommitmentsSection";
|
||||||
@@ -35,7 +35,7 @@ export const homePageQuery = graphql`
|
|||||||
query HomePageQuery @throwOnFieldError {
|
query HomePageQuery @throwOnFieldError {
|
||||||
currentCompliancePortal @required(action: THROW) {
|
currentCompliancePortal @required(action: THROW) {
|
||||||
entityName
|
entityName
|
||||||
...CompliancePortalContactInfo_compliancePortal
|
...CompliancePortalHeroMeta_compliancePortal
|
||||||
...ComplianceFrameworksSection_compliancePortal
|
...ComplianceFrameworksSection_compliancePortal
|
||||||
...SecurityCommitmentsSection_compliancePortal
|
...SecurityCommitmentsSection_compliancePortal
|
||||||
...TrustedBySection_compliancePortal
|
...TrustedBySection_compliancePortal
|
||||||
@@ -60,7 +60,7 @@ export function HomePage({ queryRef }: HomePageProps) {
|
|||||||
title={t("home.heroTitle", { name: entityName })}
|
title={t("home.heroTitle", { name: entityName })}
|
||||||
description={t("home.heroDescription")}
|
description={t("home.heroDescription")}
|
||||||
>
|
>
|
||||||
<CompliancePortalContactInfo compliancePortalKey={currentCompliancePortal} />
|
<CompliancePortalHeroMeta compliancePortalKey={currentCompliancePortal} />
|
||||||
</Hero>
|
</Hero>
|
||||||
<div className="flex w-full flex-col items-center px-8 max-md:px-4">
|
<div className="flex w-full flex-col items-center px-8 max-md:px-4">
|
||||||
<div className="flex w-full max-w-5xl flex-col">
|
<div className="flex w-full max-w-5xl flex-col">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { Heading } from "@probo/ui/src/v2/typography/Heading";
|
import { Heading } from "@probo/ui/src/v2/typography/Heading";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -41,9 +41,9 @@ export default function NotFoundPage() {
|
|||||||
<Text size={2} color="neutral">
|
<Text size={2} color="neutral">
|
||||||
{t("notFound.description")}
|
{t("notFound.description")}
|
||||||
</Text>
|
</Text>
|
||||||
<Link to={localizedPath("/")} variant="soft" color="neutral" highContrast size={2}>
|
<ButtonLink to={localizedPath("/")} variant="soft" color="neutral" highContrast size={2}>
|
||||||
{t("notFound.backHome")}
|
{t("notFound.backHome")}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
</HeaderBand>
|
</HeaderBand>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
import { ArrowRightIcon, ClockIcon, LockSimpleIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
import { ArrowRightIcon, ClockIcon, LockSimpleIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface DocumentAccessActionProps {
|
interface DocumentAccessActionProps {
|
||||||
@@ -100,7 +100,7 @@ export function DocumentAccessAction({
|
|||||||
|
|
||||||
if (isAuthorized) {
|
if (isAuthorized) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<ButtonLink
|
||||||
to={viewHref}
|
to={viewHref}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
@@ -108,7 +108,7 @@ export function DocumentAccessAction({
|
|||||||
iconStart={<ArrowRightIcon />}
|
iconStart={<ArrowRightIcon />}
|
||||||
>
|
>
|
||||||
{t("actions.view")}
|
{t("actions.view")}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { CaretLeftIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
import { CaretLeftIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { Heading } from "@probo/ui/src/v2/typography/Heading";
|
import { Heading } from "@probo/ui/src/v2/typography/Heading";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -98,9 +98,9 @@ export function DocumentViewer({ title, dataUri = null, downloadName = title, lo
|
|||||||
<div className={slots.root()}>
|
<div className={slots.root()}>
|
||||||
<HeaderBand flushBottomSpace={!isLocked}>
|
<HeaderBand flushBottomSpace={!isLocked}>
|
||||||
<div className={slots.header()}>
|
<div className={slots.header()}>
|
||||||
<Link to={localizedPath("/documents")} variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
|
<ButtonLink to={localizedPath("/documents")} variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
|
||||||
{t("viewer.back")}
|
{t("viewer.back")}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
<Heading level={1} size={7} weight="medium" highContrast className="truncate">
|
<Heading level={1} size={7} weight="medium" highContrast className="truncate">
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
MagnifyingGlassPlusIcon,
|
MagnifyingGlassPlusIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { Callout } from "@probo/ui/src/v2/Callout/Callout";
|
import { Callout } from "@probo/ui/src/v2/Callout/Callout";
|
||||||
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
|
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
|
||||||
import { Separator } from "@probo/ui/src/v2/Separator/Separator";
|
import { Separator } from "@probo/ui/src/v2/Separator/Separator";
|
||||||
@@ -236,7 +236,7 @@ export function NDAPage({ queryRef }: NDAPageProps) {
|
|||||||
<div className={slots.root()}>
|
<div className={slots.root()}>
|
||||||
<HeaderBand flushBottomSpace>
|
<HeaderBand flushBottomSpace>
|
||||||
<div className={slots.header()}>
|
<div className={slots.header()}>
|
||||||
<Link
|
<ButtonLink
|
||||||
to={localizedPath("/documents")}
|
to={localizedPath("/documents")}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
@@ -245,7 +245,7 @@ export function NDAPage({ queryRef }: NDAPageProps) {
|
|||||||
className={slots.back()}
|
className={slots.back()}
|
||||||
>
|
>
|
||||||
{t("back")}
|
{t("back")}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
<div className={slots.text()}>
|
<div className={slots.text()}>
|
||||||
<Heading level={1} size={7} weight="medium" highContrast>
|
<Heading level={1} size={7} weight="medium" highContrast>
|
||||||
{t("title")}
|
{t("title")}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { CaretLeftIcon, NewspaperIcon } from "@phosphor-icons/react";
|
import { CaretLeftIcon, NewspaperIcon } from "@phosphor-icons/react";
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink";
|
||||||
import { Heading } from "@probo/ui/src/v2/typography/Heading";
|
import { Heading } from "@probo/ui/src/v2/typography/Heading";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -68,9 +68,9 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
|||||||
<>
|
<>
|
||||||
<HeaderBand>
|
<HeaderBand>
|
||||||
<div className={toolbar()}>
|
<div className={toolbar()}>
|
||||||
<Link to={localizedPath("/updates")} variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
<ButtonLink to={localizedPath("/updates")} variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
||||||
{t("backToUpdates")}
|
{t("backToUpdates")}
|
||||||
</Link>
|
</ButtonLink>
|
||||||
<UpdatesSubscribeButton />
|
<UpdatesSubscribeButton />
|
||||||
</div>
|
</div>
|
||||||
</HeaderBand>
|
</HeaderBand>
|
||||||
|
|||||||
@@ -151,7 +151,15 @@ export function Row({ bordered, children }: { bordered?: boolean; children: Reac
|
|||||||
|
|
||||||
Variants tune **look** (size, tone, density) — they must not change a component's **structure, semantics, or prop contract**. When a "variant" would render a different element, accept different props, or fork the behavior, build a **separate component** instead. This keeps each component's typing simple and its rendered element predictable.
|
Variants tune **look** (size, tone, density) — they must not change a component's **structure, semantics, or prop contract**. When a "variant" would render a different element, accept different props, or fork the behavior, build a **separate component** instead. This keeps each component's typing simple and its rendered element predictable.
|
||||||
|
|
||||||
The clearest case is the button family: a clickable action, a styled `<a>`, and a router link are three components, not one `Button` with an `as`/`href`/`to` union.
|
The clearest case is navigation vs action: a clickable action, button-looking navigation, and underlined text links are separate components — not one `Button` with an `as`/`href`/`to` union, and not `Link`/`Anchor` that secretly look like buttons.
|
||||||
|
|
||||||
|
| Component | Element | Look |
|
||||||
|
|---|---|---|
|
||||||
|
| `Button` | `<button>` | button |
|
||||||
|
| `ButtonLink` | react-router | button |
|
||||||
|
| `ButtonAnchor` | `<a>` | button |
|
||||||
|
| `Link` | react-router | underlined text |
|
||||||
|
| `Anchor` | `<a>` | underlined text |
|
||||||
|
|
||||||
### Do / don't: separate components over polymorphic props
|
### Do / don't: separate components over polymorphic props
|
||||||
|
|
||||||
@@ -170,24 +178,33 @@ export function Button(props: ButtonProps) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Good — three flat components sharing the same tv styles
|
// Good — flat components; button look and text-link look stay separate
|
||||||
// variants.ts
|
// Button/variants.ts
|
||||||
export const button = tv({ base: "inline-flex items-center …", variants: { /* size, tone */ } });
|
export const button = tv({ base: "inline-flex items-center …", variants: { /* size, tone */ } });
|
||||||
|
|
||||||
// Button.tsx — renders <button>
|
// Button.tsx — action
|
||||||
export function Button(props: ComponentProps<"button">) {
|
export function Button(props: ComponentProps<"button">) {
|
||||||
return <button className={button()} {...props} />;
|
return <button className={button()} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor.tsx — renders <a>
|
// ButtonAnchor.tsx / ButtonLink.tsx — navigation that looks like a button
|
||||||
export function Anchor(props: ComponentProps<"a">) {
|
export function ButtonAnchor(props: ComponentProps<"a">) {
|
||||||
return <a className={button()} {...props} />;
|
return <a className={button()} {...props} />;
|
||||||
}
|
}
|
||||||
|
export function ButtonLink(props: ComponentProps<typeof RouterLink>) {
|
||||||
// Link.tsx — renders a router link
|
|
||||||
export function Link(props: ComponentProps<typeof RouterLink>) {
|
|
||||||
return <RouterLink className={button()} {...props} />;
|
return <RouterLink className={button()} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link/variants.ts — underlined text
|
||||||
|
export const link = tv({ base: "underline …", variants: { /* size, color */ } });
|
||||||
|
|
||||||
|
// Link.tsx / Anchor.tsx — navigation that looks like a link
|
||||||
|
export function Link(props: ComponentProps<typeof RouterLink>) {
|
||||||
|
return <RouterLink className={link()} {...props} />;
|
||||||
|
}
|
||||||
|
export function Anchor(props: ComponentProps<"a">) {
|
||||||
|
return <a className={link()} {...props} />;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Size/tone differences (`size="sm"`, `tone="danger"`) are legitimate `tv` variants — they don't change the element or props.
|
Size/tone differences (`size="sm"`, `tone="danger"`) are legitimate `tv` variants — they don't change the element or props.
|
||||||
@@ -542,4 +559,4 @@ Base UI primitives ship correct roles, focus management, and keyboard interactio
|
|||||||
- Keep accessible labels: every control has a visible label or an `aria-label`; icon-only buttons (`Button icon={…}`) require an `aria-label`.
|
- Keep accessible labels: every control has a visible label or an `aria-label`; icon-only buttons (`Button icon={…}`) require an `aria-label`.
|
||||||
- Don't strip `aria-*` / `role` that primitives set, and don't trap or override focus the primitive manages.
|
- Don't strip `aria-*` / `role` that primitives set, and don't trap or override focus the primitive manages.
|
||||||
- Convey state with more than color (e.g. an icon + text alongside a `red-*` tone), so meaning survives for color-blind users — the [token contrast guarantees](v2-tokens.md#contrast-guarantees) cover text legibility, not state encoding.
|
- Convey state with more than color (e.g. an icon + text alongside a `red-*` tone), so meaning survives for color-blind users — the [token contrast guarantees](v2-tokens.md#contrast-guarantees) cover text legibility, not state encoding.
|
||||||
- Use semantic elements (`<button>`, `<a>`, `<nav>`, headings) — see the [Button vs Anchor vs Link](#no-structure-changing-variants) split.
|
- Use semantic elements (`<button>`, `<a>`, `<nav>`, headings) — see the [Button / ButtonLink / ButtonAnchor / Link / Anchor](#no-structure-changing-variants) split.
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ export type ButtonProps
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clickable action (Radix "Button"). Renders a <button>; a styled <a> (Anchor)
|
// Clickable action (Radix "Button"). Renders a <button>; button-styled
|
||||||
// or router link (Link) are separate components. See contrib/claude/ui.md.
|
// navigation uses ButtonLink / ButtonAnchor, underlined text uses Link /
|
||||||
|
// Anchor. See contrib/claude/ui.md.
|
||||||
export function Button(props: ButtonProps) {
|
export function Button(props: ButtonProps) {
|
||||||
const {
|
const {
|
||||||
size, variant, color, highContrast, active, className,
|
size, variant, color, highContrast, active, className,
|
||||||
|
|||||||
@@ -21,14 +21,14 @@
|
|||||||
import { ArrowSquareOutIcon } from "@phosphor-icons/react";
|
import { ArrowSquareOutIcon } from "@phosphor-icons/react";
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
|
||||||
import { Anchor } from "./Anchor";
|
import { ButtonAnchor } from "./ButtonAnchor";
|
||||||
|
|
||||||
const sizes = [1, 2, 3, 4] as const;
|
const sizes = [1, 2, 3, 4] as const;
|
||||||
const variants = ["classic", "solid", "soft", "surface", "outline", "ghost"] as const;
|
const variants = ["classic", "solid", "soft", "surface", "outline", "ghost"] as const;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "v2/Anchor",
|
title: "v2/ButtonAnchor",
|
||||||
component: Anchor,
|
component: ButtonAnchor,
|
||||||
args: {
|
args: {
|
||||||
children: "Documentation",
|
children: "Documentation",
|
||||||
href: "#",
|
href: "#",
|
||||||
@@ -38,9 +38,9 @@ export default {
|
|||||||
highContrast: false,
|
highContrast: false,
|
||||||
active: false,
|
active: false,
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof Anchor>;
|
} satisfies Meta<typeof ButtonAnchor>;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Anchor>;
|
type Story = StoryObj<typeof ButtonAnchor>;
|
||||||
|
|
||||||
export const Playground: Story = {};
|
export const Playground: Story = {};
|
||||||
|
|
||||||
@@ -48,9 +48,9 @@ export const Sizes: Story = {
|
|||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{sizes.map(size => (
|
{sizes.map(size => (
|
||||||
<Anchor key={size} href="#" size={size}>
|
<ButtonAnchor key={size} href="#" size={size}>
|
||||||
Documentation
|
Documentation
|
||||||
</Anchor>
|
</ButtonAnchor>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -60,9 +60,9 @@ export const Variants: Story = {
|
|||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{variants.map(variant => (
|
{variants.map(variant => (
|
||||||
<Anchor key={variant} href="#" variant={variant}>
|
<ButtonAnchor key={variant} href="#" variant={variant}>
|
||||||
{variant}
|
{variant}
|
||||||
</Anchor>
|
</ButtonAnchor>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -70,8 +70,8 @@ export const Variants: Story = {
|
|||||||
|
|
||||||
export const WithIcon: Story = {
|
export const WithIcon: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<Anchor href="#" variant="soft" color="neutral" iconEnd={<ArrowSquareOutIcon />}>
|
<ButtonAnchor href="#" variant="soft" color="neutral" iconEnd={<ArrowSquareOutIcon />}>
|
||||||
External link
|
External link
|
||||||
</Anchor>
|
</ButtonAnchor>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
53
packages/ui/src/v2/Button/ButtonAnchor.tsx
Normal file
53
packages/ui/src/v2/Button/ButtonAnchor.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { button } from "./variants";
|
||||||
|
|
||||||
|
export type ButtonAnchorProps
|
||||||
|
= Omit<ComponentProps<"a">, "color">
|
||||||
|
& VariantProps<typeof button>
|
||||||
|
& {
|
||||||
|
iconStart?: ReactNode;
|
||||||
|
iconEnd?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// External (or mailto:/tel:) navigation that looks like a Button. Renders an
|
||||||
|
// <a>; for in-app button navigation use ButtonLink, for underlined text use
|
||||||
|
// Link/Anchor. See contrib/claude/ui.md.
|
||||||
|
export function ButtonAnchor(props: ButtonAnchorProps) {
|
||||||
|
const {
|
||||||
|
size, variant, color, highContrast, active, className,
|
||||||
|
iconStart, iconEnd, children, ...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={button({ size, variant, color, highContrast, active, className })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{iconStart}
|
||||||
|
{children}
|
||||||
|
{iconEnd}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
packages/ui/src/v2/Button/ButtonLink.stories.tsx
Normal file
72
packages/ui/src/v2/Button/ButtonLink.stories.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
|
||||||
|
import { ButtonLink } from "./ButtonLink";
|
||||||
|
|
||||||
|
const navItems = ["Documents", "Subprocessors", "Updates", "Requests"] as const;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/ButtonLink",
|
||||||
|
component: ButtonLink,
|
||||||
|
args: {
|
||||||
|
children: "Documents",
|
||||||
|
to: "#",
|
||||||
|
size: 2,
|
||||||
|
variant: "ghost",
|
||||||
|
color: "neutral",
|
||||||
|
highContrast: false,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
Story => (
|
||||||
|
<MemoryRouter>
|
||||||
|
<Story />
|
||||||
|
</MemoryRouter>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
} satisfies Meta<typeof ButtonLink>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ButtonLink>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
// Mirrors the Compliance Portal top-bar nav: ghost links with a persistent active
|
||||||
|
// pill on the selected item.
|
||||||
|
export const Nav: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{navItems.map((item, index) => (
|
||||||
|
<ButtonLink
|
||||||
|
key={item}
|
||||||
|
to="#"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size={2}
|
||||||
|
active={index === 0}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</ButtonLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
54
packages/ui/src/v2/Button/ButtonLink.tsx
Normal file
54
packages/ui/src/v2/Button/ButtonLink.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import { Link as RouterLink } from "react-router";
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { button } from "./variants";
|
||||||
|
|
||||||
|
export type ButtonLinkProps
|
||||||
|
= Omit<ComponentProps<typeof RouterLink>, "color">
|
||||||
|
& VariantProps<typeof button>
|
||||||
|
& {
|
||||||
|
iconStart?: ReactNode;
|
||||||
|
iconEnd?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-app navigation that looks like a Button. Renders a react-router Link; for
|
||||||
|
// a button-styled <a> use ButtonAnchor, for underlined text use Link/Anchor.
|
||||||
|
// Set `active` for the selected nav state. See contrib/claude/ui.md.
|
||||||
|
export function ButtonLink(props: ButtonLinkProps) {
|
||||||
|
const {
|
||||||
|
size, variant, color, highContrast, active, className,
|
||||||
|
iconStart, iconEnd, children, ...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouterLink
|
||||||
|
className={button({ size, variant, color, highContrast, active, className })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{iconStart}
|
||||||
|
{children}
|
||||||
|
{iconEnd}
|
||||||
|
</RouterLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,9 +20,9 @@
|
|||||||
|
|
||||||
import { tv } from "tailwind-variants/lite";
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
// Button (Radix "Button"). A <button>; an Anchor / Link are separate
|
// Button (Radix "Button"). A <button>; ButtonLink / ButtonAnchor share these
|
||||||
// components (see contrib/claude/ui.md). The variant × color surface treatment
|
// styles for button-looking navigation (see contrib/claude/ui.md). The
|
||||||
// resolves in the compound variants below.
|
// variant × color surface treatment resolves in the compound variants below.
|
||||||
export const button = tv({
|
export const button = tv({
|
||||||
base: [
|
base: [
|
||||||
"inline-flex shrink-0 items-center justify-center border border-transparent font-medium whitespace-nowrap",
|
"inline-flex shrink-0 items-center justify-center border border-transparent font-medium whitespace-nowrap",
|
||||||
@@ -59,7 +59,7 @@ export const button = tv({
|
|||||||
true: "",
|
true: "",
|
||||||
false: "",
|
false: "",
|
||||||
},
|
},
|
||||||
// Persistent "selected" treatment for nav items built on Anchor / Link
|
// Persistent "selected" treatment for nav items built on ButtonLink / ButtonAnchor
|
||||||
// (ghost / soft surfaces). Look-only — does not change structure.
|
// (ghost / soft surfaces). Look-only — does not change structure.
|
||||||
active: {
|
active: {
|
||||||
true: "",
|
true: "",
|
||||||
|
|||||||
74
packages/ui/src/v2/Link/Anchor.stories.tsx
Normal file
74
packages/ui/src/v2/Link/Anchor.stories.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { ArrowSquareOutIcon, GlobeSimpleIcon } from "@phosphor-icons/react";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
|
||||||
|
import { Anchor } from "./Anchor";
|
||||||
|
|
||||||
|
const sizes = [1, 2, 3, 4] as const;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/Anchor",
|
||||||
|
component: Anchor,
|
||||||
|
args: {
|
||||||
|
children: "blaxel.ai",
|
||||||
|
href: "https://example.com",
|
||||||
|
size: 2,
|
||||||
|
color: "neutral",
|
||||||
|
highContrast: false,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Anchor>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Anchor>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
export const Sizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
{sizes.map(size => (
|
||||||
|
<Anchor key={size} href="https://example.com" size={size}>
|
||||||
|
blaxel.ai
|
||||||
|
</Anchor>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithIcon: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<Anchor href="https://example.com" size={2} iconStart={<GlobeSimpleIcon />}>
|
||||||
|
blaxel.ai
|
||||||
|
</Anchor>
|
||||||
|
<Anchor href="https://example.com" size={2} iconEnd={<ArrowSquareOutIcon />}>
|
||||||
|
Documentation
|
||||||
|
</Anchor>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Plain: Story = {
|
||||||
|
args: {
|
||||||
|
underline: false,
|
||||||
|
iconStart: <GlobeSimpleIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,28 +21,28 @@
|
|||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import type { VariantProps } from "tailwind-variants/lite";
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
import { button } from "./variants";
|
import { link } from "./variants";
|
||||||
|
|
||||||
export type AnchorProps
|
export type AnchorProps
|
||||||
= Omit<ComponentProps<"a">, "color">
|
= Omit<ComponentProps<"a">, "color">
|
||||||
& VariantProps<typeof button>
|
& VariantProps<typeof link>
|
||||||
& {
|
& {
|
||||||
iconStart?: ReactNode;
|
iconStart?: ReactNode;
|
||||||
iconEnd?: ReactNode;
|
iconEnd?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Styled <a> sharing the Button look (Radix "Button"). Use for external links
|
// Underlined text <a> for external, mailto:, or tel: URLs. For in-app text
|
||||||
// or mailto:/tel:; for in-app navigation use Link (router). See
|
// links use Link; for button-looking <a> use ButtonAnchor. See
|
||||||
// contrib/claude/ui.md.
|
// contrib/claude/ui.md.
|
||||||
export function Anchor(props: AnchorProps) {
|
export function Anchor(props: AnchorProps) {
|
||||||
const {
|
const {
|
||||||
size, variant, color, highContrast, active, className,
|
size, color, highContrast, underline, className,
|
||||||
iconStart, iconEnd, children, ...rest
|
iconStart, iconEnd, children, ...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={button({ size, variant, color, highContrast, active, className })}
|
className={link({ size, color, highContrast, underline, className })}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{iconStart}
|
{iconStart}
|
||||||
@@ -18,24 +18,23 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
|
|
||||||
import { Link } from "./Link";
|
import { Link } from "./Link";
|
||||||
|
|
||||||
const navItems = ["Documents", "Subprocessors", "Updates", "Requests"] as const;
|
const sizes = [1, 2, 3, 4] as const;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "v2/Link",
|
title: "v2/Link",
|
||||||
component: Link,
|
component: Link,
|
||||||
args: {
|
args: {
|
||||||
children: "Documents",
|
children: "View all updates",
|
||||||
to: "#",
|
to: "#",
|
||||||
size: 2,
|
size: 2,
|
||||||
variant: "ghost",
|
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
highContrast: false,
|
highContrast: false,
|
||||||
active: false,
|
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
Story => (
|
Story => (
|
||||||
@@ -50,23 +49,28 @@ type Story = StoryObj<typeof Link>;
|
|||||||
|
|
||||||
export const Playground: Story = {};
|
export const Playground: Story = {};
|
||||||
|
|
||||||
// Mirrors the Compliance Portal top-bar nav: ghost links with a persistent active
|
export const Sizes: Story = {
|
||||||
// pill on the selected item.
|
|
||||||
export const Nav: Story = {
|
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-col items-start gap-3">
|
||||||
{navItems.map((item, index) => (
|
{sizes.map(size => (
|
||||||
<Link
|
<Link key={size} to="#" size={size}>
|
||||||
key={item}
|
View all updates
|
||||||
to="#"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
size={2}
|
|
||||||
active={index === 0}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WithIcon: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Link to="#" size={2} iconEnd={<ArrowRightIcon />}>
|
||||||
|
View all updates
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Plain: Story = {
|
||||||
|
args: {
|
||||||
|
underline: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -22,28 +22,27 @@ import type { ComponentProps, ReactNode } from "react";
|
|||||||
import { Link as RouterLink } from "react-router";
|
import { Link as RouterLink } from "react-router";
|
||||||
import type { VariantProps } from "tailwind-variants/lite";
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
import { button } from "./variants";
|
import { link } from "./variants";
|
||||||
|
|
||||||
export type LinkProps
|
export type LinkProps
|
||||||
= Omit<ComponentProps<typeof RouterLink>, "color">
|
= Omit<ComponentProps<typeof RouterLink>, "color">
|
||||||
& VariantProps<typeof button>
|
& VariantProps<typeof link>
|
||||||
& {
|
& {
|
||||||
iconStart?: ReactNode;
|
iconStart?: ReactNode;
|
||||||
iconEnd?: ReactNode;
|
iconEnd?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// In-app navigation link sharing the Button look (Radix "Button"). Renders a
|
// Underlined in-app text link (react-router). For external/mailto use Anchor;
|
||||||
// react-router Link; for external links use Anchor, for actions use Button. Set
|
// for button-looking navigation use ButtonLink. See contrib/claude/ui.md.
|
||||||
// `active` for the selected nav state. See contrib/claude/ui.md.
|
|
||||||
export function Link(props: LinkProps) {
|
export function Link(props: LinkProps) {
|
||||||
const {
|
const {
|
||||||
size, variant, color, highContrast, active, className,
|
size, color, highContrast, underline, className,
|
||||||
iconStart, iconEnd, children, ...rest
|
iconStart, iconEnd, children, ...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterLink
|
<RouterLink
|
||||||
className={button({ size, variant, color, highContrast, active, className })}
|
className={link({ size, color, highContrast, underline, className })}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{iconStart}
|
{iconStart}
|
||||||
78
packages/ui/src/v2/Link/variants.ts
Normal file
78
packages/ui/src/v2/Link/variants.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
// Text link (Link / Anchor). Default is always underlined; `underline={false}`
|
||||||
|
// keeps plain text until hover (meta rows, icon+label chrome). Sizes align
|
||||||
|
// with Text; color × highContrast compounds mirror typography neutrals.
|
||||||
|
// Button-looking navigation uses ButtonLink / ButtonAnchor instead.
|
||||||
|
export const link = tv({
|
||||||
|
base: [
|
||||||
|
"inline-flex items-center underline-offset-2",
|
||||||
|
"cursor-pointer outline-none transition-colors",
|
||||||
|
"focus-visible:rounded-1 focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
1: "gap-1 text-1 [&_svg]:size-3.5",
|
||||||
|
2: "gap-1.5 text-2 [&_svg]:size-4",
|
||||||
|
3: "gap-1.5 text-3 [&_svg]:size-4",
|
||||||
|
4: "gap-2 text-4 [&_svg]:size-5",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
neutral: "",
|
||||||
|
gold: "",
|
||||||
|
red: "",
|
||||||
|
green: "",
|
||||||
|
amber: "",
|
||||||
|
sky: "",
|
||||||
|
},
|
||||||
|
highContrast: {
|
||||||
|
true: "",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
// Look-only — does not change structure. false = underline on hover.
|
||||||
|
underline: {
|
||||||
|
true: "underline",
|
||||||
|
false: "no-underline hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{ color: "neutral", highContrast: false, class: "text-sand-11 hover:text-sand-12" },
|
||||||
|
{ color: "neutral", highContrast: true, class: "text-sand-12 hover:text-sand-12" },
|
||||||
|
{ color: "gold", highContrast: false, class: "text-gold-11 hover:text-gold-12" },
|
||||||
|
{ color: "gold", highContrast: true, class: "text-gold-12 hover:text-gold-12" },
|
||||||
|
{ color: "red", highContrast: false, class: "text-red-11 hover:text-red-12" },
|
||||||
|
{ color: "red", highContrast: true, class: "text-red-12 hover:text-red-12" },
|
||||||
|
{ color: "green", highContrast: false, class: "text-green-11 hover:text-green-12" },
|
||||||
|
{ color: "green", highContrast: true, class: "text-green-12 hover:text-green-12" },
|
||||||
|
{ color: "amber", highContrast: false, class: "text-amber-11 hover:text-amber-12" },
|
||||||
|
{ color: "amber", highContrast: true, class: "text-amber-12 hover:text-amber-12" },
|
||||||
|
{ color: "sky", highContrast: false, class: "text-sky-11 hover:text-sky-12" },
|
||||||
|
{ color: "sky", highContrast: true, class: "text-sky-12 hover:text-sky-12" },
|
||||||
|
],
|
||||||
|
defaultVariants: {
|
||||||
|
size: 2,
|
||||||
|
color: "neutral",
|
||||||
|
highContrast: false,
|
||||||
|
underline: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user