Skip to content

Commit 96ed69c

Browse files
committed
fix(fab): allow custom fab component
1 parent 48fb7f2 commit 96ed69c

File tree

9 files changed

+423
-76
lines changed

9 files changed

+423
-76
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-global-floating-action-button': patch
3+
---
4+
5+
allow custom fab component

workspaces/global-floating-action-button/plugins/global-floating-action-button/README.md

Lines changed: 140 additions & 16 deletions
Large diffs are not rendered by default.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
createContext,
19+
PropsWithChildren,
20+
useCallback,
21+
useContext,
22+
useState,
23+
} from 'react';
24+
25+
import ChatIcon from '@mui/icons-material/Chat';
26+
import CloseIcon from '@mui/icons-material/Close';
27+
import Typography from '@mui/material/Typography';
28+
import Button from '@mui/material/Button';
29+
import Fab from '@mui/material/Fab';
30+
import Tooltip from '@mui/material/Tooltip';
31+
32+
interface ChatPanelContextType {
33+
isOpen: boolean;
34+
togglePanel: () => void;
35+
}
36+
37+
const ChatPanelContext = createContext<ChatPanelContextType | undefined>(
38+
undefined,
39+
);
40+
41+
const useChatPanel = () => {
42+
const context = useContext(ChatPanelContext);
43+
if (!context) {
44+
throw new Error('useChatPanel must be used within ChatPanelProvider');
45+
}
46+
return context;
47+
};
48+
49+
export const ChatPanelProvider = ({ children }: PropsWithChildren<{}>) => {
50+
const [isOpen, setIsOpen] = useState(false);
51+
52+
const togglePanel = useCallback(() => {
53+
setIsOpen(prev => !prev);
54+
}, []);
55+
56+
return (
57+
<ChatPanelContext.Provider value={{ isOpen, togglePanel }}>
58+
{children}
59+
{isOpen && (
60+
<Typography
61+
component="div"
62+
style={{
63+
position: 'fixed',
64+
bottom: '80px',
65+
right: '24px',
66+
width: '350px',
67+
height: '400px',
68+
backgroundColor: 'white',
69+
borderRadius: '8px',
70+
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
71+
zIndex: 1000,
72+
display: 'flex',
73+
flexDirection: 'column',
74+
}}
75+
>
76+
<Typography
77+
component="div"
78+
style={{
79+
padding: '16px',
80+
borderBottom: '1px solid #eee',
81+
fontWeight: 'bold',
82+
display: 'flex',
83+
justifyContent: 'space-between',
84+
alignItems: 'center',
85+
}}
86+
>
87+
<Typography component="span">Chat Panel</Typography>
88+
<Button size="small" onClick={togglePanel}>
89+
Close
90+
</Button>
91+
</Typography>
92+
<Typography
93+
component="div"
94+
style={{ flex: 1, padding: '16px', overflowY: 'auto' }}
95+
>
96+
<Typography component="p">This is a custom chat panel!</Typography>
97+
<Typography component="p">
98+
It demonstrates how a custom FAB component can manage its own
99+
state through context.
100+
</Typography>
101+
<Typography
102+
component="p"
103+
style={{ color: '#666', fontSize: '14px' }}
104+
>
105+
Have a nice day !!
106+
</Typography>
107+
</Typography>
108+
</Typography>
109+
)}
110+
</ChatPanelContext.Provider>
111+
);
112+
};
113+
114+
// Custom FAB Component
115+
export const ChatFABComponent = () => {
116+
const { isOpen, togglePanel } = useChatPanel();
117+
118+
return (
119+
<Tooltip title={isOpen ? 'Close Chat' : 'Open Chat'} placement="left">
120+
<Fab
121+
size="small"
122+
color={isOpen ? 'default' : 'primary'}
123+
onClick={togglePanel}
124+
aria-label={isOpen ? 'Close chat' : 'Open chat'}
125+
sx={{
126+
transition: 'all 0.3s ease',
127+
}}
128+
>
129+
{isOpen ? <CloseIcon /> : <ChatIcon />}
130+
</Fab>
131+
</Tooltip>
132+
);
133+
};

