Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5c4185f
feat: initial modal conversion
thejackshelton-kunaico Aug 17, 2025
7fdafaf
feat: iterate on tests
thejackshelton-kunaico Aug 17, 2025
313639f
feat: docs example
thejackshelton-kunaico Aug 17, 2025
6035d22
fix: browser mode syntax
thejackshelton-kunaico Aug 17, 2025
f2842b1
refactor: improve naming remove cruft
thejackshelton-kunaico Aug 17, 2025
5cb9b02
feat: correct backdrop click with dragging behavior
thejackshelton-kunaico Aug 17, 2025
4f675b3
feat: backdrop handling
thejackshelton-kunaico Aug 17, 2025
a202fb0
pointer events
thejackshelton-kunaico Aug 22, 2025
bf136bc
feat: add pkg pr new for iteration
thejackshelton-kunaico Aug 22, 2025
43cc6a7
update pkg manager field
thejackshelton-kunaico Aug 22, 2025
37f6b59
Merge branch 'v2-migration' into v2-modal
thejackshelton-kunaico Sep 9, 2025
6d53600
fix: merge conflict
thejackshelton-kunaico Sep 15, 2025
d743345
Merge branch 'v2-modal' of github.com:kunai-consulting/qwik-design-sy…
thejackshelton-kunaico Sep 15, 2025
a89f3a0
feat: improved pointer handling
thejackshelton-kunaico Sep 15, 2025
5c413e0
fix: overflow handling
thejackshelton-kunaico Sep 15, 2025
3a2db54
fix: handle nested structures
thejackshelton-kunaico Sep 15, 2025
fde27eb
fix: correctly handling nested state
thejackshelton-kunaico Sep 15, 2025
64a7c0e
test: almost all tests passing
thejackshelton-kunaico Sep 15, 2025
26e9258
vitest compatible wait
thejackshelton-kunaico Sep 15, 2025
0951e0a
feat: next tick primitive
thejackshelton-kunaico Sep 16, 2025
1c2c9b4
fix: last test
thejackshelton-kunaico Sep 16, 2025
cac2e5f
feat: description and title
thejackshelton-kunaico Sep 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/pkg-pr-new.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Publish Preview Package

on: [push, pull_request]

jobs:
build-and-publish:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Enable Corepack
run: corepack enable

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"

- name: Install dependencies
run: pnpm install

- name: Build package
run: pnpm run build

- name: Publish preview packages
run: pnpm dlx pkg-pr-new publish './libs/*'
11 changes: 11 additions & 0 deletions apps/docs/src/routes/base/modal/examples/basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Modal } from "@kunai-consulting/qwik";
import { component$ } from "@qwik.dev/core";

export default component$(() => {
return (
<Modal.Root>
<Modal.Trigger>Open Modal</Modal.Trigger>
<Modal.Content>Some content</Modal.Content>
</Modal.Root>
);
});
28 changes: 28 additions & 0 deletions apps/docs/src/routes/base/modal/examples/nested.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Modal } from "@kunai-consulting/qwik";
import { component$, useSignal } from "@qwik.dev/core";

export default component$(() => {
const nestedOpen = useSignal(false);

return (
<Modal.Root data-testid="root">
<Modal.Trigger data-testid="trigger">Open Modal</Modal.Trigger>
<Modal.Content data-testid="content">
<Modal.Title data-testid="title">First Modal</Modal.Title>
<p>This is the first modal.</p>

{/* Nested Modal */}
<Modal.Root bind:open={nestedOpen}>
<Modal.Trigger>Nested Modal Trigger</Modal.Trigger>
<Modal.Content>
<Modal.Title>Nested Modal Title</Modal.Title>
<p>Nested Modal Content</p>
<Modal.Close>Close Nested</Modal.Close>
</Modal.Content>
</Modal.Root>

<Modal.Close data-testid="close">Close</Modal.Close>
</Modal.Content>
</Modal.Root>
);
});
8 changes: 8 additions & 0 deletions apps/docs/src/routes/base/modal/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Basic from "./examples/basic";
import Nested from "./examples/nested";

# Modal

<Basic />

