@spteck/fluentui-react-charts 0.1.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/components/RenderLabel/RenderLabel.d.ts +8 -0
  2. package/dist/components/RenderLabel/index.d.ts +2 -0
  3. package/dist/components/RenderLabel/useRenderLabelStylesStyles.d.ts +1 -0
  4. package/dist/components/dashboard/DashBoard.d.ts +4 -0
  5. package/dist/components/dashboard/ExampleDashboardUsage.d.ts +6 -0
  6. package/dist/components/dashboard/ICardChartContainer.d.ts +16 -0
  7. package/dist/components/dashboard/IDashboardProps.d.ts +9 -0
  8. package/dist/components/dashboard/NoDashboards.d.ts +5 -0
  9. package/dist/components/dashboard/index.d.ts +3 -0
  10. package/dist/components/dashboard/selectZoom/SelectZoom.d.ts +15 -0
  11. package/dist/components/dashboard/useDashboardStyles.d.ts +7 -0
  12. package/dist/components/index.d.ts +1 -1
  13. package/dist/components/svgImages/BusinessReportIcon.d.ts +9 -0
  14. package/dist/fluentui-react-charts.cjs.development.js +2 -0
  15. package/dist/fluentui-react-charts.cjs.development.js.map +1 -1
  16. package/dist/fluentui-react-charts.cjs.production.min.js +1 -1
  17. package/dist/fluentui-react-charts.cjs.production.min.js.map +1 -1
  18. package/dist/fluentui-react-charts.esm.js +2 -0
  19. package/dist/fluentui-react-charts.esm.js.map +1 -1
  20. package/dist/models/ChartDatum.d.ts +4 -0
  21. package/dist/models/index.d.ts +1 -0
  22. package/package.json +2 -3
  23. package/src/components/RenderLabel/RenderLabel.tsx +39 -0
  24. package/src/components/RenderLabel/index.ts +2 -0
  25. package/src/components/RenderLabel/useRenderLabelStylesStyles.ts +25 -0
  26. package/src/components/dashboard/DashBoard.tsx +220 -0
  27. package/src/components/dashboard/ExampleDashboardUsage.tsx +114 -0
  28. package/src/components/dashboard/ICardChartContainer.tsx +13 -0
  29. package/src/components/dashboard/IDashboardProps.tsx +13 -0
  30. package/src/components/dashboard/NoDashboards.tsx +26 -0
  31. package/src/components/dashboard/index.ts +4 -0
  32. package/src/components/dashboard/selectZoom/SelectZoom.tsx +189 -0
  33. package/src/components/dashboard/useDashboardStyles.ts +76 -0
  34. package/src/components/index.ts +3 -1
  35. package/src/components/svgImages/BusinessReportIcon.tsx +218 -0
  36. package/src/models/ChartDatum.ts +4 -0
  37. package/src/models/index.ts +1 -0
  38. package/dist/components/DashBoard.d.ts +0 -3
  39. package/src/components/DashBoard.tsx +0 -409
@@ -0,0 +1,4 @@
1
+ export interface ChartDatum {
2
+ name: string;
3
+ value: number;
4
+ }
@@ -1 +1,2 @@
1
1
  export * from './IChart';
2
+ export * from './ChartDatum';
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.8",
2
+ "version": "1.0.0",
3
3
  "license": "MIT",
4
4
  "main": "dist/index.js",
5
5
  "typings": "dist/index.d.ts",
@@ -38,11 +38,9 @@
38
38
  },
39
39
  "name": "@spteck/fluentui-react-charts",
40
40
  "author": "João Mendes",
41
-
42
41
  "module": "dist/fluentui-react-charts.esm.js",