workspaces/global-floating-action-button/plugins/global-floating-action-button/dev/index.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
globalFloatingActionButtonPlugin,
3232
globalFloatingActionButtonTranslations,
3333
} from '../src';
34+
import { ChatFABComponent, ChatPanelProvider } from './ChatbotComponent';
3435

3536
const mockFloatingButtons: FloatingActionButton[] = [
3637
{
@@ -91,6 +92,14 @@ const mockFloatingButtons: FloatingActionButton[] = [
9192
},
9293
];
9394

95+
const mockFloatingButtonsWithCustomComponent: FloatingActionButton[] = [
96+
{
97+
label: 'Chat',
98+
Component: ChatFABComponent,
99+
priority: -1,
100+
},
101+
];
102+
94103
const createPage = ({
95104
navTitle,
96105
path,
@@ -200,4 +209,15 @@ createDevApp()
200209
component: <ExampleComponent />,
201210
}),
202211
)
212+
.addPage({
213+
element: (
214+
<ChatPanelProvider>
215+
<ExampleComponent
216+
floatingButtons={mockFloatingButtonsWithCustomComponent}
217+
/>
218+
</ChatPanelProvider>
219+
),
220+
title: 'Custom FAB Component',
221+
path: '/test-custom-component-fab',
222+
})
203223
.render();

workspaces/global-floating-action-button/plugins/global-floating-action-button/report.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
/// <reference types="react" />
77

88
import { BackstagePlugin } from '@backstage/core-plugin-api';
9+
import { ComponentType } from 'react';
910
import { JSX as JSX_2 } from 'react/jsx-runtime';
1011
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
1112
import { TranslationResource } from '@backstage/core-plugin-api/alpha';
@@ -50,6 +51,10 @@ export type FloatingActionButton = {
5051
priority?: number;
5152
visibleOnPaths?: string[];
5253
excludeOnPaths?: string[];
54+
isDisabled?: boolean;
55+
disabledToolTip?: string;
56+
disabledToolTipKey?: string;
57+
Component?: ComponentType<any>;
5358
};
5459

5560
// @public

workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/CustomFab.tsx

Lines changed: 74 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ const useStyles = makeStyles(() => ({
3232
openInNew: { paddingBottom: '5px', paddingTop: '3px' },
3333
}));
3434

