diff --git a/package.json b/package.json index 86c71f6..7fdf8c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.55.0", + "version": "0.56.2", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx b/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx new file mode 100644 index 0000000..1b33046 --- /dev/null +++ b/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx @@ -0,0 +1,277 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "storybook/test"; +import { + ArrowUp, + Bell, + Edit, + Heart, + MessageCircle, + Plus, + Trash2, +} from "lucide-react"; +import type { ReactElement } from "react"; + +import { + FloatingActionButton, + floatingActionButtonPositionIds, + floatingActionButtonSizeIds, + floatingActionButtonVariantIds, + type FloatingActionButtonPositionId, + type FloatingActionButtonSizeId, + type FloatingActionButtonVariantId, +} from "./floating-action-button"; + +interface FloatingActionButtonExampleProps { + variant?: FloatingActionButtonVariantId; + size?: FloatingActionButtonSizeId; + position?: FloatingActionButtonPositionId; + label?: string; + disabled?: boolean; + onClick?: () => void; +} + +function FloatingActionButtonExample({ + label, + position, + ...props +}: FloatingActionButtonExampleProps): ReactElement { + const isFixed = position !== undefined && position !== "static"; + return ( +
+

+ This box acts as a stand-in viewport so you can see how the FAB anchors + to each corner without it escaping into the Storybook chrome. In a real + app, non-static positions are position: fixed{" "} + relative to the browser viewport. +

