Skip to content

Commit 0b5eefb

Browse files
V2 modal (#255)
* feat: initial modal conversion * feat: iterate on tests * feat: docs example * fix: browser mode syntax * refactor: improve naming remove cruft * feat: correct backdrop click with dragging behavior * feat: backdrop handling * pointer events * feat: add pkg pr new for iteration * update pkg manager field * feat: improved pointer handling * fix: overflow handling * fix: handle nested structures * fix: correctly handling nested state * test: almost all tests passing * vitest compatible wait * feat: next tick primitive * fix: last test * feat: description and title
1 parent fbe8c42 commit 0b5eefb

File tree

20 files changed

+2907
-1809
lines changed

20 files changed

+2907
-1809
lines changed

.github/workflows/pkg-pr-new.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Publish Preview Package
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
build-and-publish:
7+
runs-on: ubuntu-latest
8+
9+
steps:
10+
- name: Checkout code
11+
uses: actions/checkout@v4
12+
13+
- name: Enable Corepack
14+
run: corepack enable
15+
16+
- name: Setup Node.js
17+
uses: actions/setup-node@v4
18+
with:
19+
node-version: 20
20+
cache: "pnpm"
21+
22+
- name: Install dependencies
23+
run: pnpm install
24+
25+
- name: Build package
26+
run: pnpm run build
27+
28+
- name: Publish preview packages
29+
run: pnpm dlx pkg-pr-new publish './libs/*'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Modal } from "@kunai-consulting/qwik";
2+
import { component$ } from "@qwik.dev/core";
3+
4+
export default component$(() => {
5+
return (
6+
<Modal.Root>
7+
<Modal.Trigger>Open Modal</Modal.Trigger>
8+
<Modal.Content>Some content</Modal.Content>
9+
</Modal.Root>
10+
);
11+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Modal } from "@kunai-consulting/qwik";
2+
import { component$, useSignal } from "@qwik.dev/core";
3+
4+
export default component$(() => {
5+
const nestedOpen = useSignal(false);
6+
7+
return (
8+
<Modal.Root data-testid="root">
9+
<Modal.Trigger data-testid="trigger">Open Modal</Modal.Trigger>
10+
<Modal.Content data-testid="content">
11+
<Modal.Title data-testid="title">First Modal</Modal.Title>
12+
<p>This is the first modal.</p>
13+
14+
{/* Nested Modal */}
15+
<Modal.Root bind:open={nestedOpen}>
16+
<Modal.Trigger>Nested Modal Trigger</Modal.Trigger>
17+
<Modal.Content>
18+
<Modal.Title>Nested Modal Title</Modal.Title>
19+
<p>Nested Modal Content</p>
20+
<Modal.Close>Close Nested</Modal.Close>
21+
</Modal.Content>
22+
</Modal.Root>
23+
24+
<Modal.Close data-testid="close">Close</Modal.Close>
25+
</Modal.Content>
26+
</Modal.Root>
27+
);
28+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Basic from "./examples/basic";
2+
import Nested from "./examples/nested";
3+
4+
# Modal
5+
6+
<Basic />
7+
8+
<Nested />

libs/components/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"vite": "^7",
3232
"vite-tsconfig-paths": "^4.2.1",
3333
"@qwik.dev/core": "2.0.0-beta.9",
34-
"vitest-browser-qwik": "https://pkg.pr.new/kunai-consulting/vitest-browser-qwik@949e6ac"
34+
"vitest-browser-qwik": "https://pkg.pr.new/kunai-consulting/vitest-browser-qwik@949e6ac",
35+
"@fluejs/noscroll": "^1.0.0"
3536
},
3637
"dependencies": {
3738
"@oddbird/css-anchor-positioning": "^0.6"

libs/components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * as Tabs from "./tabs";
1717
export * as Toggle from "./toggle";
1818
export * as Menu from "./menu";
1919
export * as Progress from "./progress";
20+
export * as Modal from "./modal";
2021

2122
export { Render } from "./render/render";
2223

libs/components/src/modal/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { ModalRoot as Root } from "./modal-root";
2+
export { ModalContent as Content } from "./modal-content";
3+
export { ModalTitle as Title } from "./modal-title";
4+
export { ModalDescription as Description } from "./modal-description";
5+
export { ModalClose as Close } from "./modal-close";
6+
export { ModalTrigger as Trigger } from "./modal-trigger";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { $, type PropsOf, Slot, component$, useContext } from "@qwik.dev/core";
2+
import { Render } from "../render/render";
3+
import { modalContextId } from "./modal-root";
4+
5+
export const ModalClose = component$((props: PropsOf<"button">) => {
6+
const context = useContext(modalContextId);
7+
8+
const handleClose$ = $(() => {
9+
context.isOpen.value = false;
10+
});
11+
12+
return (
13+
<Render
14+
type="button"
15+
fallback="button"
16+
onClick$={[handleClose$, props.onClick$]}
17+
{...props}
18+
>
19+
<Slot />
20+
</Render>
21+
);
22+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { $, type PropsOf, Slot, component$, useContext, useSignal } from "@qwik.dev/core";
2+
import { modalContextId } from "./modal-root";
3+
4+
export const ModalContent = component$((props: PropsOf<"dialog">) => {
5+
const context = useContext(modalContextId);
6+
const isDownOnBackdrop = useSignal(false);
7+
const descriptionId = `${context.localId}-description`;
8+
const titleId = `${context.localId}-title`;
9+
10+
/**
11+
* Determines if the backdrop of the Modal has been clicked.
12+
*/
13+
const isBackdropEvent$ = $(
14+
(dialogEl: HTMLDialogElement | undefined, event: PointerEvent): boolean => {
15+
if (!dialogEl) return false;
16+
if (event.pointerId === -1) return false;
17+
18+
const modal = dialogEl.getBoundingClientRect();
19+
20+
const { clientX: x, clientY: y } = event;
21+
22+
const isInsideModal =
23+
x >= modal.left && x <= modal.right && y >= modal.top && y <= modal.bottom;
24+
25+
if (isInsideModal) {
26+
return false;
27+
}
28+
29+
return true;
30+
}
31+
);
32+
33+
const handleBackdropDown$ = $(async (e: PointerEvent) => {
34+
e.stopPropagation();
35+
if (!context.contentRef.value) {
36+
isDownOnBackdrop.value = false;
37+
return;
38+
}
39+
isDownOnBackdrop.value = await isBackdropEvent$(context.contentRef.value, e);
40+
});
41+
42+
const handleBackdropSlide$ = $(async (e: PointerEvent) => {
43+
e.stopPropagation();
44+
if (!isDownOnBackdrop.value) {
45+
isDownOnBackdrop.value = false;
46+
return;
47+
}
48+
49+
if (!context.closeOnOutsideClick) {
50+
isDownOnBackdrop.value = false;
51+
return;
52+
}
53+
54+
if (!context.contentRef.value) {
55+
isDownOnBackdrop.value = false;
56+
return;
57+
}
58+
59+
const isBackdrop = await isBackdropEvent$(context.contentRef.value, e);
60+
61+
if (isBackdrop) {
62+
context.isOpen.value = false;
63+
}
64+
65+
isDownOnBackdrop.value = false;
66+
});
67+
68+
const handleClose$ = $(() => {
69+
context.isOpen.value = false;
70+
});
71+
72+
return (
73+
<dialog
74+
{...props}
75+
ref={context.contentRef}
76+
onPointerDown$={[handleBackdropDown$, props.onPointerDown$]}
77+
onPointerUp$={[handleBackdropSlide$, props.onPointerUp$]}
78+
onClose$={[handleClose$, props.onClose$]}
79+
aria-labelledby={context.isTitle.value ? titleId : undefined}
80+
aria-describedby={context.isDescription.value ? descriptionId : undefined}
81+
>
82+
<Slot />
83+
</dialog>
84+
);
85+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { type PropsOf, Slot, component$, useConstant, useContext } from "@qwik.dev/core";
2+
import { Render } from "../render/render";
3+
import { modalContextId } from "./modal-root";
4+
5+
export const ModalDescription = component$((props: PropsOf<"div">) => {
6+
const context = useContext(modalContextId);
7+
const descriptionId = `${context.localId}-description`;
8+
9+
useConstant(() => {
10+
context.isDescription.value = true;
11+
});
12+
13+
return (
14+
<Render fallback="div" {...props} id={descriptionId}>
15+
<Slot />
16+
</Render>
17+
);
18+
});

0 commit comments

Comments
 (0)