-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #23 from pyyding/kp-add-button-component
Button component
- Loading branch information
Showing
6 changed files
with
461 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { cx } from "../common/utils.ts"; | ||
|
||
type LoadingSpinnerProps = { | ||
className?: string; | ||
}; | ||
|
||
export function LoadingSpinner({ className }: LoadingSpinnerProps) { | ||
return ( | ||
<svg | ||
className={cx( | ||
"animate-spin h-3.5 w-3.5 border-2 border-b-transparent rounded-full border-current", | ||
className, | ||
)} | ||
viewBox="0 0 24 24" | ||
></svg> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,194 @@ | ||
import React from "react"; | ||
import { cx } from "../common/utils.ts"; | ||
import { LoadingSpinner } from "./LoadingSpinner.tsx"; | ||
|
||
type ButtonColor = "gray" | "purple" | "red" | "black" | "yellow"; | ||
type ButtonVariant = "solid" | "outline" | "ghost"; | ||
type ButtonSize = "xs" | "sm" | "md" | "lg"; | ||
|
||
export type ButtonProps = { | ||
children: React.ReactNode; | ||
variant?: ButtonVariant; | ||
color?: ButtonColor; | ||
size?: ButtonSize; | ||
} & ( | ||
| (React.AnchorHTMLAttributes<HTMLAnchorElement> & { | ||
onClick?: never; | ||
disabled?: never; | ||
loading?: never; | ||
loadingText?: never; | ||
}) | ||
| (React.ButtonHTMLAttributes<HTMLButtonElement> & { | ||
href?: never; | ||
target?: never; | ||
rel?: never; | ||
loading?: boolean; | ||
loadingText?: string; | ||
}) | ||
); | ||
|
||
const buttonStyleConfig = { | ||
base: | ||
"inline-flex appearance-none items-center justify-center select-none relative whitespace-nowrap " + | ||
"align-middle outline-none rounded-md font-medium transition-colors transition-shadow " + | ||
"min-w-[2.5rem] gap-2 " + | ||
"disabled:opacity-40 disabled:shadow-none disabled:pointer-events-none " + | ||
"focus-visible:shadow-outline", | ||
size: { | ||
xs: "text-xs px-2 h-6", | ||
sm: "text-sm px-3 h-8", | ||
md: "text-sm px-3 py-1.5 h-8", | ||
lg: "text-lg px-6 h-12", | ||
}, | ||
variant: { | ||
solid: "border shadow-subtle", | ||
outline: "bg-transparent border hover:bg-opacity-50", | ||
ghost: "bg-transparent border-none", | ||
}, | ||
color: { | ||
gray: { | ||
solid: | ||
"text-gray-800 bg-whiteAlpha-900 border-gray-200 hover:bg-gray-100 active:bg-gray-300", | ||
ghost: "text-gray-800 hover:bg-gray-100 active:bg-gray-200", | ||
outline: | ||
"border-gray-200 text-gray-800 hover:bg-gray-100 active:bg-gray-200", | ||
}, | ||
purple: { | ||
solid: | ||
"bg-purple-500 text-white border-current hover:bg-purple-600 active:bg-purple-700", | ||
ghost: | ||
"text-purple-500 border-none hover:bg-purple-50 active:bg-purple-100", | ||
outline: "text-purple-500 hover:bg-purple-50 active:bg-purple-100", | ||
}, | ||
red: { | ||
solid: | ||
"text-white bg-red-500 border-current hover:bg-red-600 active:bg-red-700", | ||
ghost: "text-red-500 hover:bg-red-50 active:bg-red-100", | ||
outline: "text-red-500 border-current hover:bg-red-50 active:bg-red-100", | ||
}, | ||
black: { | ||
solid: | ||
"text-whiteAlpha-900 bg-gray-800 border-current hover:bg-gray-900 active:bg-gray-900", | ||
ghost: "text-gray-800 hover:bg-gray-50 active:bg-gray-100", | ||
outline: | ||
"text-gray-800 border-current hover:bg-gray-50 active:bg-gray-100", | ||
}, | ||
yellow: { | ||
solid: | ||
"text-gray-800 bg-yellow-400 hover:bg-yellow-500 active:bg-yellow-600", | ||
ghost: "text-yellow-500 hover:bg-yellow-50 active:bg-yellow-100", | ||
outline: | ||
"text-yellow-500 border-current hover:bg-yellow-50 hover:bg-opacity-50 active:bg-yellow-100", | ||
}, | ||
}, | ||
}; | ||
|
||
function buttonStyles({ | ||
variant, | ||
color, | ||
size, | ||
}: { | ||
variant: ButtonVariant; | ||
color: ButtonColor; | ||
size: ButtonSize; | ||
}): string { | ||
const variantStyle = buttonStyleConfig.variant[variant]; | ||
const colorStyle = buttonStyleConfig.color[color][variant]; | ||
const sizeStyle = buttonStyleConfig.size[size]; | ||
return cx(buttonStyleConfig.base, colorStyle, variantStyle, sizeStyle); | ||
} | ||
|
||
export const Button = React.forwardRef< | ||
HTMLButtonElement | HTMLLinkElement, | ||
ButtonProps | ||
>((props, ref) => { | ||
const { | ||
variant = "solid", | ||
color = "gray", | ||
size = "md", | ||
className, | ||
children, | ||
...rest | ||
} = props; | ||
|
||
const buttonStyle = buttonStyles({ variant, color, size }); | ||
|
||
if (rest.href) { | ||
return ( | ||
<a | ||
className={cx(className, buttonStyle)} | ||
{...(rest as React.AnchorHTMLAttributes<HTMLAnchorElement>)} | ||
ref={ref as React.RefObject<HTMLAnchorElement>} | ||
> | ||
<TouchTarget>{children}</TouchTarget> | ||
</a> | ||
); | ||
} | ||
|
||
const { disabled, loading, loadingText, ...buttonProps } = rest; | ||
|
||
const isDisabled = disabled || loading; | ||
|
||
return ( | ||
<button | ||
disabled={isDisabled} | ||
aria-label={loading && !loadingText ? "Loading, please wait" : undefined} | ||
className={cx(className, buttonStyle)} | ||
{...(buttonProps as React.ButtonHTMLAttributes<HTMLButtonElement>)} | ||
ref={ref as React.RefObject<HTMLButtonElement>} | ||
> | ||
<LoaderWrapper loading={loading} loadingText={loadingText}> | ||
<TouchTarget>{children}</TouchTarget> | ||
</LoaderWrapper> | ||
</button> | ||
); | ||
}); | ||
|
||
/* Expand the hit area to at least 44×44px on touch devices */ | ||
export function TouchTarget({ children }: { children: React.ReactNode }) { | ||
return ( | ||
<> | ||
{children} | ||
<span | ||
className="absolute left-1/2 top-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 [@media(pointer:fine)]:hidden" | ||
aria-hidden="true" | ||
/> | ||
</> | ||
); | ||
} | ||
|
||
function LoaderWrapper({ | ||
loading, | ||
loadingText, | ||
children, | ||
}: { | ||
loading?: boolean; | ||
loadingText?: string; | ||
children: React.ReactNode; | ||
}) { | ||
if (!loading) { | ||
return children; | ||
} | ||
|
||
return ( | ||
<> | ||
{loadingText ? ( | ||
<> | ||
<LoadingSpinner /> | ||
{loadingText} | ||
</> | ||
) : ( | ||
<LoaderWithInitialWidth>{children}</LoaderWithInitialWidth> | ||
)} | ||
</> | ||
); | ||
} | ||
|
||
function LoaderWithInitialWidth({ children }: { children: React.ReactNode }) { | ||
return ( | ||
<div className="inline-flex items-center relative"> | ||
<LoadingSpinner className="absolute inset-0 m-auto" /> | ||
<div className="invisible">{children}</div> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,19 +1,32 @@ | ||
import './styles.css'; | ||
import "./styles.css"; | ||
|
||
export { BarChart } from './components/BarChart' | ||
export type { BarChartProps } from './components/BarChart' | ||
export { BarItem } from './components/BarChart/BarItem' | ||
export type { BarItemProps } from './components/BarChart/BarItem' | ||
export { XAxis } from './components/XAxis' | ||
export { defaultXAxisProps } from './components/XAxis/constants' | ||
export type { XAxisProps } from './components/XAxis' | ||
export { YAxis } from './components/YAxis' | ||
export { defaultYAxisProps } from './components/YAxis/constants' | ||
export type { YAxisProps } from './components/YAxis' | ||
export { Grid } from './components/Grid' | ||
export { defaultGridProps } from './components/Grid/constants' | ||
export { ChartTooltip, DefaultTooltip, ChartTooltipTitle, ChartTooltipValue, ChartTooltipFooter } from './components/ChartTooltip' | ||
export { EmptyState } from './components/EmptyState' | ||
export type { EmptyStateProps } from './components/EmptyState' | ||
export { Lifecycle, Command, CommandOption, CommandList, CommandInput } from './components/Command' | ||
export { Popover } from './components/Popover' | ||
export { BarChart } from "./components/BarChart"; | ||
export type { BarChartProps } from "./components/BarChart"; | ||
export { BarItem } from "./components/BarChart/BarItem"; | ||
export type { BarItemProps } from "./components/BarChart/BarItem"; | ||
export { XAxis } from "./components/XAxis"; | ||
export { defaultXAxisProps } from "./components/XAxis/constants"; | ||
export type { XAxisProps } from "./components/XAxis"; | ||
export { YAxis } from "./components/YAxis"; | ||
export { defaultYAxisProps } from "./components/YAxis/constants"; | ||
export type { YAxisProps } from "./components/YAxis"; | ||
export { Grid } from "./components/Grid"; | ||
export { defaultGridProps } from "./components/Grid/constants"; | ||
export { | ||
ChartTooltip, | ||
DefaultTooltip, | ||
ChartTooltipTitle, | ||
ChartTooltipValue, | ||
ChartTooltipFooter, | ||
} from "./components/ChartTooltip"; | ||
export { EmptyState } from "./components/EmptyState"; | ||
export type { EmptyStateProps } from "./components/EmptyState"; | ||
export { | ||
Lifecycle, | ||
Command, | ||
CommandOption, | ||
CommandList, | ||
CommandInput, | ||
} from "./components/Command"; | ||
export { Popover } from "./components/Popover"; | ||
export { Button, type ButtonProps } from "./components/Button"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
import type { Meta, StoryObj } from "@storybook/react"; | ||
import { Button } from "../../lib/components/Button"; | ||
import { MagnifyingGlassIcon } from "../../lib/icons/MagnifyingGlassIcon.tsx"; | ||
|
||
const meta: Meta<typeof Button> = { | ||
title: "Components/Button", | ||
component: Button, | ||
args: {}, | ||
}; | ||
|
||
export default meta; | ||
|
||
type Story = StoryObj<typeof Button>; | ||
|
||
export const Default: Story = { | ||
args: { | ||
children: "Default button", | ||
}, | ||
}; | ||
|
||
export const Disabled: Story = { | ||
args: { | ||
children: "Disabled", | ||
disabled: true, | ||
}, | ||
}; | ||
|
||
export const YellowOutline: Story = { | ||
args: { | ||
children: "Outline", | ||
variant: "outline", | ||
color: "yellow", | ||
}, | ||
}; | ||
|
||
export const PurpleGhost: Story = { | ||
args: { | ||
children: "Purple ghost", | ||
color: "purple", | ||
variant: "ghost", | ||
}, | ||
}; | ||
|
||
export const Loading: Story = { | ||
args: { | ||
children: "Long button text", | ||
loading: true, | ||
}, | ||
}; | ||
|
||
export const LoadingWithText: Story = { | ||
args: { | ||
children: "Loading", | ||
loading: true, | ||
loadingText: "Submitting...", | ||
}, | ||
}; | ||
|
||
export const Large: Story = { | ||
args: { | ||
children: "Large", | ||
size: "lg", | ||
}, | ||
}; | ||
|
||
export const IconAndText: Story = { | ||
args: { | ||
children: ( | ||
<> | ||
<MagnifyingGlassIcon /> | ||
Search | ||
</> | ||
), | ||
}, | ||
}; | ||
|
||
export const IconOnly: Story = { | ||
args: { | ||
children: <MagnifyingGlassIcon />, | ||
color: "purple", | ||
size: "sm", | ||
variant: "ghost", | ||
}, | ||
}; | ||
|
||
export const Link: Story = { | ||
args: { | ||
children: "Open June", | ||
variant: "ghost", | ||
color: "purple", | ||
href: "https://june.so", | ||
target: "_blank", | ||
rel: "noreferrer", | ||
}, | ||
}; |
Oops, something went wrong.