+ } + aria-label="Create new item" + className={isFixed ? "absolute" : undefined} + /> +
+ ); +} + +const meta = { + title: "Components/FloatingActionButton", + component: FloatingActionButtonExample, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + argTypes: { + variant: { + options: floatingActionButtonVariantIds, + control: { type: "select" }, + }, + size: { + options: floatingActionButtonSizeIds, + control: { type: "radio" }, + }, + position: { + options: floatingActionButtonPositionIds, + control: { type: "select" }, + }, + label: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + }, + args: { + variant: "primary", + size: "default", + position: "bottom-right", + disabled: false, + onClick: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Extended: Story = { + args: { + label: "New item", + }, +}; + +export const Brand: Story = { + args: { + variant: "brand", + }, +}; + +export const Destructive: Story = { + args: { + variant: "destructive", + label: "Delete", + }, +}; + +export const Outline: Story = { + args: { + variant: "outline", + }, +}; + +export const Small: Story = { + args: { + size: "sm", + }, +}; + +export const Large: Story = { + args: { + size: "lg", + label: "Compose", + }, +}; + +export const BottomLeft: Story = { + args: { + position: "bottom-left", + }, +}; + +export const TopRight: Story = { + args: { + position: "top-right", + variant: "secondary", + }, +}; + +function AllVariantsExample(): ReactElement { + return ( +
+ {floatingActionButtonVariantIds.map((variant) => ( +
+ } + aria-label={`${variant} FAB`} + /> + + {variant} + +
+ ))} +
+ ); +} + +export const AllVariants: Story = { + render: () => , + args: {}, +}; + +function AllSizesExample(): ReactElement { + return ( +
+ {floatingActionButtonSizeIds.map((size) => ( +
+ } + aria-label={`${size} FAB`} + /> + + {size} + +
+ ))} +
+ ); +} + +export const AllSizes: Story = { + render: () => , + args: {}, +}; + +function ExtendedShowcaseExample(): ReactElement { + return ( +
+ } + label="Compose" + /> + } + label="New message" + /> + } + label="Delete forever" + /> + } + label="Favorite" + /> + } + label="Notifications" + /> +
+ ); +} + +export const ExtendedShowcase: Story = { + render: () => , + args: {}, +}; + +function ScrollToTopExample(): ReactElement { + return ( +
+
+

Long scrollable content

+ {Array.from({ length: 14 }).map((_, idx) => ( +

+ Paragraph {idx + 1} - A floating action button is most useful when + pinned to a fixed corner of the viewport. In this story it is pinned + to the bottom-right of this container so reviewers can see how it + stays anchored while the surrounding content scrolls underneath. +

+ ))} +
+ } + aria-label="Scroll to top" + className="absolute" + /> +
+ ); +} + +export const PinnedToContainer: Story = { + render: () => , + args: {}, +}; diff --git a/src/components/ui/floating-action-button/floating-action-button.tsx b/src/components/ui/floating-action-button/floating-action-button.tsx new file mode 100644 index 0000000..e602c7b --- /dev/null +++ b/src/components/ui/floating-action-button/floating-action-button.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type { + ButtonHTMLAttributes, + ReactElement, + ReactNode, + Ref, +} from "react"; + +import { cn } from "@/lib/utils"; + +export const floatingActionButtonVariantIds = [ + "primary", + "secondary", + "destructive", + "outline", + "brand", +] as const satisfies string[]; + +export type FloatingActionButtonVariantId = + (typeof floatingActionButtonVariantIds)[number]; + +export const floatingActionButtonSizeIds = [ + "sm", + "default", + "lg", +] as const satisfies string[]; + +export type FloatingActionButtonSizeId = + (typeof floatingActionButtonSizeIds)[number]; + +export const floatingActionButtonPositionIds = [ + "bottom-right", + "bottom-left", + "top-right", + "top-left", + "static", +] as const satisfies string[]; + +export type FloatingActionButtonPositionId = + (typeof floatingActionButtonPositionIds)[number]; + +const floatingActionButtonVariants = cva( + "group/fab inline-flex items-center justify-center gap-2 font-medium ring-offset-background shadow-lg transition-[background-color,border-color,color,box-shadow,transform] hover:shadow-xl active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none z-40", + { + variants: { + variant: { + primary: + "bg-primary text-primary-foreground hover:bg-primary/90 shadow-primary/25", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-secondary/25", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-destructive/30", + outline: + "border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground", + brand: + "bg-schemavaults-brand-blue text-primary-foreground hover:bg-schemavaults-brand-blue/90 shadow-schemavaults-brand-blue/30", + } satisfies Record, + size: { + sm: "h-10 w-10 rounded-full text-sm [&_svg]:size-4", + default: "h-14 w-14 rounded-full text-base [&_svg]:size-6", + lg: "h-16 w-16 rounded-full text-base [&_svg]:size-7", + } satisfies Record, + extended: { + true: "rounded-full w-auto", + false: "", + }, + position: { + "bottom-right": "fixed bottom-6 right-6", + "bottom-left": "fixed bottom-6 left-6", + "top-right": "fixed top-6 right-6", + "top-left": "fixed top-6 left-6", + static: "", + } satisfies Record, + }, + compoundVariants: [ + { extended: true, size: "sm", class: "h-10 px-4" }, + { extended: true, size: "default", class: "h-14 px-6" }, + { extended: true, size: "lg", class: "h-16 px-8" }, + ], + defaultVariants: { + variant: "primary", + size: "default", + extended: false, + position: "bottom-right", + }, + }, +); + +export interface FloatingActionButtonProps + extends Omit, "children">, + VariantProps { + ref?: Ref; + /** + * Icon rendered inside the FAB. Required for icon-only mode. + */ + icon: ReactNode; + /** + * Optional label rendered alongside the icon. When provided, the FAB + * switches to its extended layout (icon + text) unless `extended` is + * explicitly set to false. + */ + label?: ReactNode; + /** + * Accessible label for the button. Strongly recommended in icon-only + * mode where no visible text describes the action. + */ + "aria-label"?: string; + /** + * Render the button as a different element via Radix Slot (useful for + * wrapping with a Link component while preserving styles). + */ + asChild?: boolean; +} + +function FloatingActionButton({ + className, + variant, + size, + position, + extended, + icon, + label, + asChild = false, + type = "button", + ref, + ...props +}: FloatingActionButtonProps): ReactElement { + const Comp = asChild ? Slot : "button"; + const isExtended = extended ?? Boolean(label); + + return ( + + + {isExtended && label != null ? ( + + {label} + + ) : null} + + ); +} +FloatingActionButton.displayName = "FloatingActionButton"; + +export { FloatingActionButton, floatingActionButtonVariants }; + +export default FloatingActionButton; diff --git a/src/components/ui/floating-action-button/index.ts b/src/components/ui/floating-action-button/index.ts new file mode 100644 index 0000000..ee68c4f --- /dev/null +++ b/src/components/ui/floating-action-button/index.ts @@ -0,0 +1,4 @@ +export * from "./floating-action-button"; +export type * from "./floating-action-button"; + +export { default } from "./floating-action-button"; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 9484b88..67ca7b8 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -248,3 +248,6 @@ export type * from "./carousel"; export * from "./countdown"; export type * from "./countdown"; + +export * from "./floating-action-button"; +export type * from "./floating-action-button";