diff --git a/src/components/fab/FabMenu.stories.tsx b/src/components/fab/FabMenu.stories.tsx new file mode 100644 index 0000000..91b6431 --- /dev/null +++ b/src/components/fab/FabMenu.stories.tsx @@ -0,0 +1,164 @@ +import { + mdiContentCopy, + mdiContentCut, + mdiContentPaste, + mdiDelete, + mdiPencilOutline, + mdiShare, +} from '@mdi/js' +import Icon from '@mdi/react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Fab } from './Fab' +import { FabMenu, FabMenuItem } from './FabMenu' + +const meta: Meta = { + title: 'components/Button/FabMenu', + component: FabMenu, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + variant: { + options: ['primary', 'secondary', 'tertiary'], + control: { type: 'radio' }, + }, + fabSize: { + options: ['small', 'default', 'large'], + control: { type: 'radio' }, + }, + }, +} + +export default meta + +type Story = StoryObj + +const createMenuItems = () => [ + }> + Edit + , + }> + Copy + , + }> + Cut + , + }> + Paste + , + }> + Share + , + }> + Delete + , +] + +export const Primary: Story = { + args: { + variant: 'primary', + fabSize: 'default', + items: createMenuItems(), + children: ( + + + + ), + }, +} + +export const Secondary: Story = { + args: { + variant: 'secondary', + fabSize: 'default', + items: createMenuItems(), + children: ( + + + + ), + }, +} + +export const Tertiary: Story = { + args: { + variant: 'tertiary', + fabSize: 'default', + items: createMenuItems(), + children: ( + + + + ), + }, +} + +export const SmallFab: Story = { + args: { + variant: 'primary', + fabSize: 'small', + items: createMenuItems().slice(0, 3), // Show fewer items for small FAB + children: ( + + + + ), + }, +} + +export const LargeFab: Story = { + args: { + variant: 'primary', + fabSize: 'large', + items: createMenuItems(), + children: ( + + + + ), + }, +} + +export const WithDisabledItems: Story = { + args: { + variant: 'primary', + fabSize: 'default', + items: [ + }> + Edit + , + } disabled> + Copy (disabled) + , + }> + Share + , + ], + children: ( + + + + ), + }, +} + +export const MaximumItems: Story = { + args: { + variant: 'primary', + fabSize: 'default', + items: createMenuItems(), // All 6 items (maximum as per spec) + children: ( + + + + ), + }, + parameters: { + docs: { + description: { + story: 'The FAB menu can have up to six items as per Material Design specifications.', + }, + }, + }, +} \ No newline at end of file diff --git a/src/components/fab/FabMenu.tsx b/src/components/fab/FabMenu.tsx new file mode 100644 index 0000000..50ec728 --- /dev/null +++ b/src/components/fab/FabMenu.tsx @@ -0,0 +1,291 @@ +import './fab-menu.scss' +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingList, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useListNavigation, + useRole, + useTransitionStyles, + useTypeahead, +} from '@floating-ui/react' +import { mdiClose } from '@mdi/js' +import Icon from '@mdi/react' +import clsx from 'clsx' +import { createContext, forwardRef, useContext, useRef, useState } from 'react' +import { Ripple } from '@/ripple/Ripple' +import { ExtendProps } from '@/utils/type' + +const FabMenuContext = createContext<{ + getItemProps: ( + userProps?: React.HTMLProps, + ) => Record + activeIndex: number | null + setActiveIndex: React.Dispatch> + isOpen: boolean + variant: 'primary' | 'secondary' | 'tertiary' +}>({ + getItemProps: () => ({}), + activeIndex: null, + setActiveIndex: () => {}, + isOpen: false, + variant: 'primary', +}) + +export interface FabMenuProps { + /** + * FAB trigger element + */ + children: React.ReactNode + /** + * Menu items + */ + items: React.ReactNode[] + /** + * @default "primary" + */ + variant?: 'primary' | 'secondary' | 'tertiary' + /** + * FAB size affects menu positioning + * @default "default" + */ + fabSize?: 'default' | 'small' | 'large' + /** + * Control open state + */ + open?: boolean + /** + * Handle open state change + */ + onOpenChange?: (open: boolean) => void + /** + * Default open state + */ + defaultOpen?: boolean +} + +/** + * FAB Menu - A floating action button menu that opens from a FAB to display multiple related actions + * + * @specs https://m3.material.io/components/floating-action-button/specs + */ +export const FabMenu = forwardRef< + HTMLElement, + ExtendProps +>(function FabMenu( + { + children, + items, + variant = 'primary', + fabSize = 'default', + open: controlledOpen, + onOpenChange, + defaultOpen = false, + className, + ...props + }, + _ref, +) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen) + const isOpen = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen + const setIsOpen = (open: boolean) => { + if (controlledOpen === undefined) { + setUncontrolledOpen(open) + } + onOpenChange?.(open) + } + + const [activeIndex, setActiveIndex] = useState(null) + const elementsRef = useRef>([]) + const labelsRef = useRef>([]) + + // Calculate offset based on FAB size + const getOffset = () => { + switch (fabSize) { + case 'small': + return { mainAxis: -48, alignmentAxis: -8 } // 40dp FAB -> align close button higher + case 'large': + return { mainAxis: -96, alignmentAxis: -20 } // 96dp FAB -> align close button much higher + default: + return { mainAxis: -64, alignmentAxis: -8 } // 56dp FAB -> align close button at same level + } + } + + const { floatingStyles, refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + placement: 'top-end', + middleware: [ + offset(getOffset()), + flip(), + shift({ padding: 16 }), + ], + whileElementsMounted: autoUpdate, + }) + + const click = useClick(context) + const dismiss = useDismiss(context, { bubbles: true }) + const role = useRole(context, { role: 'menu' }) + const listNavigation = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + }) + const typeahead = useTypeahead(context, { + listRef: labelsRef, + onMatch: setActiveIndex, + activeIndex, + }) + + const { styles, isMounted } = useTransitionStyles(context, { + duration: { + open: 300, + close: 200, + }, + initial: { + opacity: 0, + transform: 'scale(0.8) translateY(8px)', + }, + open: { + opacity: 1, + transform: 'scale(1) translateY(0px)', + }, + close: { + opacity: 0, + transform: 'scale(0.8) translateY(8px)', + }, + }) + + const { getReferenceProps, getFloatingProps, getItemProps } = + useInteractions([click, dismiss, role, listNavigation, typeahead]) + + return ( + +
+ {children} +
+ + + {isMounted && ( + + +
+ {/* Close button */} + setIsOpen(false)} + aria-label="Close menu" + > + + + + {/* Menu items */} +
+ {items} +
+
+
+
+ )} +
+
+ ) +}) + +export interface FabMenuItemProps { + /** + * Icon for the menu item + */ + icon: React.ReactNode + /** + * Label text for the menu item + */ + children: React.ReactNode + /** + * Disabled state + */ + disabled?: boolean + /** + * Click handler + */ + onClick?: (event: React.MouseEvent) => void +} + +/** + * Menu item for FAB Menu + */ +export const FabMenuItem = forwardRef< + HTMLElement, + ExtendProps +>(function FabMenuItem( + { + icon, + children, + disabled, + onClick, + className, + ...props + }, + forwardedRef, +) { + const menu = useContext(FabMenuContext) + + return ( + ) => { + if (!disabled) { + onClick?.(event) + } + }, + })} + > +
+ {icon} +
+
+ {children} +
+
+ ) +}) \ No newline at end of file diff --git a/src/components/fab/fab-menu.scss b/src/components/fab/fab-menu.scss new file mode 100644 index 0000000..62a2bd2 --- /dev/null +++ b/src/components/fab/fab-menu.scss @@ -0,0 +1,152 @@ +// https://m3.material.io/components/floating-action-button/specs + +@import '@/style/utils'; +@import '@/style/elevation'; + +.sd-fab-menu-trigger { + display: inline-block; + position: relative; +} + +.sd-fab-menu { + display: flex; + flex-direction: column; + gap: 8px; // FAB menu close button between space + min-width: max-content; + z-index: 1000; + + // Close button styles + .sd-fab-menu-close { + @include display-inline-flex-center; + width: 56px; // FAB menu close button container width + height: 56px; // FAB menu close button container height + border: none; + border-radius: var(--md-sys-shape-corner-full); // FAB menu close button container shape + cursor: pointer; + font-size: 0; // Hide any text content + overflow: hidden; + -webkit-tap-highlight-color: transparent; + transition: all 200ms; + @include elevation-level3; // FAB menu close button container elevation + + // Icon size is 20dp as specified + svg { + width: 20px; + height: 20px; + } + + @media (any-hover: hover) { + &:hover { + filter: brightness(96%); + } + } + &:active { + filter: brightness(92%); + } + &:focus-visible { + outline: none; + filter: brightness(88%); + } + } + + // Menu items container + .sd-fab-menu-items { + display: flex; + flex-direction: column; + gap: 4px; // FAB menu - menu item between space + } + + // Individual menu item styles + .sd-fab-menu-item { + @include display-inline-flex-center; + height: 56px; // FAB menu - menu item container height + border: none; + border-radius: var(--md-sys-shape-corner-full); // FAB menu - menu item container shape + padding: 0 24px 0 24px; // FAB menu - menu item leading space and trailing space + cursor: pointer; + font-size: inherit; + overflow: hidden; + -webkit-tap-highlight-color: transparent; + transition: all 200ms; + gap: 8px; // FAB menu - menu item icon label space + min-width: max-content; + // elevation-level0 means no elevation/shadow + + &-icon { + @include display-inline-flex-center; + width: 24px; // FAB menu - menu item icon size + height: 24px; + flex-shrink: 0; + color: currentColor; + + svg { + width: 24px; + height: 24px; + } + } + + &-label { + // FAB menu - menu item label text - using font from design tokens + font-size: 14px; + font-weight: 500; + line-height: 20px; + white-space: nowrap; + color: currentColor; + } + + @media (any-hover: hover) { + &:hover { + filter: brightness(96%); + } + } + &:active { + filter: brightness(92%); + } + &:focus-visible { + outline: none; + filter: brightness(88%); + } + + &[data-sd-disabled='true'] { + pointer-events: none; + filter: grayscale(98%) opacity(40%); + } + } + + // Color variants for close button and menu items + &--primary { + .sd-fab-menu-close { + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + } + + .sd-fab-menu-item[data-sd-variant='primary'] { + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + } + } + + &--secondary { + .sd-fab-menu-close { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + } + + .sd-fab-menu-item[data-sd-variant='secondary'] { + background: var(--md-sys-color-secondary); + color: var(--md-sys-color-on-secondary); + } + } + + &--tertiary { + .sd-fab-menu-close { + background: var(--md-sys-color-tertiary-container); + color: var(--md-sys-color-on-tertiary-container); + } + + .sd-fab-menu-item[data-sd-variant='tertiary'] { + background: var(--md-sys-color-tertiary); + color: var(--md-sys-color-on-tertiary); + } + } +} \ No newline at end of file diff --git a/src/components/fab/index.ts b/src/components/fab/index.ts index cf2ae55..fbd8c80 100644 --- a/src/components/fab/index.ts +++ b/src/components/fab/index.ts @@ -1 +1,2 @@ export * from './Fab' +export * from './FabMenu'