<Nested />
3 changes: 2 additions & 1 deletion libs/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"vite": "^7",
"vite-tsconfig-paths": "^4.2.1",
"@qwik.dev/core": "2.0.0-beta.9",
"vitest-browser-qwik": "https://pkg.pr.new/kunai-consulting/vitest-browser-qwik@949e6ac"
"vitest-browser-qwik": "https://pkg.pr.new/kunai-consulting/vitest-browser-qwik@949e6ac",
"@fluejs/noscroll": "^1.0.0"
},
"dependencies": {
"@oddbird/css-anchor-positioning": "^0.6"
Expand Down
1 change: 1 addition & 0 deletions libs/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * as Tabs from "./tabs";
export * as Toggle from "./toggle";
export * as Menu from "./menu";
export * as Progress from "./progress";
export * as Modal from "./modal";

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

Expand Down
6 changes: 6 additions & 0 deletions libs/components/src/modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { ModalRoot as Root } from "./modal-root";
export { ModalContent as Content } from "./modal-content";
export { ModalTitle as Title } from "./modal-title";
export { ModalDescription as Description } from "./modal-description";
export { ModalClose as Close } from "./modal-close";
export { ModalTrigger as Trigger } from "./modal-trigger";
22 changes: 22 additions & 0 deletions libs/components/src/modal/modal-close.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { $, type PropsOf, Slot, component$, useContext } from "@qwik.dev/core";
import { Render } from "../render/render";
import { modalContextId } from "./modal-root";

export const ModalClose = component$((props: PropsOf<"button">) => {
const context = useContext(modalContextId);

const handleClose$ = $(() => {
context.isOpen.value = false;
});

return (
<Render
type="button"
fallback="button"
onClick$={[handleClose$, props.onClick$]}
{...props}
>
<Slot />
</Render>
);
});
85 changes: 85 additions & 0 deletions libs/components/src/modal/modal-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { $, type PropsOf, Slot, component$, useContext, useSignal } from "@qwik.dev/core";
import { modalContextId } from "./modal-root";

export const ModalContent = component$((props: PropsOf<"dialog">) => {
const context = useContext(modalContextId);
const isDownOnBackdrop = useSignal(false);
const descriptionId = `${context.localId}-description`;
const titleId = `${context.localId}-title`;

/**
* Determines if the backdrop of the Modal has been clicked.
*/
const isBackdropEvent$ = $(
(dialogEl: HTMLDialogElement | undefined, event: PointerEvent): boolean => {
if (!dialogEl) return false;
if (event.pointerId === -1) return false;

const modal = dialogEl.getBoundingClientRect();

const { clientX: x, clientY: y } = event;

const isInsideModal =
x >= modal.left && x <= modal.right && y >= modal.top && y <= modal.bottom;

if (isInsideModal) {
return false;
}

return true;
}
);

const handleBackdropDown$ = $(async (e: PointerEvent) => {
e.stopPropagation();
if (!context.contentRef.value) {
isDownOnBackdrop.value = false;
return;
}
isDownOnBackdrop.value = await isBackdropEvent$(context.contentRef.value, e);
});

const handleBackdropSlide$ = $(async (e: PointerEvent) => {
e.stopPropagation();
if (!isDownOnBackdrop.value) {
isDownOnBackdrop.value = false;
return;
}

if (!context.closeOnOutsideClick) {
isDownOnBackdrop.value = false;
return;
}

if (!context.contentRef.value) {
isDownOnBackdrop.value = false;
return;
}

const isBackdrop = await isBackdropEvent$(context.contentRef.value, e);

if (isBackdrop) {
context.isOpen.value = false;
}

isDownOnBackdrop.value = false;
});

const handleClose$ = $(() => {
context.isOpen.value = false;
});

return (
<dialog
{...props}
ref={context.contentRef}
onPointerDown$={[handleBackdropDown$, props.onPointerDown$]}
onPointerUp$={[handleBackdropSlide$, props.onPointerUp$]}
onClose$={[handleClose$, props.onClose$]}
aria-labelledby={context.isTitle.value ? titleId : undefined}
aria-describedby={context.isDescription.value ? descriptionId : undefined}
>
<Slot />
</dialog>
);
});
18 changes: 18 additions & 0 deletions libs/components/src/modal/modal-description.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { type PropsOf, Slot, component$, useConstant, useContext } from "@qwik.dev/core";
import { Render } from "../render/render";
import { modalContextId } from "./modal-root";

export const ModalDescription = component$((props: PropsOf<"div">) => {
const context = useContext(modalContextId);
const descriptionId = `${context.localId}-description`;

useConstant(() => {
context.isDescription.value = true;
});

return (
<Render fallback="div" {...props} id={descriptionId}>
<Slot />
</Render>
);
});
107 changes: 107 additions & 0 deletions libs/components/src/modal/modal-root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { createNoScroll, markScrollable } from "@fluejs/noscroll";
import { initTouchHandler, resetTouchHandler } from "@fluejs/noscroll/touch";
import { type BindableProps, useBindings } from "@kunai-consulting/qwik-utils";
import {
type PropsOf,
type Signal,
Slot,
component$,
createContextId,
noSerialize,
useConstant,
useContext,
useContextProvider,
useId,
useSignal,
useTask$
} from "@qwik.dev/core";
import { Render } from "../render/render";

export const modalContextId = createContextId<ModalContext>("qds-modal");

type ModalContext = {
contentRef: Signal<HTMLDialogElement | undefined>;
isOpen: Signal<boolean>;
closeOnOutsideClick: boolean;
level: number;
isTitle: Signal<boolean>;
isDescription: Signal<boolean>;
localId: string;
};

type ModalRootProps = PropsOf<"div"> &
BindableProps<{
open: boolean;
}> & {
closeOnOutsideClick?: boolean;
};

export const ModalRoot = component$((props: ModalRootProps) => {
const contentRef = useSignal<HTMLDialogElement | undefined>();
const isInitialized = useSignal(false);
const disablePageScrollFn = useSignal<() => void>();
const enablePageScrollFn = useSignal<() => void>();
const isTitle = useSignal(false);
const isDescription = useSignal(false);
const localId = useId();

// handling nested state
const parentContext = useContext(modalContextId, null);
const level = useConstant(() => {
return (parentContext?.level ?? 0) + 1;
});

const { closeOnOutsideClick = true, ...restProps } = props;

const { openSig: isOpen } = useBindings(restProps, {
open: false
});

useTask$(({ track, cleanup }) => {
track(isOpen);

if (!isInitialized.value) {
if (!contentRef.value) return;
markScrollable(contentRef.value);
isInitialized.value = true;

const { disablePageScroll, enablePageScroll } = createNoScroll({
onInitScrollDisable: initTouchHandler,
onResetScrollDisable: resetTouchHandler
});

disablePageScrollFn.value = noSerialize(disablePageScroll);
enablePageScrollFn.value = noSerialize(enablePageScroll);
}

if (isOpen.value) {
contentRef.value?.showModal();
disablePageScrollFn.value?.();
} else {
contentRef.value?.close();
}

cleanup(() => {
if (level > 1) return;
enablePageScrollFn.value?.();
});
});

const context: ModalContext = {
contentRef,
isOpen,
closeOnOutsideClick,
level,
isTitle,
isDescription,
localId
};

useContextProvider(modalContextId, context);

return (
<Render fallback="div" {...restProps}>
<Slot />
</Render>
);
});
18 changes: 18 additions & 0 deletions libs/components/src/modal/modal-title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { type PropsOf, Slot, component$, useConstant, useContext } from "@qwik.dev/core";
import { Render } from "../render/render";
import { modalContextId } from "./modal-root";

export const ModalTitle = component$((props: PropsOf<"div">) => {
const context = useContext(modalContextId);
const titleId = `${context.localId}-title`;

useConstant(() => {
context.isTitle.value = true;
});

return (
<Render fallback="div" {...props} id={titleId}>
<Slot />
</Render>
);
});
17 changes: 17 additions & 0 deletions libs/components/src/modal/modal-trigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { $, type PropsOf, Slot, component$, useContext } from "@qwik.dev/core";
import { Render } from "../render/render";
import { modalContextId } from "./modal-root";

export const ModalTrigger = component$((props: PropsOf<"button">) => {
const context = useContext(modalContextId);

const handleToggle$ = $(() => {
context.isOpen.value = !context.isOpen.value;
});

return (
<Render fallback="button" onClick$={[handleToggle$, props.onClick$]} {...props}>
<Slot />
</Render>
);
});
Loading
Loading