35+
const isExternalUri = (uri: string) => /^([a-z+.-]+):/.test(uri);
36+
37+
const getIconOrder = (displayOnRight: boolean, isExternal: boolean) =>
38+
displayOnRight
39+
? { externalIcon: isExternal ? 1 : -1, icon: 3 }
40+
: { externalIcon: isExternal ? 3 : -1, icon: 1 };
41+
42+
const getFabVariant = (
43+
showLabel?: boolean,
44+
isExternal?: boolean,
45+
icon?: string | ReactElement,
46+
): 'extended' | 'circular' =>
47+
showLabel || isExternal || !icon ? 'extended' : 'circular';
48+
3549
const FABLabel = ({
3650
label,
3751
slot,
@@ -47,6 +61,7 @@ const FABLabel = ({
4761
}) => {
4862
const styles = useStyles();
4963
const marginStyle = getSlotOptions(slot).margin;
64+
5065
return (
5166
<>
5267
{showExternalIcon && (
@@ -88,11 +103,15 @@ export const CustomFab = ({
88103
t: TranslationFunction<typeof globalFloatingActionButtonTranslationRef.T>;
89104
}) => {
90105
const navigate = useNavigate();
91-
const isExternalUri = (uri: string) => /^([a-z+.-]+):/.test(uri);
92-
const isExternal = isExternalUri(actionButton.to!);
93-
const newWindow = isExternal && !!/^https?:/.exec(actionButton.to!);
94-
const navigateTo = () =>
95-
actionButton.to && !isExternal ? navigate(actionButton.to) : '';
106+
107+
const isExternal = actionButton.to ? isExternalUri(actionButton.to) : false;
108+
const newWindow = isExternal && /^https?:/.test(actionButton.to || '');
109+
110+
const navigateTo = () => {
111+
if (actionButton.to && !isExternal) {
112+
navigate(actionButton.to);
113+
}
114+
};
96115

97116
const resolvedLabel = getTranslatedTextWithFallback(
98117
t,
@@ -107,6 +126,18 @@ export const CustomFab = ({
107126
)
108127
: undefined;
109128

129+
const resolvedDisabledTooltip = actionButton.disabledToolTip
130+
? getTranslatedTextWithFallback(
131+
t,
132+
actionButton.disabledToolTipKey,
133+
actionButton.disabledToolTip,
134+
)
135+
: undefined;
136+
137+
const currentTooltip = actionButton.isDisabled
138+
? resolvedDisabledTooltip
139+
: resolvedTooltip;
140+
110141
if (!resolvedLabel) {
111142
// eslint-disable-next-line no-console
112143
console.warn(
@@ -117,58 +148,54 @@ export const CustomFab = ({
117148
}
118149

119150
const labelText =
120-
(resolvedLabel || '').length > 20
151+
resolvedLabel.length > 20
121152
? `${resolvedLabel.slice(0, resolvedLabel.length)}...`
122153
: resolvedLabel;
123154

124-
const getColor = () => {
125-
if (actionButton.color) {
126-
return actionButton.color;
127-
}
128-
return undefined;
129-
};
130-
131155
const displayOnRight =
132156
actionButton.slot === Slot.PAGE_END || !actionButton.slot;
133157

158+
const slot = actionButton.slot || Slot.PAGE_END;
159+
const displayLabel =
160+
actionButton.showLabel || !actionButton.icon ? labelText : '';
161+
162+
const fabElement = (
163+
<Fab
164+
{...(newWindow ? { target: '_blank', rel: 'noopener' } : {})}
165+
className={className}
166+
style={{
167+
color: actionButton.iconColor || '#1f1f1f',
168+
backgroundColor: actionButton.color ? '' : 'white',
169+
}}
170+
variant={getFabVariant(
171+
actionButton.showLabel,
172+
isExternal,
173+
actionButton.icon,
174+
)}
175+
size={size || actionButton.size || 'medium'}
176+
color={actionButton.color}
177+
aria-label={resolvedLabel}
178+
data-testid={resolvedLabel.replace(' ', '-').toLocaleLowerCase('en-US')}
179+
onClick={actionButton.onClick || navigateTo}
180+
disabled={actionButton.isDisabled}
181+
{...(isExternal ? { href: actionButton.to } : {})}
182+
>
183+
<FABLabel
184+
showExternalIcon={isExternal}
185+
icon={actionButton.icon}
186+
label={displayLabel}
187+
order={getIconOrder(displayOnRight, isExternal)}
188+
slot={slot}
189+
/>
190+
</Fab>
191+
);
192+
134193
return (
135194
<Tooltip
136-
title={resolvedTooltip}
195+
title={currentTooltip}
137196
placement={getSlotOptions(actionButton.slot).tooltipDirection}
138197
>
139-
<Fab
140-
{...(newWindow ? { target: '_blank', rel: 'noopener' } : {})}
141-
className={className}
142-
style={{
143-
color: actionButton?.iconColor || '#1f1f1f',
144-
backgroundColor: actionButton.color ? '' : 'white',
145-
}}
146-
variant={
147-
actionButton.showLabel || isExternal || !actionButton.icon
148-
? 'extended'
149-
: 'circular'
150-
}
151-
size={size || actionButton.size || 'medium'}
152-
color={getColor()}
153-
aria-label={resolvedLabel}
154-
data-testid={(resolvedLabel || '')
155-
.replace(' ', '-')
156-
.toLocaleLowerCase('en-US')}
157-
onClick={actionButton.onClick || navigateTo}
158-
{...(isExternal ? { href: actionButton.to } : {})}
159-
>
160-
<FABLabel
161-
showExternalIcon={isExternal}
162-
icon={actionButton.icon}
163-
label={actionButton.showLabel || !actionButton.icon ? labelText : ''}
164-
order={
165-
displayOnRight
166-
? { externalIcon: isExternal ? 1 : -1, icon: 3 }
167-
: { externalIcon: isExternal ? 3 : -1, icon: 1 }
168-
}
169-
slot={actionButton.slot || Slot.PAGE_END}
170-
/>
171-
</Fab>
198+
{fabElement}
172199
</Tooltip>
173200
);
174201
};

0 commit comments

Comments
 (0)