Skip to content

Commit e4732b7

Browse files
committed
feat: flip context menus
1 parent 33204c1 commit e4732b7

5 files changed

Lines changed: 106 additions & 32 deletions

File tree

src/components/context-menu/index.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,21 @@ import './style.css';
77

88
const IdCtx = createContext<string | undefined>(undefined);
99

10-
let lastAnchor: HTMLElement | null = null;
11-
1210
function handleContextMenu(e: MouseEvent) {
1311
e.preventDefault();
1412
const el = e.currentTarget as HTMLElement;
15-
if (lastAnchor) lastAnchor.style.anchorName = '';
16-
lastAnchor = el;
17-
el.style.anchorName = '--p-context-menu';
1813
const target = el.ownerDocument.getElementById(
1914
el.getAttribute('commandfor')!,
2015
) as HTMLDialogElement;
2116
if (!target) return;
22-
const rect = el.getBoundingClientRect();
23-
target.style.setProperty('--p-context-menu-x', `${e.clientX - rect.x}px`);
24-
target.style.setProperty('--p-context-menu-y', `${e.clientY - rect.y}px`);
17+
let x = e.clientX;
18+
let y = e.clientY;
2519
target.showModal();
20+
const r = target.getBoundingClientRect();
21+
if (x + r.width > innerWidth) x = Math.max(0, x - r.width);
22+
if (y + r.height > innerHeight) y = Math.max(0, y - r.height);
23+
target.style.setProperty('--p-x', x + 'px');
24+
target.style.setProperty('--p-y', y + 'px');
2625
}
2726

