Add v2 Anchor and Link components
Round out the button family with the two navigational siblings described in the UI guide: Anchor renders a styled <a> for external links and Link renders a react-router link for in-app navigation, both sharing Button's tv styles. Add an `active` look variant so nav items can carry a persistent selected state without forking structure. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
71
packages/ui/src/v2/Button/Anchor.stories.tsx
Normal file
71
packages/ui/src/v2/Button/Anchor.stories.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { ArrowSquareOutIcon } from "@phosphor-icons/react";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
|
||||||
|
import { Anchor } from "./Anchor";
|
||||||
|
|
||||||
|
const sizes = [1, 2, 3, 4] as const;
|
||||||
|
const variants = ["classic", "solid", "soft", "surface", "outline", "ghost"] as const;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/Anchor",
|
||||||
|
component: Anchor,
|
||||||
|
args: {
|
||||||
|
children: "Documentation",
|
||||||
|
href: "#",
|
||||||
|
size: 2,
|
||||||
|
variant: "solid",
|
||||||
|
color: "gold",
|
||||||
|
highContrast: false,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Anchor>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Anchor>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
export const Sizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{sizes.map(size => (
|
||||||
|
<Anchor key={size} href="#" size={size}>
|
||||||
|
Documentation
|
||||||
|
</Anchor>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Variants: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{variants.map(variant => (
|
||||||
|
<Anchor key={variant} href="#" variant={variant}>
|
||||||
|
{variant}
|
||||||
|
</Anchor>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithIcon: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Anchor href="#" variant="soft" color="neutral" iconEnd={<ArrowSquareOutIcon />}>
|
||||||
|
External link
|
||||||
|
</Anchor>
|
||||||
|
),
|
||||||
|
};
|
||||||
47
packages/ui/src/v2/Button/Anchor.tsx
Normal file
47
packages/ui/src/v2/Button/Anchor.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { button } from "./variants";
|
||||||
|
|
||||||
|
export type AnchorProps
|
||||||
|
= Omit<ComponentProps<"a">, "color">
|
||||||
|
& VariantProps<typeof button>
|
||||||
|
& {
|
||||||
|
iconStart?: ReactNode;
|
||||||
|
iconEnd?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styled <a> sharing the Button look (Radix "Button"). Use for external links
|
||||||
|
// or mailto:/tel:; for in-app navigation use Link (router). See
|
||||||
|
// contrib/claude/ui.md.
|
||||||
|
export function Anchor(props: AnchorProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
packages/ui/src/v2/Button/Link.stories.tsx
Normal file
66
packages/ui/src/v2/Button/Link.stories.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
|
||||||
|
import { Link } from "./Link";
|
||||||
|
|
||||||
|
const navItems = ["Documents", "Subprocessors", "Updates", "Requests"] as const;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/Link",
|
||||||
|
component: Link,
|
||||||
|
args: {
|
||||||
|
children: "Documents",
|
||||||
|
to: "#",
|
||||||
|
size: 2,
|
||||||
|
variant: "ghost",
|
||||||
|
color: "neutral",
|
||||||
|
highContrast: false,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
Story => (
|
||||||
|
<MemoryRouter>
|
||||||
|
<Story />
|
||||||
|
</MemoryRouter>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
} satisfies Meta<typeof Link>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Link>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
// Mirrors the Trust Center 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) => (
|
||||||
|
<Link
|
||||||
|
key={item}
|
||||||
|
to="#"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size={2}
|
||||||
|
active={index === 0}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
48
packages/ui/src/v2/Button/Link.tsx
Normal file
48
packages/ui/src/v2/Button/Link.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS 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 LinkProps
|
||||||
|
= Omit<ComponentProps<typeof RouterLink>, "color">
|
||||||
|
& VariantProps<typeof button>
|
||||||
|
& {
|
||||||
|
iconStart?: ReactNode;
|
||||||
|
iconEnd?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-app navigation link sharing the Button look (Radix "Button"). Renders a
|
||||||
|
// react-router Link; for external links use Anchor, for actions use Button. Set
|
||||||
|
// `active` for the selected nav state. See contrib/claude/ui.md.
|
||||||
|
export function Link(props: LinkProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,6 +53,12 @@ export const button = tv({
|
|||||||
true: "",
|
true: "",
|
||||||
false: "",
|
false: "",
|
||||||
},
|
},
|
||||||
|
// Persistent "selected" treatment for nav items built on Anchor / Link
|
||||||
|
// (ghost / soft surfaces). Look-only — does not change structure.
|
||||||
|
active: {
|
||||||
|
true: "",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
compoundVariants: [
|
compoundVariants: [
|
||||||
// ── solid: filled step-9 background, white text (dark for light hues) ──
|
// ── solid: filled step-9 background, white text (dark for light hues) ──
|
||||||
@@ -104,6 +110,21 @@ export const button = tv({
|
|||||||
{ variant: "ghost", color: "amber", class: "text-amber-11 hover:bg-amber-3" },
|
{ variant: "ghost", color: "amber", class: "text-amber-11 hover:bg-amber-3" },
|
||||||
{ variant: "ghost", color: "sky", class: "text-sky-11 hover:bg-sky-3" },
|
{ variant: "ghost", color: "sky", class: "text-sky-11 hover:bg-sky-3" },
|
||||||
|
|
||||||
|
// ── active: persistent selected background + high-contrast text ──
|
||||||
|
// ghost goes from transparent to the step-3 tint; soft bumps to step-4.
|
||||||
|
{ variant: "ghost", color: "neutral", active: true, class: "bg-sand-3 text-sand-12" },
|
||||||
|
{ variant: "ghost", color: "gold", active: true, class: "bg-gold-3 text-gold-12" },
|
||||||
|
{ variant: "ghost", color: "red", active: true, class: "bg-red-3 text-red-12" },
|
||||||
|
{ variant: "ghost", color: "green", active: true, class: "bg-green-3 text-green-12" },
|
||||||
|
{ variant: "ghost", color: "amber", active: true, class: "bg-amber-3 text-amber-12" },
|
||||||
|
{ variant: "ghost", color: "sky", active: true, class: "bg-sky-3 text-sky-12" },
|
||||||
|
{ variant: "soft", color: "neutral", active: true, class: "bg-sand-4 text-sand-12" },
|
||||||
|
{ variant: "soft", color: "gold", active: true, class: "bg-gold-4 text-gold-12" },
|
||||||
|
{ variant: "soft", color: "red", active: true, class: "bg-red-4 text-red-12" },
|
||||||
|
{ variant: "soft", color: "green", active: true, class: "bg-green-4 text-green-12" },
|
||||||
|
{ variant: "soft", color: "amber", active: true, class: "bg-amber-4 text-amber-12" },
|
||||||
|
{ variant: "soft", color: "sky", active: true, class: "bg-sky-4 text-sky-12" },
|
||||||
|
|
||||||
// ── high-contrast text bump for the tinted variants ──
|
// ── high-contrast text bump for the tinted variants ──
|
||||||
{ variant: ["soft", "surface", "outline", "ghost"], color: "neutral", highContrast: true, class: "text-sand-12" },
|
{ variant: ["soft", "surface", "outline", "ghost"], color: "neutral", highContrast: true, class: "text-sand-12" },
|
||||||
{ variant: ["soft", "surface", "outline", "ghost"], color: "gold", highContrast: true, class: "text-gold-12" },
|
{ variant: ["soft", "surface", "outline", "ghost"], color: "gold", highContrast: true, class: "text-gold-12" },
|
||||||
@@ -117,6 +138,7 @@ export const button = tv({
|
|||||||
variant: "solid",
|
variant: "solid",
|
||||||
color: "gold",
|
color: "gold",
|
||||||
highContrast: false,
|
highContrast: false,
|
||||||
|
active: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user