diff --git a/workspaces/global-floating-action-button/.changeset/unlucky-grapes-perform.md b/workspaces/global-floating-action-button/.changeset/unlucky-grapes-perform.md new file mode 100644 index 0000000000..e5eb9f2c9e --- /dev/null +++ b/workspaces/global-floating-action-button/.changeset/unlucky-grapes-perform.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-global-floating-action-button': patch +--- + +allow custom fab component diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/README.md b/workspaces/global-floating-action-button/plugins/global-floating-action-button/README.md index ed7252d8f6..c9602675a0 100644 --- a/workspaces/global-floating-action-button/plugins/global-floating-action-button/README.md +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/README.md @@ -247,22 +247,146 @@ The sections below are relevant for static plugins. If the plugin is expected to #### Floating Action Button Parameters -| Name | Type | Description | Notes | -| ------------------ | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| **slot** | `enum` | The position where the fab will be placed. Valid values: `PAGE_END`, `BOTTOM_LEFT`. | [optional] default to `PAGE_END`. | -| **label** | `String` | A name for your action button. | required | -| **labelKey** | `String` | Translation key for the label. If provided, will be used instead of label when translations are available. | optional | -| **icon** | `String`
`React.ReactElement`
`SVG image icon`
`HTML image icon` | An icon for your floating button. Recommended to use **filled** icons from the [Material Design library](https://fonts.google.com/icons) | optional | -| **showLabel** | `Boolean` | To display the label next to your icon. | optional | -| **size** | `'small'`
`'medium'`
`'large'` | A name for your action button. | [optional] default to `'medium'` | -| **color** | `'default'`
`'error'`
`'info'`
`'inherit'`
`'primary'`
`'secondary'`
`'success'`
`'warning'` | The color of the component. It supports both default and custom theme colors, which can be added as shown in the [palette customization guide](https://mui.com/material-ui/customization/palette/#custom-colors). | [optional] default to `'default'`. | -| **onClick** | `React.MouseEventHandler` | the action to be performed on `onClick`. | optional | -| **to** | `String` | Specify an href if the action button should open a internal/external link. | optional | -| **toolTip** | `String` | The text to appear on hover. | optional | -| **toolTipKey** | `String` | Translation key for the tooltip. If provided, will be used instead of toolTip when translations are available. | optional | -| **priority** | `number` | When multiple sub-menu actions are displayed, the button can be prioritized to position either at the top or the bottom. | optional | -| **visibleOnPaths** | `string[]` | The action button will appear only on the specified paths and will remain hidden on all other paths. | [optional] default to displaying on all paths. | -| **excludeOnPaths** | `string[]` | The action button will be hidden only on the specified paths and will appear on all other paths. | [optional] default to displaying on all paths. | +| Name | Type | Description | Notes | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| **slot** | `enum` | The position where the fab will be placed. Valid values: `PAGE_END`, `BOTTOM_LEFT`. | [optional] default to `PAGE_END`. | +| **label** | `String` | A name for your action button. | required | +| **labelKey** | `String` | Translation key for the label. If provided, will be used instead of label when translations are available. | optional | +| **icon** | `String`
`React.ReactElement`
`SVG image icon`
`HTML image icon` | An icon for your floating button. Recommended to use **filled** icons from the [Material Design library](https://fonts.google.com/icons) | optional | +| **showLabel** | `Boolean` | To display the label next to your icon. | optional | +| **size** | `'small'`
`'medium'`
`'large'` | A name for your action button. | [optional] default to `'medium'` | +| **color** | `'default'`
`'error'`
`'info'`
`'inherit'`
`'primary'`
`'secondary'`
`'success'`
`'warning'` | The color of the component. It supports both default and custom theme colors, which can be added as shown in the [palette customization guide](https://mui.com/material-ui/customization/palette/#custom-colors). | [optional] default to `'default'`. | +| **onClick** | `React.MouseEventHandler` | the action to be performed on `onClick`. | optional | +| **to** | `String` | Specify an href if the action button should open a internal/external link. | optional | +| **toolTip** | `String` | The text to appear on hover. | optional | +| **toolTipKey** | `String` | Translation key for the tooltip. If provided, will be used instead of toolTip when translations are available. | optional | +| **priority** | `number` | When multiple sub-menu actions are displayed, the button can be prioritized to position either at the top or the bottom. | optional | +| **visibleOnPaths** | `string[]` | The action button will appear only on the specified paths and will remain hidden on all other paths. | [optional] default to displaying on all paths. | +| **excludeOnPaths** | `string[]` | The action button will be hidden only on the specified paths and will appear on all other paths. | [optional] default to displaying on all paths. | +| **isDisabled** | `Boolean` | Whether the FAB is disabled. | optional | +| **disabledToolTip** | `String` | Tooltip to display when the FAB is disabled. | optional | +| **disabledToolTipKey** | `String` | Translation key for the disabled tooltip. | optional | +| **Component** | `ComponentType` | Custom component to render as the FAB item. When provided, this takes priority over icon, tooltip, and onClick. | optional | + +### Custom FAB Components + +You can provide a custom React component to render as a FAB item. This is useful when you need full control over the FAB's behavior, such as managing state through context or implementing complex interactions. + +#### Using Custom Components in Static Configuration + +```tsx title="packages/app/src/components/Root/Root.tsx" +import { useState, createContext, useContext, useCallback, PropsWithChildren } from 'react'; +import Fab from '@mui/material/Fab'; +import Tooltip from '@mui/material/Tooltip'; +import ChatIcon from '@mui/icons-material/Chat'; +import CloseIcon from '@mui/icons-material/Close'; +import { + GlobalFloatingActionButton, +} from '@red-hat-developer-hub/backstage-plugin-global-floating-action-button'; + +// Create a context for managing state +interface ChatPanelContextType { + isOpen: boolean; + togglePanel: () => void; +} + +const ChatPanelContext = createContext(undefined); + +const useChatPanel = () => { + const context = useContext(ChatPanelContext); + if (!context) { + throw new Error('useChatPanel must be used within ChatPanelProvider'); + } + return context; +}; + +// Provider component to wrap your app +const ChatPanelProvider = ({ children }: PropsWithChildren<{}>) => { + const [isOpen, setIsOpen] = useState(false); + const togglePanel = useCallback(() => setIsOpen(prev => !prev), []); + + return ( + + {children} + {isOpen && ( +
+ {/* Your panel content */} +
+ )} +
+ ); +}; + +// Custom FAB Component +const ChatFABComponent = () => { + const { isOpen, togglePanel } = useChatPanel(); + + return ( + + + {isOpen ? : } + + + ); +}; + +// Use the custom component in Root +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + {children} + + +); +``` + +**Key Points:** + +- The `Component` can have it's own `icon`, `toolTip`, and `onClick` action. The Custom components have full control over their rendering, state, and behavior +- Use a context provider pattern when the FAB needs to manage shared state (e.g., opening panels, modals) +- The custom component should render its own `Fab` element from MUI + +### Disabled FAB State + +You can disable a FAB and show a custom tooltip when it's disabled: + +```tsx title="packages/app/src/components/Root/Root.tsx" +, + label: 'Create', + toolTip: 'Create entity', + to: '/create', + isDisabled: true, + disabledToolTip: 'Creation is currently unavailable', + disabledToolTipKey: 'fab.create.disabled.tooltip', + }, + ]} +/> +``` + +When `isDisabled` is `true`: + +- The FAB will be visually disabled and non-interactive +- The `disabledToolTip` (or `disabledToolTipKey` translation) will be shown instead of the regular tooltip +- The `onClick` handler will not be triggered ### Translation Support diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/dev/ChatbotComponent.tsx b/workspaces/global-floating-action-button/plugins/global-floating-action-button/dev/ChatbotComponent.tsx new file mode 100644 index 0000000000..fcd6a4587a --- /dev/null +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/dev/ChatbotComponent.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useState, +} from 'react'; + +import ChatIcon from '@mui/icons-material/Chat'; +import CloseIcon from '@mui/icons-material/Close'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Fab from '@mui/material/Fab'; +import Tooltip from '@mui/material/Tooltip'; + +interface ChatPanelContextType { + isOpen: boolean; + togglePanel: () => void; +} + +const ChatPanelContext = createContext( + undefined, +); + +const useChatPanel = () => { + const context = useContext(ChatPanelContext); + if (!context) { + throw new Error('useChatPanel must be used within ChatPanelProvider'); + } + return context; +}; + +export const ChatPanelProvider = ({ children }: PropsWithChildren<{}>) => { + const [isOpen, setIsOpen] = useState(false); + + const togglePanel = useCallback(() => { + setIsOpen(prev => !prev); + }, []); + + return ( + + {children} + {isOpen && ( + + + Chat Panel + + + + This is a custom chat panel! + + It demonstrates how a custom FAB component can manage its own + state through context. + + + Have a nice day !! + + + + )} + + ); +}; + +// Custom FAB Component +export const ChatFABComponent = () => { + const { isOpen, togglePanel } = useChatPanel(); + + return ( + + + {isOpen ? : } + + + ); +}; diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/dev/index.tsx b/workspaces/global-floating-action-button/plugins/global-floating-action-button/dev/index.tsx index 47e964307e..75d82031eb 100644 --- a/workspaces/global-floating-action-button/plugins/global-floating-action-button/dev/index.tsx +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/dev/index.tsx @@ -31,6 +31,7 @@ import { globalFloatingActionButtonPlugin, globalFloatingActionButtonTranslations, } from '../src'; +import { ChatFABComponent, ChatPanelProvider } from './ChatbotComponent'; const mockFloatingButtons: FloatingActionButton[] = [ { @@ -91,6 +92,14 @@ const mockFloatingButtons: FloatingActionButton[] = [ }, ]; +const mockFloatingButtonsWithCustomComponent: FloatingActionButton[] = [ + { + label: 'Chat', + Component: ChatFABComponent, + priority: -1, + }, +]; + const createPage = ({ navTitle, path, @@ -200,4 +209,15 @@ createDevApp() component: , }), ) + .addPage({ + element: ( + + + + ), + title: 'Custom FAB Component', + path: '/test-custom-component-fab', + }) .render(); diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/report.api.md b/workspaces/global-floating-action-button/plugins/global-floating-action-button/report.api.md index 7b714f66f0..f11d8c1970 100644 --- a/workspaces/global-floating-action-button/plugins/global-floating-action-button/report.api.md +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/report.api.md @@ -6,6 +6,7 @@ /// import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { ComponentType } from 'react'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; import { TranslationResource } from '@backstage/core-plugin-api/alpha'; @@ -50,6 +51,10 @@ export type FloatingActionButton = { priority?: number; visibleOnPaths?: string[]; excludeOnPaths?: string[]; + isDisabled?: boolean; + disabledToolTip?: string; + disabledToolTipKey?: string; + Component?: ComponentType; }; // @public diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/CustomFab.tsx b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/CustomFab.tsx index 61b33986ca..48328714fc 100644 --- a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/CustomFab.tsx +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/CustomFab.tsx @@ -32,6 +32,20 @@ const useStyles = makeStyles(() => ({ openInNew: { paddingBottom: '5px', paddingTop: '3px' }, })); +const isExternalUri = (uri: string) => /^([a-z+.-]+):/.test(uri); + +const getIconOrder = (displayOnRight: boolean, isExternal: boolean) => + displayOnRight + ? { externalIcon: isExternal ? 1 : -1, icon: 3 } + : { externalIcon: isExternal ? 3 : -1, icon: 1 }; + +const getFabVariant = ( + showLabel?: boolean, + isExternal?: boolean, + icon?: string | ReactElement, +): 'extended' | 'circular' => + showLabel || isExternal || !icon ? 'extended' : 'circular'; + const FABLabel = ({ label, slot, @@ -47,6 +61,7 @@ const FABLabel = ({ }) => { const styles = useStyles(); const marginStyle = getSlotOptions(slot).margin; + return ( <> {showExternalIcon && ( @@ -88,11 +103,15 @@ export const CustomFab = ({ t: TranslationFunction; }) => { const navigate = useNavigate(); - const isExternalUri = (uri: string) => /^([a-z+.-]+):/.test(uri); - const isExternal = isExternalUri(actionButton.to!); - const newWindow = isExternal && !!/^https?:/.exec(actionButton.to!); - const navigateTo = () => - actionButton.to && !isExternal ? navigate(actionButton.to) : ''; + + const isExternal = actionButton.to ? isExternalUri(actionButton.to) : false; + const newWindow = isExternal && /^https?:/.test(actionButton.to || ''); + + const navigateTo = () => { + if (actionButton.to && !isExternal) { + navigate(actionButton.to); + } + }; const resolvedLabel = getTranslatedTextWithFallback( t, @@ -107,6 +126,18 @@ export const CustomFab = ({ ) : undefined; + const resolvedDisabledTooltip = actionButton.disabledToolTip + ? getTranslatedTextWithFallback( + t, + actionButton.disabledToolTipKey, + actionButton.disabledToolTip, + ) + : undefined; + + const currentTooltip = actionButton.isDisabled + ? resolvedDisabledTooltip + : resolvedTooltip; + if (!resolvedLabel) { // eslint-disable-next-line no-console console.warn( @@ -117,58 +148,54 @@ export const CustomFab = ({ } const labelText = - (resolvedLabel || '').length > 20 + resolvedLabel.length > 20 ? `${resolvedLabel.slice(0, resolvedLabel.length)}...` : resolvedLabel; - const getColor = () => { - if (actionButton.color) { - return actionButton.color; - } - return undefined; - }; - const displayOnRight = actionButton.slot === Slot.PAGE_END || !actionButton.slot; + const slot = actionButton.slot || Slot.PAGE_END; + const displayLabel = + actionButton.showLabel || !actionButton.icon ? labelText : ''; + + const fabElement = ( + + + + ); + return ( - - - + {fabElement} ); }; diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FABWithSubmenu.tsx b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FABWithSubmenu.tsx index 0911d5d71c..8101fbe5a3 100644 --- a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FABWithSubmenu.tsx +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FABWithSubmenu.tsx @@ -74,7 +74,7 @@ export const FABWithSubmenu = ({ }; }, [pathname]); - const handleClick = () => { + const handleToggle = () => { if (isMenuOpen) { setTimeout(() => { setIsMenuOpen(false); @@ -104,7 +104,7 @@ export const FABWithSubmenu = ({ {fabs?.map(fb => { + const FabComponent = fb.Component; + return ( - + {FabComponent ? ( + + ) : ( + + )} ); diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FloatingButton.tsx b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FloatingButton.tsx index 0a85787cd1..cce699d149 100644 --- a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FloatingButton.tsx +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FloatingButton.tsx @@ -89,6 +89,10 @@ export const FloatingButton = ({ ); } else { + const singleFab = fabs[0]; + const FabComponent = singleFab.Component; + + // If a custom FAB component is provided, render it instead of CustomFab fabDiv = (
- + {FabComponent ? ( + + ) : ( + + )}
); } diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/types.ts b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/types.ts index 00d342dd5e..6ce64154d6 100644 --- a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/types.ts +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { ComponentType } from 'react'; + /** * Slot * @@ -56,9 +58,26 @@ export type FloatingActionButton = { to?: string; toolTip?: string; toolTipKey?: string; + /** + * Priority for ordering buttons (lower number = higher priority) + * The FAB action with the lowest priority will be displayed on top of other FAB actions in the sub-menu + */ priority?: number; visibleOnPaths?: string[]; excludeOnPaths?: string[]; + /** + * Whether the FAB is disabled + */ + isDisabled?: boolean; + /** + * Tooltip to display when the FAB is disabled + */ + disabledToolTip?: string; + disabledToolTipKey?: string; + /** + * Custom FAB component + */ + Component?: ComponentType; }; /**