2827
export function ContextMenuTrigger({children}: JSX.ElementChildrenAttribute) {

src/components/context-menu/style.css

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22
@import "../../components/popover/style.css";
33
@import "../dropdown-menu/style.css";
44

5-
body [p="context-menu"] {
5+
[p="context-menu"] {
66
position: fixed;
7-
position-anchor: --p-context-menu;
8-
left: anchor(left);
9-
top: anchor(top);
10-
--tf-x: 0;
7+
left: 0;
8+
top: 0;
119
min-width: 6rem;
12-
margin-left: var(--p-context-menu-x);
13-
margin-top: var(--p-context-menu-y);
1410
padding: 0.25rem 0;
11+
transform: translate(var(--p-x), var(--p-y));
12+
--p-x: 0;
13+
--p-y: 0;
1514
}
1615
[p="context-menu"]::backdrop {
1716
opacity: 0;

src/components/dropdown-menu/style.css

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22
@import "../popover/style.css";
33

44
[p="dropdown-content"] {
5-
left: 0;
6-
--tf-x: 0;
75
padding: 0.5rem 0;
8-
min-width: 100%;
6+
min-width: var(--p-anchor-width);
97
}
108

119
[p="dropdown-menu-item"],

src/components/popover/style.css

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,40 +11,65 @@
1111
[p="dropdown-content"],
1212
[p="context-menu"],
1313
[p="combobox-list"] {
14-
position: absolute;
15-
display: none;
16-
top: 100%;
17-
left: 50%;
18-
--tf-x: -50%;
19-
min-width: 100%;
20-
transform-origin: top center;
2114
background-color: hsl(var(--p-popover));
2215
color: hsl(var(--p-popover-foreground));
2316
border-radius: var(--p-radius);
2417
border: 1px solid hsl(var(--p-border));
2518
padding: 0;
26-
/* padding: 0.5rem; */
27-
margin: 0.25rem 0 0;
2819
box-shadow: 0 4px 12px rgb(0 0 0 / 0.15);
2920
pointer-events: none;
30-
/* animation: popover-show 150ms cubic-bezier(0.4,0,0.2,1); */
3121
animation: popover-show 200ms var(--p-ease);
3222
z-index: 10;
3323
outline: none;
24+
margin: 0;
25+
right: auto;
26+
bottom: auto;
27+
}
28+
29+
[p="popover-content"],
30+
[p="dropdown-content"],
31+
[p="combobox-list"] {
32+
position: fixed;
33+
display: none;
34+
left: 0;
35+
top: 0;
36+
right: auto;
37+
bottom: auto;
38+
--p-gap: 0.25rem;
39+
transform-origin: top left;
40+
--p-x: var(--p-anchor-left);
41+
--p-y: calc(var(--p-anchor-top) + var(--p-anchor-height) + var(--p-gap));
42+
transform: translate(
43+
max(0px, min(calc(100vw - var(--p-content-width, 0px)), var(--p-x))),
44+
max(0px, min(calc(100vh - var(--p-content-height, 0px)), var(--p-y)))
45+
);
46+
}
47+
48+
[p="popover-content"][data-flip~="x"],
49+
[p="dropdown-content"][data-flip~="x"],
50+
[p="combobox-list"][data-flip~="x"] {
51+
--p-x: calc(
52+
var(--p-anchor-left) + var(--p-anchor-width) - var(--p-content-width)
53+
);
54+
}
55+
56+
[p="popover-content"][data-flip~="y"],
57+
[p="dropdown-content"][data-flip~="y"],
58+
[p="combobox-list"][data-flip~="y"] {
59+
--p-y: calc(var(--p-anchor-top) - var(--p-content-height) - var(--p-gap));
3460
}
3561

3662
[p="popover-content"][open],
3763
[p="dropdown-content"][open],
38-
[p="context-menu"][open],
39-
[p="combobox-list"][open] {
64+
[p="combobox-list"][open],
65+
[p="context-menu"][open] {
4066
display: inline-block;
4167
pointer-events: auto;
42-
transform: translateX(var(--tf-x)) scale(1);
4368
}
4469

4570
@keyframes popover-show {
4671
from {
4772
opacity: 0;
48-
transform: translateX(var(--tf-x)) scale(0.9);
73+
transform: translate(var(--p-x), var(--p-y)) scale(0.9);
4974
}
5075
}

src/lib/commands.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
let commandsInstalled: boolean;
2+
const triggers = new WeakMap<Element, Element>();
23

34
export function installCommands() {
45
if (commandsInstalled) return;
56
commandsInstalled = true;
67
if ('commandFor' in HTMLButtonElement.prototype) return;
78
addEventListener('click', commandClickHandler);
9+
addEventListener('toggle', dialogToggleHandler, true);
10+
addEventListener('show', dialogToggleHandler, true);
811
}
912

1013
function elementTarget(node: EventTarget) {
@@ -13,6 +16,46 @@ function elementTarget(node: EventTarget) {
1316
: ((node as Node).parentNode as Element);
1417
}
1518

19+
function positionDialog(target: HTMLDialogElement, trigger: Element) {
20+
const a = trigger.getBoundingClientRect();
21+
const t = target.style.transform;
22+
target.style.setProperty('transform', 'none');
23+
const d = target.getBoundingClientRect();
24+
if (t) target.style.setProperty('transform', t);
25+
else target.style.removeProperty('transform');
26+
target.style.setProperty('--p-anchor-left', `${a.left}px`);
27+
target.style.setProperty('--p-anchor-top', `${a.top}px`);
28+
target.style.setProperty('--p-anchor-width', `${a.width}px`);
29+
target.style.setProperty('--p-anchor-height', `${a.height}px`);
30+
target.style.setProperty('--p-content-width', `${d.width}px`);
31+
target.style.setProperty('--p-content-height', `${d.height}px`);
32+
const flipX = a.left + d.width > innerWidth && a.left + a.width > d.width;
33+
const flipY =
34+
a.top + a.height + d.height > innerHeight && a.top > d.height;
35+
let flip = '';
36+
if (flipX) flip += ' x';
37+
if (flipY) flip += ' y';
38+
target.setAttribute('data-flip', flip);
39+
}
40+
41+
let resizeInstalled: boolean;
42+
function ensureResize() {
43+
if (resizeInstalled) return;
44+
resizeInstalled = true;
45+
addEventListener(
46+
'resize',
47+
() => {
48+
document
49+
.querySelectorAll<HTMLDialogElement>('dialog[p][open]')
50+
.forEach((el) => {
51+
const trig = triggers.get(el);
52+
if (trig) positionDialog(el, trig);
53+
});
54+
},
55+
{passive: true},
56+
);
57+
}
58+
1659
function commandClickHandler(e: MouseEvent) {
1760
const el = elementTarget(e.target!);
1861
const trigger = el.closest<Element>('[command]');
@@ -37,9 +80,19 @@ function commandClickHandler(e: MouseEvent) {
3780
}
3881

3982
const method = command.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
83+
if (target instanceof HTMLDialogElement) triggers.set(target, trigger);
4084
(target as any)[method]?.();
4185
}
4286

87+
function dialogToggleHandler(e: Event) {
88+
const target = e.target as Element;
89+
if (target instanceof HTMLDialogElement && target.open) {
90+
const trig = triggers.get(target);
91+
if (trig) positionDialog(target, trig);
92+
ensureResize();
93+
}
94+
}
95+
4396
let dialogsDropdownsInstalled: boolean;
4497
export function installDialogsDropdowns() {
4598
if (dialogsDropdownsInstalled) return;

0 commit comments

Comments
 (0)