43
42
  "devDependencies": {
44
43
  "@size-limit/preset-small-lib": "^11.2.0",
45
-
46
44
  "@types/react": "^18.3.23",
47
45
  "@types/react-dom": "^18.3.7",
48
46
  "husky": "^9.1.7",
@@ -54,6 +52,7 @@
54
52
  "dependencies": {
55
53
  "@emotion/css": "^11.13.5",
56
54
  "@fluentui/react-components": "^9.66.2",
55
+ "@iconify/react": "^6.0.0",
57
56
  "@juggle/resize-observer": "^3.4.0",
58
57
  "chart.js": "^4.5.0",
59
58
  "chartjs-plugin-datalabels": "^2.2.0",
@@ -0,0 +1,39 @@
1
+ import * as React from 'react';
2
+
3
+ import {
4
+ Caption1,
5
+ tokens,
6
+ } from '@fluentui/react-components';
7
+ import { Icon } from '@iconify/react';
8
+
9
+ import { useRenderLabelStyles } from './useRenderLabelStylesStyles';
10
+
11
+ export interface IRenderLabelProps {
12
+ label: string; icon?: string | JSX.Element; isRequired?: boolean
13
+ }
14
+
15
+ export const RenderLabel: React.FunctionComponent<IRenderLabelProps> = (props: React.PropsWithChildren<IRenderLabelProps>) => {
16
+ const { label, icon, isRequired } = props;
17
+ const styles = useRenderLabelStyles();
18
+ return (
19
+ <>
20
+ <div className={styles.labelContainer}>
21
+ {icon && React.isValidElement(icon) ? (
22
+ icon
23
+ ) : (
24
+ <Icon
25
+ icon={icon as string}
26
+ className={styles.iconStyles}
27
+ width={"20px"}
28
+ height={"20px"}
29
+ color={tokens.colorBrandForeground1}
30
+ />
31
+ )}
32
+ <Caption1 style={{ color: tokens.colorBrandForeground1 }}>{label}</Caption1>
33
+ <Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>{isRequired ? " *" : ""}</Caption1>
34
+ </div>
35
+ </>
36
+ );
37
+ };
38
+
39
+ export default RenderLabel;
@@ -0,0 +1,2 @@
1
+ export * from './RenderLabel';
2
+ export { default as RenderLabel } from './RenderLabel';
@@ -0,0 +1,25 @@
1
+ /* eslint-disable @typescript-eslint/explicit-function-return-type */
2
+ /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ import {
4
+ makeStyles,
5
+ shorthands,
6
+ } from '@fluentui/react-components';
7
+
8
+ export const useRenderLabelStyles = makeStyles({
9
+
10
+ labelContainer: {
11
+ display: "flex",
12
+ flexDirection: "row",
13
+ justifyContent: "flex-start",
14
+ alignItems: "center",
15
+ ...shorthands.gap("6px"),
16
+ },
17
+ iconStyles: {
18
+ width: "26px",
19
+ },
20
+ item: {
21
+ paddingLeft: "15px",
22
+ },
23
+
24
+
25
+ });
@@ -0,0 +1,220 @@
1
+ 'use client';
2
+
3
+ import { Card, CardHeader, Text, Theme } from '@fluentui/react-components';
4
+ import React, {
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+
12
+ import { ICardChartContainer } from './ICardChartContainer';
13
+ import { IDashboardProps } from './IDashboardProps';
14
+ import { NoDashboards } from './NoDashboards';
15
+ import { SelectZoom } from './selectZoom/SelectZoom';
16
+ import { useDashboardStyles } from './useDashboardStyles';
17
+ import { useGraphUtils } from '../../hooks';
18
+
19
+ const MINIMUM_DASHBOARD_WIDTH = 600;
20
+ const MAX_ROWS = 4;
21
+
22
+ const Dashboard: React.FC<IDashboardProps> = ({
23
+ cardCharts,
24
+ theme,
25
+ containerWidth ,
26
+ containerHeight = '100%',
27
+ maxSpanRows = MAX_ROWS,
28
+ }) => {
29
+ const styles = useDashboardStyles();
30
+
31
+ const { getChartComponent } = useGraphUtils(theme as Theme);
32
+
33
+ const [CardChartContainer, setCardChartContainer] = useState<
34
+ ICardChartContainer[]
35
+ >([]);
36
+
37
+ const [sizes, setSizes] = useState<
38
+ Record<string, { spanCols: number; spanRows: number }>
39
+ >({});
40
+ const dragItem = useRef<number | null>(null);
41
+ const dragOverItem = useRef<number | null>(null);
42
+ const containerRef = useRef<HTMLDivElement>(null);
43
+
44
+ React.useEffect(() => {
45
+ setCardChartContainer(cardCharts);
46
+ setSizes(() => {
47
+ const initialSizes: Record<
48
+ string,
49
+ { spanCols: number; spanRows: number }
50
+ > = {};
51
+ cardCharts.forEach(c => {
52
+ initialSizes[c.id] = {
53
+ spanCols: c.defaultSpan?.spanCols ?? 1,
54
+ spanRows: c.defaultSpan?.spanRows ?? 1,
55
+ };
56
+ });
57
+ return initialSizes;
58
+ });
59
+ }, [cardCharts]);
60
+
61
+ useEffect(() => {
62
+ if (containerWidth <= MINIMUM_DASHBOARD_WIDTH) {
63
+ setSizes(() => {
64
+ const reset: Record<
65
+ string,
66
+ { spanCols: number; spanRows: number }
67
+ > = {};
68
+ CardChartContainer.forEach(c => {
69
+ reset[c.id] = { spanCols: 1, spanRows: 1 };
70
+ });
71
+ return reset;
72
+ });
73
+ }
74
+ }, [containerWidth, CardChartContainer]);
75
+
76
+ const showZoom = useMemo(() => containerWidth > MINIMUM_DASHBOARD_WIDTH, [
77
+ containerWidth,
78
+ ]);
79
+
80
+ const { minCardWidth, gridGap, containerPadding } = useMemo(
81
+ () => ({
82
+ minCardWidth: 350,
83
+ gridGap: 16,
84
+ containerPadding: 20,
85
+ }),
86
+ []
87
+ );
88
+
89
+ const innerWidth = containerWidth - containerPadding * 2;
90
+ const columns = useMemo(
91
+ () => Math.floor((innerWidth + gridGap) / (minCardWidth + gridGap)),
92
+ [innerWidth, gridGap, minCardWidth]
93
+ );
94
+ const maxZoom = Math.max(columns, 1);
95
+
96
+ const handleSort = useCallback(() => {
97
+ if (
98
+ dragItem.current !== null &&
99
+ dragOverItem.current !== null &&
100
+ dragItem.current !== dragOverItem.current
101
+ ) {
102
+ const copy = [...CardChartContainer];
103
+ const [moved] = copy.splice(dragItem.current, 1);
104
+ copy.splice(dragOverItem.current, 0, moved);
105
+ setCardChartContainer(copy);
106
+ }
107
+ dragItem.current = null;
108
+ dragOverItem.current = null;
109
+ }, [CardChartContainer]);
110
+
111
+ const handleZoomSelect = useCallback(
112
+ (id: string, span: { spanCols: number; spanRows: number }) => {
113
+ setSizes(prev => ({
114
+ ...prev,
115
+ [id]: {
116
+ spanCols: Math.min(Math.max(span.spanCols, 1), maxZoom),
117
+ spanRows: Math.min(Math.max(span.spanRows, 1), maxSpanRows),
118
+ },
119
+ }));
120
+ },
121
+ [maxZoom, maxSpanRows]
122
+ );
123
+
124
+ const defaultColsAndRowSpanBasedOnNumberColumns = useMemo(() => {
125
+ return CardChartContainer.reduce((acc, card) => {
126
+ acc[card.id] = {
127
+ spanCols: Math.min(card.defaultSpan?.spanCols ?? 1, columns),
128
+ spanRows: card.defaultSpan?.spanRows ?? 1,
129
+ };
130
+ return acc;
131
+ }, {} as Record<string, { spanCols: number; spanRows: number }>);
132
+ }, [CardChartContainer, columns]);
133
+
134
+ const renderCards = useMemo(() => {
135
+ return CardChartContainer.map((cardContainer, idx) => (
136
+ <div
137
+ className={styles.cardWrapper}
138
+ key={cardContainer.id}
139
+ draggable
140
+ onDragStart={() => (dragItem.current = idx)}
141
+ onDragEnter={() => (dragOverItem.current = idx)}
142
+ onDragOver={e => e.preventDefault()}
143
+ onDragEnd={handleSort}
144
+ style={{
145
+ gridColumnEnd: sizes[cardContainer.id]?.spanCols
146
+ ? `span ${sizes[cardContainer.id].spanCols}`
147
+ : `span ${defaultColsAndRowSpanBasedOnNumberColumns[
148
+ cardContainer.id
149
+ ]?.spanCols || 1}`,
150
+ gridRowEnd: sizes[cardContainer.id]?.spanRows
151
+ ? `span ${sizes[cardContainer.id].spanRows}`
152
+ : `span ${defaultColsAndRowSpanBasedOnNumberColumns[
153
+ cardContainer.id
154
+ ]?.spanRows || 1}`,
155
+ }}
156
+ >
157
+ <Card className={styles.cardBody}>
158
+ <CardHeader
159
+ header={
160
+ <Text weight="semibold" size={400}>
161
+ {cardContainer.cardTitle}
162
+ </Text>
163
+ }
164
+ action={
165
+ showZoom ? (
166
+ <SelectZoom
167
+ values={
168
+ sizes[cardContainer.id] ||
169
+ defaultColsAndRowSpanBasedOnNumberColumns[
170
+ cardContainer.id
171
+ ] || { spanCols: 1, spanRows: 1 }
172
+ }
173
+ maxCols={maxZoom}
174
+ maxRows={maxSpanRows}
175
+ IsOpen={false}
176
+ onChange={v => handleZoomSelect(cardContainer.id, v)}
177
+ />
178
+ ) : (
179
+ undefined
180
+ )
181
+ }
182
+ />
183
+ <div
184
+ className={styles.chartContainer}
185
+ style={{ height: containerHeight }}
186
+ >
187
+ {theme &&
188
+ theme.fontSizeBase100 &&
189
+ getChartComponent(cardContainer.chart, theme as Theme)}
190
+ </div>
191
+ </Card>
192
+ </div>
193
+ ));
194
+ }, [
195
+ CardChartContainer,
196
+ handleSort,
197
+ sizes,
198
+ styles.cardBody,
199
+ styles.chartContainer,
200
+ showZoom,
201
+ maxZoom,
202
+ getChartComponent,
203
+ handleZoomSelect,
204
+ theme,
205
+ ]);
206
+
207
+ if (CardChartContainer.length === 0) {
208
+ return <NoDashboards />;
209
+ }
210
+
211
+ return (
212
+ <>
213
+ <div ref={containerRef} className={styles.dashboardContainer}>
214
+ {renderCards}
215
+ </div>
216
+ </>
217
+ );
218
+ };
219
+
220
+ export default Dashboard;
@@ -0,0 +1,114 @@
1
+ import { Text, Theme, webLightTheme } from '@fluentui/react-components';
2
+
3
+ import DashBoard from './DashBoard';
4
+ import { ICardChartContainer } from './ICardChartContainer';
5
+ import React from 'react';
6
+ import { Stack } from '../stack/Stack';
7
+
8
+ // Example usage of the reusable DashBoard component
9
+ const ExampleDashboardUsage: React.FC<{ theme: Theme }> = ({ theme }) => {
10
+ // Sample data for the charts
11
+ const sampleData = [
12
+ { month: 'Jan', sales: 10000, expenses: 8000 },
13
+ { month: 'Feb', sales: 15000, expenses: 9000 },
14
+ { month: 'Mar', sales: 12000, expenses: 7500 },
15
+ { month: 'Apr', sales: 18000, expenses: 10000 },
16
+ { month: 'May', sales: 16000, expenses: 8500 },
17
+ { month: 'Jun', sales: 22000, expenses: 11000 },
18
+ ];
19
+
20
+ // Define the chart containers with different chart types
21
+ const cardChartContainers: ICardChartContainer[] = [
22
+ {
23
+ id: '1',
24
+ cardTitle: 'Monthly Sales',
25
+ chart: {
26
+ id: '1',
27
+ title: 'Sales Performance',
28
+ type: 'line',
29
+ data: [{
30
+ label: 'Sales',
31
+ data: sampleData.map(d => ({ name: d.month, value: d.sales, x: d.month, y: d.sales }))
32
+ }],
33
+ },
34
+ defaultSpan: { spanCols: 2, spanRows: 1 },
35
+ },
36
+ {
37
+ id: '2',
38
+ cardTitle: 'Sales vs Expenses',
39
+ chart: {
40
+ id: '2',
41
+ title: 'Financial Overview',
42
+ type: 'bar',
43
+ data: [
44
+ {
45
+ label: 'Sales',
46
+ data: sampleData.map(d => ({ name: d.month, value: d.sales, x: d.month, y: d.sales }))
47
+ },
48
+ {
49
+ label: 'Expenses',
50
+ data: sampleData.map(d => ({ name: d.month, value: d.expenses, x: d.month, y: d.expenses }))
51
+ }
52
+ ],
53
+ },
54
+ defaultSpan: { spanCols: 2, spanRows: 2 },
55
+ },
56
+ {
57
+ id: '3',
58
+ cardTitle: 'Revenue Distribution',
59
+ chart: {
60
+ id: '3',
61
+ title: 'Q2 Revenue',
62
+ type: 'pie',
63
+ data: [{
64
+ label: 'Revenue',
65
+ data: [
66
+ { name: 'Product A', value: 35000 },
67
+ { name: 'Product B', value: 25000 },
68
+ { name: 'Product C', value: 18000 },
69
+ { name: 'Services', value: 15000 },
70
+ ]
71
+ }],
72
+ },
73
+ defaultSpan: { spanCols: 1, spanRows: 1 },
74
+ },
75
+ {
76
+ id: '4',
77
+ cardTitle: 'Growth Trend',
78
+ chart: {
79
+ id: '4',
80
+ title: 'Monthly Growth',
81
+ type: 'area',
82
+ data: [{
83
+ label: 'Growth',
84
+ data: sampleData.map(d => {
85
+ const growthPercentage = ((d.sales - d.expenses) / d.expenses * 100);
86
+ return {
87
+ name: d.month,
88
+ value: Number(growthPercentage.toFixed(1)),
89
+ x: d.month,
90
+ y: Number(growthPercentage.toFixed(1))
91
+ };
92
+ })
93
+ }],
94
+ },
95
+ defaultSpan: { spanCols: 2, spanRows: 1 },
96
+ },
97
+ ];
98
+
99
+ return (
100
+ <Stack direction="vertical" style={{ width: '100%', height: '100vh' }}>
101
+ <Stack padding="l">
102
+ <Text as="h1" size={900} weight="bold">Sample Dashboard</Text>
103
+ </Stack>
104
+ <DashBoard
105
+ cardCharts={cardChartContainers}
106
+ theme={theme ?? webLightTheme}
107
+ containerWidth={window.innerWidth}
108
+ containerHeight={window.innerHeight - 100} // Adjust height for header
109
+ />
110
+ </Stack>
111
+ );
112
+ };
113
+
114
+ export default ExampleDashboardUsage;
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+
3
+ import { ChartDatum } from '../../models/ChartDatum';
4
+ import { IChart } from '../../models';
5
+
6
+ export interface ICardChartContainer {
7
+ chart: IChart;
8
+ cardTitle: string;
9
+ showZoom?: boolean;
10
+ id: string;
11
+ data?: { label: string; data: ChartDatum[]; }[];
12
+ defaultSpan?: { spanCols: number; spanRows: number; };
13
+ }
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+
3
+ import { ICardChartContainer } from './ICardChartContainer';
4
+ import { Theme } from '@fluentui/react-components';
5
+
6
+ export interface IDashboardProps {
7
+ cardCharts: ICardChartContainer[];
8
+ theme: Theme;
9
+ containerWidth: number;
10
+ containerHeight?: number;
11
+
12
+ maxSpanRows?: number;
13
+ }
@@ -0,0 +1,26 @@
1
+ import * as React from 'react';
2
+
3
+ import BusinessReportIcon from '../svgImages/BusinessReportIcon';
4
+ import Stack from '../stack/Stack';
5
+ import { Text } from '@fluentui/react-components';
6
+
7
+ export interface INoDashboardsProps {
8
+ height?: string;
9
+ }
10
+
11
+ export const NoDashboards: React.FunctionComponent<INoDashboardsProps> = (props: React.PropsWithChildren<INoDashboardsProps>) => {
12
+ const { height } = props;
13
+
14
+ return (
15
+ <>
16
+ <Stack
17
+ style={{ height: height || "100%" }}
18
+ justifyContent="Center"
19
+ alignItems="Center"
20
+ >
21
+ <BusinessReportIcon width={200} height={200} />
22
+ <Text size={500} weight='semibold'>No Dashboards Available</Text>
23
+ </Stack>
24
+ </>
25
+ );
26
+ };
@@ -0,0 +1,4 @@
1
+ export * from './DashBoard';
2
+ export * from './ICardChartContainer';
3
+ export * from './IDashboardProps';
4
+
@@ -0,0 +1,189 @@
1
+ import * as React from "react";
2
+
3
+ import {
4
+ Caption1,
5
+ Menu,
6
+ MenuButton,
7
+ MenuPopover,
8
+ MenuProps,
9
+ MenuTrigger,
10
+ Tooltip,
11
+ tokens,
12
+ } from "@fluentui/react-components";
13
+ import {
14
+ Settings20Filled,
15
+ Settings20Regular,
16
+ bundleIcon,
17
+ } from "@fluentui/react-icons";
18
+
19
+ import { Icon } from "@iconify/react";
20
+ import { RenderLabel } from "../../RenderLabel";
21
+ import { css } from "@emotion/css";
22
+
23
+ export interface ISelectZoomProps {
24
+ IsOpen: boolean;
25
+ onChange?: (value: { spanCols: number; spanRows: number }) => void;
26
+ values: { spanCols: number; spanRows: number };
27
+ maxCols: number;
28
+ maxRows: number;
29
+ }
30
+
31
+ const useStyles = (): {
32
+ gridContainer: string;
33
+ cell: string;
34
+ hoveredCell: string;
35
+ selectedCell: string;
36
+ menuPopover: string;
37
+ bottomText: string;
38
+ } => ({
39
+ gridContainer: css`
40
+ display: grid;
41
+ gap: 4px;
42
+ padding: 8px;
43
+ justify-content: center;
44
+ `,
45
+ cell: css`
46
+ width: 30px;
47
+ height: 30px;
48
+ border: 1px solid ${tokens.colorNeutralStroke1};
49
+ background-color: ${tokens.colorNeutralBackground1};
50
+ cursor: pointer;
51
+ transition: background-color 150ms ease, transform 150ms ease;
52
+ will-change: background-color, transform;
53
+ `,
54
+ hoveredCell: css`
55
+ background-color: ${tokens.colorNeutralBackground1Hover};
56
+ transform: scale(1.05);
57
+ `,
58
+ selectedCell: css`
59
+ background-color: ${tokens.colorNeutralBackground1Selected};
60
+ `,
61
+ menuPopover: css`
62
+ min-width: fit-content;
63
+ `,
64
+ bottomText: css`
65
+ padding-left: 8px;
66
+ padding-right: 8px;
67
+ `,
68
+
69
+
70
+ });
71
+
72
+ export const SelectZoom: React.FunctionComponent<ISelectZoomProps> = (
73
+ props: React.PropsWithChildren<ISelectZoomProps>
74
+ ) => {
75
+ const { onChange, values: defaultValues, maxCols, maxRows } = props;
76
+ const Settings = bundleIcon(Settings20Filled, Settings20Regular);
77
+ const styles = useStyles();
78
+
79
+ const [values, setValues] = React.useState(defaultValues);
80
+ const [hovered, setHovered] = React.useState<{
81
+ spanCols: number;
82
+ spanRows: number;
83
+ } | null>(null);
84
+ const [open, setOpen] = React.useState(false);
85
+
86
+ React.useEffect(() => {
87
+ setValues(defaultValues);
88
+ }, [defaultValues]);
89
+
90
+ const onOpenChange: MenuProps["onOpenChange"] = (_, data) => {
91
+ setOpen(data.open);
92
+ setHovered(null);
93
+ };
94
+
95
+ const handleCellClick = (row: number, col: number):void => {
96
+ const newValues = { spanCols: col + 1, spanRows: row + 1 };
97
+ setValues(newValues);
98
+ onChange?.(newValues);
99
+ setOpen(false);
100
+ };
101
+
102
+ const handleCellHover = (row: number, col: number):void => {
103
+ setHovered({ spanCols: col + 1, spanRows: row + 1 });
104
+ };
105
+
106
+ const handleMouseLeave = ():void => {
107
+ setHovered(null);
108
+ };
109
+
110
+ const renderGridCells = (): React.ReactNode[] => {
111
+ const cells: React.ReactNode[] = [];
112
+ for (let row = 0; row < maxRows; row++) {
113
+ for (let col = 0; col < maxCols; col++) {
114
+ const isSelected = row < values.spanRows && col < values.spanCols;
115
+ const isHovered =
116
+ hovered && row < hovered.spanRows && col < hovered.spanCols;
117
+ cells.push(
118
+ <div
119
+ key={`${row}-${col}`}
120
+ className={`${styles.cell} ${isHovered ? styles.hoveredCell : ""} ${
121
+ isSelected ? styles.selectedCell : ""
122
+ }`}
123
+ onMouseEnter={() => handleCellHover(row, col)}
124
+ onClick={() => handleCellClick(row, col)}
125
+ />
126
+ );
127
+ }
128
+ }
129
+ return cells;
130
+ };
131
+
132
+ // Compute popover width dynamically
133
+ const popoverWidth = React.useMemo(() => (30 * maxCols) + (4 * (maxCols - 1)) + 32, [maxCols]); // 30px for each cell, 4px gap, and 32px padding (16 left and right)
134
+
135
+ return (
136
+ <Menu open={open} onOpenChange={onOpenChange}>
137
+ <MenuTrigger disableButtonEnhancement>
138
+ <Tooltip content="Configure" relationship="label">
139
+ <MenuButton
140
+ icon={<Settings />}
141
+ size="small"
142
+ appearance="transparent"
143
+ />
144
+ </Tooltip>
145
+ </MenuTrigger>
146
+
147
+ <MenuPopover
148
+ style={{ width: `${popoverWidth}px`, minWidth: "120px", padding: 8 }}
149
+ >
150
+ <div
151
+ style={{
152
+ display: "flex",
153
+ flexDirection: "row",
154
+ alignItems: "center",
155
+ gap: "6px",
156
+ width: "100%",
157
+ boxSizing: "border-box",
158
+ padding: "8px",
159
+ }}
160
+ >
161
+ <RenderLabel
162
+ label={`Selected Span (${values.spanCols} × ${values.spanRows})`}
163
+ icon={
164
+ <Icon
165
+ icon="fluent:number-row-20-regular"
166
+ width="32"
167
+ height="32"
168
+ />
169
+ }
170
+ />
171
+ </div>
172
+
173
+ {/* Grid */}
174
+ <div
175
+ className={styles.gridContainer}
176
+ style={{
177
+ gridTemplateColumns: `repeat(${maxCols}, 30px)`,
178
+ gridTemplateRows: `repeat(${maxRows}, 30px)`,
179
+ }}
180
+ onMouseLeave={handleMouseLeave}
181
+ >
182
+ {renderGridCells()}
183
+ </div>
184
+
185
+ <Caption1 className={styles.bottomText}>Click to set span</Caption1>
186
+ </MenuPopover>
187
+ </Menu>
188
+ );
189
+ };