Skip to content
This repository was archived by the owner on Jan 30, 2026. It is now read-only.

Commit af33a9b

Browse files
authored
feat: enhance FieldMenu with grouped field display and auto-expand functionality for categories (#6)
1 parent 66d06b9 commit af33a9b

2 files changed

Lines changed: 160 additions & 58 deletions

File tree

src/defaults/FieldMenu.tsx

Lines changed: 159 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22
import type { FieldDefinition, FieldMenuProps } from "../types";
33

44
export const FieldMenu: React.FC<FieldMenuProps> = ({
@@ -37,11 +37,61 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
3737
};
3838
}, [position]);
3939

40-
if (!isVisible) return null;
41-
42-
const visibleFields = filteredFields ?? availableFields;
40+
const fieldsToDisplay = filteredFields ?? availableFields;
4341
const hasFilter = Boolean(filterQuery);
44-
const hasVisibleFields = visibleFields.length > 0;
42+
43+
const groupedFields = useMemo(() => {
44+
const groups: { category: string; fields: FieldDefinition[] }[] = [];
45+
const categoryIndex = new Map<string, number>();
46+
47+
fieldsToDisplay.forEach((field) => {
48+
const categoryName = field.category?.trim() || "Uncategorized";
49+
const existingIndex = categoryIndex.get(categoryName);
50+
51+
if (existingIndex !== undefined) {
52+
groups[existingIndex].fields.push(field);
53+
return;
54+
}
55+
56+
categoryIndex.set(categoryName, groups.length);
57+
groups.push({ category: categoryName, fields: [field] });
58+
});
59+
60+
return groups;
61+
}, [fieldsToDisplay]);
62+
63+
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>({});
64+
65+
useEffect(() => {
66+
setExpandedCategories((previous) => {
67+
if (groupedFields.length === 0) {
68+
return Object.keys(previous).length === 0 ? previous : {};
69+
}
70+
71+
const next: Record<string, boolean> = {};
72+
let hasChanges = Object.keys(previous).length !== groupedFields.length;
73+
74+
groupedFields.forEach(({ category }, index) => {
75+
// Auto-expand all categories when filtering is active
76+
const target = hasFilter ? true : (previous[category] ?? index === 0);
77+
next[category] = target;
78+
if (!hasChanges && previous[category] !== target) {
79+
hasChanges = true;
80+
}
81+
});
82+
83+
return hasChanges ? next : previous;
84+
});
85+
}, [groupedFields, hasFilter]);
86+
87+
const toggleCategory = useCallback((category: string) => {
88+
setExpandedCategories((previous) => ({
89+
...previous,
90+
[category]: !previous[category],
91+
}));
92+
}, []);
93+
94+
if (!isVisible) return null;
4595

