From 2f8c5f12a58dd4e6c8161c13790fd6d08010c396 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 07:54:51 +0000 Subject: [PATCH 1/3] 0.56.0 - Add FloatingActionButton component with variants, sizes, positions, and extended-label mode Introduces a new FloatingActionButton component (Material-style FAB) for pinned primary actions. Supports five color variants (primary, secondary, destructive, outline, brand), three sizes, four fixed positions plus a static mode, and an extended layout when a label is provided. Includes a Storybook story showcasing all variants, sizes, an extended showcase, and a pinned-to-container example. --- package.json | 2 +- .../FloatingActionButton.stories.tsx | 272 ++++++++++++++++++ .../floating-action-button.tsx | 180 ++++++++++++ .../ui/floating-action-button/index.ts | 4 + src/components/ui/index.ts | 3 + 5 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/floating-action-button/FloatingActionButton.stories.tsx create mode 100644 src/components/ui/floating-action-button/floating-action-button.tsx create mode 100644 src/components/ui/floating-action-button/index.ts diff --git a/package.json b/package.json index 86c71f6..24e1780 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.55.0", + "version": "0.56.0", "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..54db41d --- /dev/null +++ b/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx @@ -0,0 +1,272 @@ +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, + ...props +}: FloatingActionButtonExampleProps): ReactElement { + return ( +
+

+ The floating action button is rendered inside this container. Adjust the + controls in the side panel to explore the available variants, sizes, and + positions. +

+ } + aria-label="Create new item" + /> +
+ ); +} + +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..8f4a1f1 --- /dev/null +++ b/src/components/ui/floating-action-button/floating-action-button.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + forwardRef, + type ButtonHTMLAttributes, + type ReactElement, + type ReactNode, + type 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 { + /** + * 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 FloatingActionButtonImpl( + { + className, + variant, + size, + position, + extended, + icon, + label, + asChild = false, + type = "button", + ...props + }: FloatingActionButtonProps, + ref: Ref, +): ReactElement { + const Comp = asChild ? Slot : "button"; + const isExtended = extended ?? Boolean(label); + + return ( + + + {isExtended && label != null ? ( + + {label} + + ) : null} + + ); +} + +export const FloatingActionButton = forwardRef< + HTMLButtonElement, + FloatingActionButtonProps +>(FloatingActionButtonImpl); +FloatingActionButton.displayName = "FloatingActionButton"; + +export { 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"; From 9307791cdc23dd1976356f09f0c24345491779bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 12:03:05 +0000 Subject: [PATCH 2/3] 0.56.1 - FloatingActionButton: use React 19 ref-as-prop instead of deprecated forwardRef React 19 supports passing `ref` directly through component props, making `forwardRef` unnecessary. Refactored FloatingActionButton to accept `ref` as a prop and dropped the `forwardRef` wrapper, matching the pattern already used elsewhere in the package (e.g. Banner). --- package.json | 2 +- .../floating-action-button.tsx | 47 ++++++++----------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 24e1780..9fdf6f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.56.0", + "version": "0.56.1", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/floating-action-button/floating-action-button.tsx b/src/components/ui/floating-action-button/floating-action-button.tsx index 8f4a1f1..e602c7b 100644 --- a/src/components/ui/floating-action-button/floating-action-button.tsx +++ b/src/components/ui/floating-action-button/floating-action-button.tsx @@ -2,12 +2,11 @@ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import { - forwardRef, - type ButtonHTMLAttributes, - type ReactElement, - type ReactNode, - type Ref, +import type { + ButtonHTMLAttributes, + ReactElement, + ReactNode, + Ref, } from "react"; import { cn } from "@/lib/utils"; @@ -93,6 +92,7 @@ const floatingActionButtonVariants = cva( export interface FloatingActionButtonProps extends Omit, "children">, VariantProps { + ref?: Ref; /** * Icon rendered inside the FAB. Required for icon-only mode. */ @@ -115,21 +115,19 @@ export interface FloatingActionButtonProps asChild?: boolean; } -function FloatingActionButtonImpl( - { - className, - variant, - size, - position, - extended, - icon, - label, - asChild = false, - type = "button", - ...props - }: FloatingActionButtonProps, - ref: Ref, -): ReactElement { +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); @@ -168,13 +166,8 @@ function FloatingActionButtonImpl( ); } - -export const FloatingActionButton = forwardRef< - HTMLButtonElement, - FloatingActionButtonProps ->(FloatingActionButtonImpl); FloatingActionButton.displayName = "FloatingActionButton"; -export { floatingActionButtonVariants }; +export { FloatingActionButton, floatingActionButtonVariants }; export default FloatingActionButton; From 868ae10f8b6117b78e772b2ce83648adcea484a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 12:16:45 +0000 Subject: [PATCH 3/3] 0.56.2 - FloatingActionButton stories: contain the FAB inside the playground container The interactive playground claimed the FAB was "rendered inside this container" but the FAB uses position: fixed, so it actually anchored to the Storybook viewport instead. Updated the playground wrapper to act as a stand-in viewport: it now has overflow-hidden, and when a non-static position is selected the FAB is overridden to position: absolute so it pins to the demo box's corners. Reworded the description to accurately explain the behavior (fixed in real apps, absolute here for visualization). --- package.json | 2 +- .../FloatingActionButton.stories.tsx | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 9fdf6f0..7fdf8c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.56.1", + "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 index 54db41d..1b33046 100644 --- a/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx +++ b/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx @@ -32,20 +32,25 @@ interface FloatingActionButtonExampleProps { function FloatingActionButtonExample({ label, + position, ...props }: FloatingActionButtonExampleProps): ReactElement { + const isFixed = position !== undefined && position !== "static"; return ( -
+

- The floating action button is rendered inside this container. Adjust the - controls in the side panel to explore the available variants, sizes, and - positions. + 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} />
);