Skip to content

Commit 875f15d

Browse files
committed
Refactor the ContextMenu component a bit.
1 parent 318975a commit 875f15d

File tree

7 files changed

+142
-117
lines changed

7 files changed

+142
-117
lines changed

src/app.css

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -233,26 +233,41 @@ body {
233233
}
234234
}
235235

236-
.popover-menu-backdrop {
236+
.popover {
237+
background: var(--color-popover-background);
238+
border: 1px solid var(--color-border);
239+
box-shadow:
240+
rgb(0 0 0 / 0.25) 0 2px 4px,
241+
rgb(0 0 0 / 0.125) 0 4px 8px;
242+
243+
&.fixed-positioning {
244+
position: fixed;
245+
width: max-content;
246+
}
247+
248+
&.animate-enter {
249+
animation: 0.1s ease fade-in;
250+
}
251+
}
252+
253+
.popover-backdrop {
237254
position: fixed;
238-
z-index: 1000;
239255
inset: 0;
240256
}
241257

242-
.popover-menu {
243-
position: fixed;
244-
z-index: 1001;
258+
.popover-container {
259+
position: absolute;
260+
display: flex;
261+
flex-direction: column;
262+
gap: 8px;
263+
}
264+
265+
.menu-popover {
245266
display: flex;
246267
flex-direction: column;
247268
align-items: stretch;
248-
width: max-content;
249269
background: var(--color-menu-background);
250-
border: 1px solid var(--color-border);
251-
box-shadow:
252-
rgb(0 0 0 / 0.25) 0 2px 4px,
253-
rgb(0 0 0 / 0.125) 0 4px 8px;
254270
overflow: auto;
255-
animation: 0.1s ease fade-in;
256271

257272
--color-button-disabled-background: var(--color-button-background);
258273

src/app.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { GlasgowFileSystem, type FileTreeNode } from './filesystem';
1212
import { PanelContainer } from './components/panel';
1313
import { TreeView } from './components/tree-view';
1414

15-
import { onlyTruthy } from './helpers/truthy-filter';
15+
import { onlyTruthy } from './helpers/comparison';
1616
import { joinPath } from './helpers/path';
1717

1818
import { GLASGOW_WHEEL_URL, HOME_DIRECTORY } from './config';

src/components/context-menu.tsx

Lines changed: 76 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
import { createComputed, createEffect, createMemo, createSignal, For, on, type JSX } from 'solid-js';
1+
import { createEffect, createSelector, createSignal, For, type JSX } from 'solid-js';
2+
import { Portal } from 'solid-js/web';
23
import { computePosition, flip, shift, size } from '@floating-ui/dom';
34

45
import { modulo } from '../helpers/modulo';
56
import { cls } from '../helpers/class-names';
7+
import { equals } from '../helpers/comparison';
68

7-
export type TwoDim = [number, number];
9+
export type Point2D = [number, number];
810

911
interface FocusTrapProps {
1012
returnFocus: () => void;
1113
children: JSX.Element;
1214
}
1315

1416
const FocusTrap = (props: FocusTrapProps) => {
15-
const trap = () => <div tabIndex={0} style={{ position: 'absolute' }} onFocus={props.returnFocus} />;
17+
const trap = () => (
18+
<div tabIndex={0} style={{ position: 'absolute' }} onFocus={props.returnFocus} />
19+
);
1620

1721
return (
1822
<>
@@ -25,92 +29,82 @@ const FocusTrap = (props: FocusTrapProps) => {
2529

2630
interface ContextMenuItem {
2731
name: string;
28-
action: (event: Event) => void;
32+
action: () => void;
2933
}
3034

3135
interface ContextMenuProps {
32-
position: TwoDim;
36+
anchor: Point2D;
3337
items: ContextMenuItem[];
3438
onCancel: () => void;
3539
}
3640

3741
export const ContextMenu = (props: ContextMenuProps) => {
3842
let elementRef: HTMLDivElement;
43+
let listElement: HTMLUListElement;
3944

40-
const [constrainedPosition, setConstrainedPosition] = createSignal<TwoDim>([0, 0]);
41-
const [maxSize, setMaxSize] = createSignal<TwoDim>([0, 0]);
42-
const needToRecalculatePosition = createMemo(on(() => props.position, () => ({ value: true })));
43-
const [currentIndex, setCurrentIndex] = createSignal<number | null>(null);
44-
const itemElements: HTMLElement[] = [];
45+
const [constrainedPosition, setConstrainedPosition] = createSignal<Point2D>([0, 0]);
46+
const [maxSize, setMaxSize] = createSignal<Point2D>([0, 0]);
47+
const [lastAnchorPoint, setLastAnchorPoint] = createSignal<Point2D | null>(null);
4548

46-
createComputed(() => {
47-
props.items;
48-
setCurrentIndex(null);
49-
});
49+
const [selectedIndex, setSelectedIndex] = createSignal<number | null>(null);
50+
const isItemSelected = createSelector(selectedIndex);
5051

5152
const handleBackdropClick = (event: MouseEvent) => {
5253
event.preventDefault();
5354
props.onCancel();
5455
};
5556

5657
const handleMouseOut = (event: MouseEvent) => {
57-
setCurrentIndex(null);
58+
setSelectedIndex(null);
5859
};
5960

6061
const handleKeyDown = (event: KeyboardEvent) => {
6162
if (event.key === 'Escape') {
6263
props.onCancel();
6364
event.stopPropagation();
6465
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
65-
let direction = event.key === 'ArrowDown' ? 1 : -1;
66-
let newIndex;
67-
if (currentIndex() !== null) {
68-
newIndex = modulo(currentIndex()! + direction, props.items.length);
69-
} else {
70-
newIndex = direction === 1 ? 0 : props.items.length - 1;
71-
}
72-
setCurrentIndex(newIndex);
66+
let delta = event.key === 'ArrowDown' ? 1 : -1;
67+
let oldIndex = selectedIndex() ?? (delta > 0 ? -1 : props.items.length);
68+
let newIndex = modulo(oldIndex + delta, props.items.length);
69+
setSelectedIndex(newIndex);
7370
event.stopPropagation();
7471
}
7572
};
7673

7774
const handleItemClick = (item: ContextMenuItem) => (event: MouseEvent) => {
78-
item.action(event);
75+
item.action();
7976
props.onCancel();
8077
};
8178

8279
const handleItemKeyDown = (item: ContextMenuItem) => (event: KeyboardEvent) => {
8380
if (event.key === 'Enter' || event.key === ' ') {
84-
item.action(event);
81+
item.action();
8582
props.onCancel();
8683
}
8784
};
8885

8986
const handleItemHover = (idx: number) => (event: MouseEvent) => {
90-
setCurrentIndex(idx);
87+
setSelectedIndex(idx);
9188
};
9289

9390
createEffect(() => {
94-
if (currentIndex() !== null) {
95-
itemElements[currentIndex()!]?.focus();
96-
} else {
97-
elementRef.focus();
98-
}
91+
((listElement.children[selectedIndex() ?? -1] ?? elementRef) as HTMLElement).focus();
9992
});
10093

10194
createEffect(() => {
102-
if (needToRecalculatePosition().value && elementRef) {
95+
let anchorPoint = props.anchor;
96+
if (!equals(lastAnchorPoint(), anchorPoint)) {
10397
const virtualReferenceElement = {
10498
getBoundingClientRect() {
10599
return {
106100
width: 0,
107101
height: 0,
108-
x: props.position[0],
109-
y: props.position[1],
110-
top: props.position[1],
111-
left: props.position[0],
112-
right: props.position[0],
113-
bottom: props.position[1],
102+
x: anchorPoint[0],
103+
y: anchorPoint[1],
104+
top: anchorPoint[1],
105+
left: anchorPoint[0],
106+
right: anchorPoint[0],
107+
bottom: anchorPoint[1],
114108
};
115109
},
116110
};
@@ -120,9 +114,7 @@ export const ContextMenu = (props: ContextMenuProps) => {
120114
availableHeight: number;
121115
}>();
122116

123-
// Will be overridden on the next render
124-
elementRef.style.maxWidth = '';
125-
elementRef.style.maxHeight = '';
117+
setMaxSize([0, 0]);
126118

127119
computePosition(virtualReferenceElement, elementRef, {
128120
placement: 'bottom-start',
@@ -142,54 +134,53 @@ export const ContextMenu = (props: ContextMenuProps) => {
142134
}).then(async (result) => {
143135
const size = await sizeCalculation.promise;
144136

145-
setConstrainedPosition([result.x, result.y]);
146-
setMaxSize([size.availableWidth, size.availableHeight]);
147-
148-
needToRecalculatePosition().value = false;
137+
if (equals(props.anchor, anchorPoint)) {
138+
setConstrainedPosition([result.x, result.y]);
139+
setMaxSize([size.availableWidth, size.availableHeight]);
140+
setLastAnchorPoint(anchorPoint);
141+
}
149142
});
150143
}
151144
});
152145

153146
return (
154-
<FocusTrap returnFocus={() => elementRef.focus()}>
155-
<div
156-
class="popover-menu-backdrop"
157-
onClick={handleBackdropClick}
158-
onMouseDown={handleBackdropClick}
159-
/>
160-
<div
161-
ref={(el) => elementRef = el}
162-
class="popover-menu"
163-
style={{
164-
'left': `${constrainedPosition()[0]}px`,
165-
'top': `${constrainedPosition()[1]}px`,
166-
'max-width': maxSize()[0] > 0 ? `${maxSize()[0]}px` : '',
167-
'max-height': maxSize()[1] > 0 ? `${maxSize()[1]}px` : '',
168-
}}
169-
tabIndex={0}
170-
onMouseOut={handleMouseOut}
171-
onKeyDown={handleKeyDown}
172-
>
173-
<ul class="menu-list">
174-
<For each={props.items}>
175-
{(item, idx) => (
176-
<li
177-
ref={(element) => {
178-
if (element) itemElements[idx()] = element;
179-
else delete itemElements[idx()];
180-
}}
181-
class={cls('menu-list-item', currentIndex() === idx() && 'focused')}
182-
tabIndex={currentIndex() === idx() ? 0 : -1}
183-
onClick={handleItemClick(item)}
184-
onKeyDown={handleItemKeyDown(item)}
185-
onMouseEnter={handleItemHover(idx())}
186-
>
187-
{item.name}
188-
</li>
189-
)}
190-
</For>
191-
</ul>
192-
</div>
193-
</FocusTrap>
147+
<Portal>
148+
<FocusTrap returnFocus={() => elementRef.focus()}>
149+
<div
150+
class="popover-backdrop"
151+
onClick={handleBackdropClick}
152+
onMouseDown={handleBackdropClick}
153+
/>
154+
<div
155+
ref={(el) => elementRef = el}
156+
class="menu-popover popover fixed-positioning animate-enter"
157+
style={{
158+
'left': `${constrainedPosition()[0]}px`,
159+
'top': `${constrainedPosition()[1]}px`,
160+
'max-width': maxSize()[0] > 0 ? `${maxSize()[0]}px` : '',
161+
'max-height': maxSize()[1] > 0 ? `${maxSize()[1]}px` : '',
162+
}}
163+
tabIndex={0}
164+
onMouseOut={handleMouseOut}
165+
onKeyDown={handleKeyDown}
166+
>
167+
<ul ref={(el) => listElement = el} class="menu-list">
168+
<For each={props.items}>
169+
{(item, idx) => (
170+
<li
171+
class={cls('menu-list-item', isItemSelected(idx()) && 'focused')}
172+
tabIndex={isItemSelected(idx()) ? 0 : -1}
173+
onClick={handleItemClick(item)}
174+
onKeyDown={handleItemKeyDown(item)}
175+
onMouseEnter={handleItemHover(idx())}
176+
>
177+
{item.name}
178+
</li>
179+
)}
180+
</For>
181+
</ul>
182+
</div>
183+
</FocusTrap>
184+
</Portal>
194185
);
195186
};

src/components/panel.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createComputed, createSignal, For, onCleanup, Show, type JSX } from 'solid-js';
22

3-
import { ContextMenu, type TwoDim } from './context-menu';
3+
import { ContextMenu, type Point2D } from './context-menu';
44
import { Icon } from './icon';
55
import { IconMore } from './icon-more';
66

@@ -39,7 +39,7 @@ interface PanelAction {
3939
iconName?: string;
4040
iconOnly?: boolean;
4141
disabled: boolean;
42-
handleAction: (event: Event) => void;
42+
handleAction: () => void;
4343
}
4444

4545
interface PanelActionsProps {
@@ -48,7 +48,7 @@ interface PanelActionsProps {
4848

4949
const PanelActions = (props: PanelActionsProps) => {
5050
const [numberOfVisibleActions, setNumberOfVisibleActions] = createSignal(props.actions?.length ?? 0);
51-
const [actionsMenuOpenAtPosition, setActionsMenuOpenAtPosition] = createSignal<TwoDim | null>(null);
51+
const [actionsMenuOpenAtPosition, setActionsMenuOpenAtPosition] = createSignal<Point2D | null>(null);
5252

5353
const visibleActionsWrapperRef = useResizeObserverRef((entry: ResizeObserverEntry) => {
5454
const wrapper = entry.target as HTMLElement;
@@ -116,10 +116,10 @@ const PanelActions = (props: PanelActionsProps) => {
116116
</div>
117117
{(props.actions && actionsMenuOpenAtPosition()) ? (
118118
<ContextMenu
119-
position={actionsMenuOpenAtPosition()!}
119+
anchor={actionsMenuOpenAtPosition()!}
120120
items={props.actions.slice(numberOfVisibleActions()).map((action) => ({
121121
name: action.name,
122-
action: (event) => action.handleAction(event),
122+
action: () => action.handleAction(),
123123
}))}
124124
onCancel={() => setActionsMenuOpenAtPosition(null)}
125125
/>
@@ -213,7 +213,7 @@ export const PanelContainer = (props: PanelContainerProps) => {
213213
<span>{panel.name}</span>
214214
</h2>
215215
<Show when={panel.actions && panel.actions.length > 0}>
216-
{(_) => <PanelActions actions={panel.actions} />}
216+
<PanelActions actions={panel.actions} />
217217
</Show>
218218
</header>
219219
</Show>

0 commit comments

Comments
 (0)