4696
const handleCreateField = async () => {
4797
const trimmedName = newFieldName.trim();
@@ -68,69 +118,22 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
68118

69119
return (
70120
<div className="superdoc-field-menu" style={menuStyle}>
71-
<div
72-
style={{
73-
padding: "0 16px 8px 16px",
74-
borderBottom: "1px solid #f3f4f6",
75-
marginBottom: "8px",
76-
}}
77-
>
121+
{hasFilter && (
78122
<div
79123
style={{
80-
fontSize: "12px",
81-
color: "#6b7280",
82-
textTransform: "uppercase",
124+
padding: "8px 16px",
125+
borderBottom: "1px solid #f0f0f0",
126+
marginBottom: "4px",
83127
}}
84128
>
85-
Insert Field
86-
</div>
87-
{hasFilter && (
88-
<div style={{ fontSize: "12px", color: "#6b7280", marginTop: "4px" }}>
129+
<div style={{ fontSize: "12px", color: "#6b7280" }}>
89130
Filtering results for
90131
<span
91132
style={{ fontWeight: 600, color: "#111827", marginLeft: "4px" }}
92133
>
93134
{filterQuery}
94135
</span>
95136
</div>
96-
)}
97-
</div>
98-
99-
{hasVisibleFields ? (
100-
visibleFields.map((field) => (
101-
<div
102-
key={field.id}
103-
className="field-menu-item"
104-
onClick={() => onSelect(field)}
105-
style={{
106-
padding: "8px 16px",
107-
cursor: "pointer",
108-
}}
109-
>
110-
<span style={{ fontWeight: 500 }}>{field.label}</span>
111-
{field.category && (
112-
<span
113-
style={{
114-
fontSize: "0.85em",
115-
color: "#666",
116-
marginLeft: "8px",
117-
}}
118-
>
119-
{field.category}
120-
</span>
121-
)}
122-
</div>
123-
))
124-
) : (
125-
<div
126-
style={{
127-
padding: "16px",
128-
fontSize: "13px",
129-
color: "#6b7280",
130-
textAlign: "center",
131-
}}
132-
>
133-
No matching fields
134137
</div>
135138
)}
136139

@@ -211,6 +214,105 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
211214
</div>
212215
)}
213216

217+
{allowCreate && availableFields.length > 0 && (
218+
<div
219+
style={{
220+
borderTop: "1px solid #eee",
221+
margin: "4px 0",
222+
}}
223+
/>
224+
)}
225+
226+
{groupedFields.length === 0 ? (
227+
<div
228+
style={{
229+
padding: "16px",
230+
fontSize: "13px",
231+
color: "#6b7280",
232+
textAlign: "center",
233+
}}
234+
>
235+
No matching fields
236+
</div>
237+
) : (
238+
groupedFields.map(({ category, fields }, index) => {
239+
const isExpanded = Boolean(expandedCategories[category]);
240+
const itemsMaxHeight = `${Math.max(fields.length * 40, 0)}px`;
241+
242+
return (
243+
<div
244+
key={category}
245+
style={{
246+
borderTop: index === 0 && allowCreate ? undefined : "1px solid #f0f0f0",
247+
}}
248+
>
249+
<button
250+
type="button"
251+
onClick={() => toggleCategory(category)}
252+
style={{
253+
width: "100%",
254+
display: "flex",
255+
alignItems: "center",
256+
justifyContent: "space-between",
257+
padding: "8px 16px",
258+
background: "transparent",
259+
border: "none",
260+
cursor: "pointer",
261+
fontWeight: 500,
262+
textAlign: "left",
263+
}}
264+
>
265+
<span>
266+
{category} ({fields.length})
267+
</span>
268+
<span
269+
aria-hidden
270+
style={{
271+
display: "inline-block",
272+
width: "8px",
273+
height: "8px",
274+
borderRight: "2px solid #666",
275+
borderBottom: "2px solid #666",
276+
transform: isExpanded ? "rotate(45deg)" : "rotate(-45deg)",
277+
transition: "transform 0.2s ease",
278+
marginLeft: "12px",
279+
}}
280+
/>
281+
</button>
282+
<div
283+
data-category={category}
284+
aria-hidden={!isExpanded}
285+
style={{
286+
overflow: "hidden",
287+
maxHeight: isExpanded ? itemsMaxHeight : "0px",
288+
opacity: isExpanded ? 1 : 0,
289+
transition: "max-height 0.2s ease, opacity 0.2s ease",
290+
pointerEvents: isExpanded ? "auto" : "none",
291+
}}
292+
>
293+
<div style={{ padding: isExpanded ? "4px 0" : 0 }}>
294+
{fields.map((field) => (
295+
<div
296+
key={field.id}
297+
className="field-menu-item"
298+
onClick={() => onSelect(field)}
299+
style={{
300+
padding: "8px 16px",
301+
cursor: "pointer",
302+
display: "flex",
303+
alignItems: "center",
304+
justifyContent: "space-between",
305+
}}
306+
>
307+
<span style={{ fontWeight: 500 }}>{field.label}</span>
308+
</div>
309+
))}
310+
</div>
311+
</div>
312+
</div>
313+
);
314+
}))}
315+
214316
<div
215317
style={{
216318
borderTop: "1px solid #eee",

src/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ const SuperDocTemplateBuilder = forwardRef<
366366
};
367367

368368
triggerCleanupRef.current = cleanup;
369-
menuTriggerFromRef.current = triggerStart;
369+
menuTriggerFromRef.current = from;
370370
setMenuPosition(bounds);
371371
setMenuVisible(true);
372372
resetMenuFilter();

0 commit comments

Comments
 (0)