Skip to content

Commit 2ae4a70

Browse files
authored
chore: inital headless tree setup (#6051)
* add package
1 parent 41d9104 commit 2ae4a70

File tree

6 files changed

+398
-2
lines changed

6 files changed

+398
-2
lines changed

app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/right-panel/RightPanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ export const RightPanel = ({ id, lang }: { id: string; lang: Language }) => {
9999
? "top-30"
100100
: "top-10"
101101
: isIntersecting
102-
? "top-20"
103-
: "top-0";
102+
? "top-20"
103+
: "top-0";
104104

105105
// Observe if the header is offscreen
106106
// Used to determine the position of the right panel button "toggle" button
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Note this is a work in progress to move the tree view to a more accessible implementation.
3+
*/
4+
5+
import "./style.css";
6+
import {
7+
asyncDataLoaderFeature,
8+
createOnDropHandler,
9+
dragAndDropFeature,
10+
hotkeysCoreFeature,
11+
keyboardDragAndDropFeature,
12+
selectionFeature,
13+
} from "@headless-tree/core";
14+
import { DemoItem, asyncDataLoader, data } from "./data";
15+
import { AssistiveTreeDescription, useTree } from "@headless-tree/react";
16+
import { cn } from "@lib/utils";
17+
18+
export const TreeView = ({ ref }: { ref: React.Ref<HTMLDivElement> }) => {
19+
const tree = useTree<DemoItem>({
20+
initialState: {
21+
expandedItems: ["fruit"],
22+
selectedItems: ["banana", "orange"],
23+
},
24+
rootItemId: "root",
25+
getItemName: (item) => item.getItemData()?.name,
26+
isItemFolder: (item) => !!item.getItemData()?.children,
27+
canReorder: true,
28+
onDrop: createOnDropHandler((item, newChildren) => {
29+
data[item.getId()].children = newChildren;
30+
}),
31+
indent: 20,
32+
dataLoader: asyncDataLoader,
33+
features: [
34+
asyncDataLoaderFeature,
35+
selectionFeature,
36+
hotkeysCoreFeature,
37+
dragAndDropFeature,
38+
keyboardDragAndDropFeature,
39+
],
40+
});
41+
42+
return (
43+
<div {...tree.getContainerProps()} className="tree" ref={ref}>
44+
<AssistiveTreeDescription tree={tree} />
45+
{tree.getItems().map((item) => (
46+
<button
47+
key={item.getId()}
48+
{...item.getProps()}
49+
style={{ paddingLeft: `${item.getItemMeta().level * 20}px` }}
50+
>
51+
<div
52+
className={cn("treeitem", {
53+
focused: item.isFocused(),
54+
expanded: item.isExpanded(),
55+
selected: item.isSelected(),
56+
folder: item.isFolder(),
57+
drop: item.isDragTarget(),
58+
})}
59+
>
60+
{item.getItemName()}
61+
</div>
62+
</button>
63+
))}
64+
<div style={tree.getDragLineStyle()} className="dragline" />
65+
</div>
66+
);
67+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
export type DemoItem = {
2+
name: string;
3+
children?: string[];
4+
};
5+
6+
export const data: Record<string, DemoItem> = {
7+
root: {
8+
name: "Root",
9+
children: ["fruit", "vegetables", "meals", "dessert", "drinks"],
10+
},
11+
fruit: {
12+
name: "Fruit",
13+
children: ["apple", "banana", "orange", "berries", "lemon"],
14+
},
15+
apple: { name: "Apple" },
16+
banana: { name: "Banana" },
17+
orange: { name: "Orange" },
18+
lemon: { name: "Lemon" },
19+
berries: { name: "Berries", children: ["red", "blue", "black"] },
20+
red: { name: "Red", children: ["strawberry", "raspberry"] },
21+
strawberry: { name: "Strawberry" },
22+
raspberry: { name: "Raspberry" },
23+
blue: { name: "Blue", children: ["blueberry"] },
24+
blueberry: { name: "Blueberry" },
25+
black: { name: "Black", children: ["blackberry"] },
26+
blackberry: { name: "Blackberry" },
27+
vegetables: {
28+
name: "Vegetables",
29+
children: ["tomato", "carrot", "cucumber", "potato"],
30+
},
31+
tomato: { name: "Tomato" },
32+
carrot: { name: "Carrot" },
33+
cucumber: { name: "Cucumber" },
34+
potato: { name: "Potato" },
35+
meals: {
36+
name: "Meals",
37+
children: ["america", "europe", "asia", "australia"],
38+
},
39+
america: { name: "America", children: ["burger", "hotdog", "pizza"] },
40+
burger: { name: "Burger" },
41+
hotdog: { name: "Hotdog" },
42+
pizza: { name: "Pizza" },
43+
europe: {
44+
name: "Europe",
45+
children: ["pasta", "paella", "schnitzel", "risotto", "weisswurst"],
46+
},
47+
pasta: { name: "Pasta" },
48+
paella: { name: "Paella" },
49+
schnitzel: { name: "Schnitzel" },
50+
risotto: { name: "Risotto" },
51+
weisswurst: { name: "Weisswurst" },
52+
asia: { name: "Asia", children: ["sushi", "ramen", "curry", "noodles"] },
53+
sushi: { name: "Sushi" },
54+
ramen: { name: "Ramen" },
55+
curry: { name: "Curry" },
56+
noodles: { name: "Noodles" },
57+
australia: {
58+
name: "Australia",
59+
children: ["potatowedges", "pokebowl", "lemoncurd", "kumarafries"],
60+
},
61+
potatowedges: { name: "Potato Wedges" },
62+
pokebowl: { name: "Poke Bowl" },
63+
lemoncurd: { name: "Lemon Curd" },
64+
kumarafries: { name: "Kumara Fries" },
65+
dessert: {
66+
name: "Dessert",
67+
children: ["icecream", "cake", "pudding", "cookies"],
68+
},
69+
icecream: { name: "Icecream" },
70+
cake: { name: "Cake" },
71+
pudding: { name: "Pudding" },
72+
cookies: { name: "Cookies" },
73+
drinks: { name: "Drinks", children: ["water", "juice", "beer", "wine"] },
74+
water: { name: "Water" },
75+
juice: { name: "Juice" },
76+
beer: { name: "Beer" },
77+
wine: { name: "Wine" },
78+
};
79+
80+
const wait = (ms: number) =>
81+
new Promise((resolve) => {
82+
setTimeout(resolve, ms);
83+
});
84+
85+
export const syncDataLoader = {
86+
getItem: (id: string) => data[id],
87+
getChildren: (id: string) => data[id]?.children ?? [],
88+
};
89+
90+
export const asyncDataLoader = {
91+
getItem: (itemId: string) => wait(50).then(() => data[itemId]),
92+
getChildren: (itemId: string) => wait(50).then(() => data[itemId]?.children ?? []),
93+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
.tree {
2+
max-width: 300px;
3+
min-height: 100px;
4+
}
5+
6+
.tree button[role="treeitem"] {
7+
display: flex;
8+
background: transparent;
9+
border: none;
10+
width: 100%;
11+
padding: 0 0 2px 0;
12+
}
13+
14+
.treeitem {
15+
width: 100%;
16+
text-align: left;
17+
background-color: white;
18+
padding: 6px 10px;
19+
position: relative;
20+
border-radius: 8px;
21+
transition: background-color 0.2s ease;
22+
cursor: pointer;
23+
}
24+
.treeitem:hover {
25+
background-color: var(--selected-color);
26+
}
27+
28+
.renaming-item {
29+
background-color: var(--selected-color);
30+
margin-bottom: 2px;
31+
border-radius: 8px;
32+
padding: 4px 10px 5px 24px;
33+
}
34+
.renaming-item input {
35+
width: 100%;
36+
height: 100%;
37+
border: none;
38+
background: transparent;
39+
outline: none;
40+
}
41+
42+
.treeitem:hover {
43+
border-color: black;
44+
}
45+
46+
.tree button[role="treeitem"]:focus {
47+
outline: none;
48+
}
49+
50+
.treeitem.selected {
51+
background-color: #eee;
52+
}
53+
54+
button:focus-visible .treeitem.focused,
55+
.treeitem.searchmatch.focused {
56+
outline: 2px solid black;
57+
}
58+
59+
.treeitem.drop {
60+
border-color: var(--selected-color);
61+
background-color: #e1f1f8;
62+
}
63+
64+
.treeitem.searchmatch {
65+
background-color: #e1f8ff;
66+
}
67+
68+
.treeitem.folder:before {
69+
content: url(data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9InByZXNlcnZlIj48Zz48Zz48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTQuNjQ2IDEuNjQ2YS41LjUgMCAwIDEgLjcwOCAwbDYgNmEuNS41IDAgMCAxIDAgLjcwOGwtNiA2YS41LjUgMCAwIDEtLjcwOC0uNzA4TDEwLjI5MyA4IDQuNjQ2IDIuMzU0YS41LjUgMCAwIDEgMC0uNzA4eiIgY2xhc3M9InJjdC10cmVlLWl0ZW0tYXJyb3ctcGF0aCI+PC9wYXRoPjwvZz48L2c+PC9zdmc+);
70+
width: 10px;
71+
display: inline-block;
72+
z-index: 1;
73+
margin-right: 4px;
74+
transition: transform 0.1s ease-in-out;
75+
}
76+
77+
.treeitem.folder.expanded:before {
78+
transform: rotate(90deg);
79+
}
80+
81+
.treeitem:not(.folder) {
82+
padding-left: 24px;
83+
}
84+
85+
.treeitem.selected:after {
86+
content: " ";
87+
position: absolute;
88+
top: 5px;
89+
left: -2px;
90+
height: 16px;
91+
width: 4px;
92+
background-color: #0366d6;
93+
border-radius: 99px;
94+
}
95+
96+
.description {
97+
font-family: sans-serif;
98+
font-size: 0.8rem;
99+
background-color: #eee;
100+
border-radius: 8px;
101+
padding: 8px 12px;
102+
}
103+
104+
.dragline {
105+
height: 2px;
106+
margin-top: -1px;
107+
background-color: #0366d6;
108+
}
109+
110+
.dragline::before {
111+
content: "";
112+
position: absolute;
113+
left: 0;
114+
top: -3px;
115+
height: 4px;
116+
width: 4px;
117+
background: #fff;
118+
border: 2px solid #0366d6;
119+
border-radius: 99px;
120+
}
121+
122+
.outeritem {
123+
display: flex;
124+
align-items: center;
125+
gap: 2px;
126+
}
127+
.outeritem button:not([role="treeitem"]) {
128+
padding: 2px 4px;
129+
height: 80%;
130+
}
131+
132+
.actionbar {
133+
display: flex;
134+
align-items: center;
135+
justify-content: center;
136+
flex-wrap: wrap;
137+
gap: 4px;
138+
margin-top: 8px;
139+
}
140+
141+
.foreign-dragsource,
142+
.foreign-dropzone,
143+
.searchbox,
144+
.actionbtn {
145+
height: 30px;
146+
background-color: transparent;
147+
border: 1px solid #808080;
148+
padding: 0 8px;
149+
border-radius: 4px;
150+
font-size: 0.8rem;
151+
color: #393939;
152+
display: flex;
153+
align-items: center;
154+
justify-items: center;
155+
}
156+
157+
.foreign-dragsource {
158+
cursor: grab;
159+
}
160+
.foreign-dragsource:active {
161+
cursor: grabbing;
162+
}
163+
.foreign-dragsource:before {
164+
content: url(data:image/svg+xml;base64,PHN2ZyBzdHJva2U9ImN1cnJlbnRDb2xvciIgZmlsbD0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGhlaWdodD0iMThweCIgd2lkdGg9IjE4cHgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkgMTNhMSAxIDAgMSAxIDAtMiAxIDEgMCAwIDEgMCAyWm03LTFhMSAxIDAgMSAxLTIgMCAxIDEgMCAwIDEgMiAwWk05IDhhMSAxIDAgMSAxIDAtMiAxIDEgMCAwIDEgMCAyWm03LTFhMSAxIDAgMSAxLTIgMCAxIDEgMCAwIDEgMiAwWk05IDE4YTEgMSAwIDEgMSAwLTIgMSAxIDAgMCAxIDAgMlptNiAwYTEgMSAwIDEgMSAwLTIgMSAxIDAgMCAxIDAgMloiPjwvcGF0aD48L3N2Zz4=);
165+
width: 10px;
166+
display: inline-block;
167+
z-index: 1;
168+
margin-right: 8px;
169+
margin-top: 2px;
170+
}
171+
.foreign-dragsource:hover {
172+
background-color: #f6f6f6;
173+
}
174+
175+
.foreign-dropzone {
176+
border: 1px dashed #808080;
177+
padding: 0 26px;
178+
}
179+
180+
.searchbox {
181+
padding: 8px 16px;
182+
margin-bottom: 8px;
183+
flex-wrap: wrap;
184+
gap: 4px;
185+
height: unset;
186+
}
187+
188+
.searchbox:before {
189+
content: "Navigate between search results with ArrowUp and ArrowDown. Press Escape to close search.";
190+
display: block;
191+
width: 100%;
192+
}
193+
.searchbox input {
194+
flex-grow: 1;
195+
padding: 4px;
196+
}
197+
198+
.actionbtn:hover {
199+
cursor: pointer;
200+
background-color: #f6f6f6;
201+
}
202+
203+
.visible-assistive-text {
204+
position: unset !important;
205+
width: unset !important;
206+
height: 60px !important;
207+
margin: unset !important;
208+
overflow: auto !important;
209+
clip: unset !important;
210+
211+
display: block;
212+
margin-bottom: 1rem !important;
213+
background-color: #e1f1f8;
214+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@
6868
"@gcforms/types": "workspace:*",
6969
"@hcaptcha/react-hcaptcha": "^1.12.0",
7070
"@hcaptcha/types": "^1.0.4",
71+
"@headless-tree/core": "^1.4.0",
72+
"@headless-tree/react": "^1.4.0",
7173
"@headlessui/react": "^2.2.0",
7274
"@neshca/cache-handler": "^1.2.1",
7375
"@next/mdx": "15.5.0",

0 commit comments

Comments
 (0)