mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-15 02:05:37 +02:00
✨ feat: Add /home (#111)
This commit is contained in:
13
packages/ui/src/modal/index.ts
Normal file
13
packages/ui/src/modal/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
//Copied from https://github.com/qwikifiers/qwik-ui/blob/main/packages/kit-headless/src/components/modal/index.ts
|
||||
//Why? because qwik-ui/headless requires qwik v1.7.2 as a peer dependency, which is causing build errors in the monorepo
|
||||
// Reference: https://github.com/qwikifiers/qwik-ui/blob/26c17886e9a84de9d0da09f1180ede5fdceb70f3/packages/kit-headless/package.json#L33
|
||||
|
||||
export { HModalRoot as Root } from './modal-root';
|
||||
export { HModalPanel as Panel } from './modal-panel';
|
||||
export { HModalContent as Content } from './modal-content';
|
||||
export { HModalFooter as Footer } from './modal-footer';
|
||||
export { HModalHeader as Header } from './modal-header';
|
||||
export { HModalTitle as Title } from './modal-title';
|
||||
export { HModalDescription as Description } from './modal-description';
|
||||
export { HModalTrigger as Trigger } from './modal-trigger';
|
||||
export { HModalClose as Close } from './modal-close';
|
||||
16
packages/ui/src/modal/modal-close.tsx
Normal file
16
packages/ui/src/modal/modal-close.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type PropsOf, Slot, component$, useContext, $ } from '@builder.io/qwik';
|
||||
import { modalContextId } from './modal-context';
|
||||
|
||||
export const HModalClose = component$((props: PropsOf<'button'>) => {
|
||||
const context = useContext(modalContextId);
|
||||
|
||||
const handleClick$ = $(() => {
|
||||
context.showSig.value = false;
|
||||
});
|
||||
|
||||
return (
|
||||
<button onClick$={[handleClick$, props.onClick$]} {...props}>
|
||||
<Slot />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
12
packages/ui/src/modal/modal-content.tsx
Normal file
12
packages/ui/src/modal/modal-content.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { type PropsOf, Slot, component$ } from '@builder.io/qwik';
|
||||
|
||||
/**
|
||||
* @deprecated This component is deprecated and will be removed in future releases.
|
||||
*/
|
||||
export const HModalContent = component$((props: PropsOf<'div'>) => {
|
||||
return (
|
||||
<div {...props}>
|
||||
<Slot />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
13
packages/ui/src/modal/modal-context.tsx
Normal file
13
packages/ui/src/modal/modal-context.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { type QRL, type Signal, createContextId } from '@builder.io/qwik';
|
||||
|
||||
export const modalContextId = createContextId<ModalContext>('qui-modal');
|
||||
|
||||
export type ModalContext = {
|
||||
// core state
|
||||
localId: string;
|
||||
showSig: Signal<boolean>;
|
||||
onShow$?: QRL<() => void>;
|
||||
onClose$?: QRL<() => void>;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
};
|
||||
16
packages/ui/src/modal/modal-description.tsx
Normal file
16
packages/ui/src/modal/modal-description.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type PropsOf, Slot, component$, useContext } from '@builder.io/qwik';
|
||||
import { modalContextId } from './modal-context';
|
||||
|
||||
export type ModalDescriptionProps = PropsOf<'p'>;
|
||||
|
||||
export const HModalDescription = component$((props: ModalDescriptionProps) => {
|
||||
const context = useContext(modalContextId);
|
||||
|
||||
const descriptionId = `${context.localId}-description`;
|
||||
|
||||
return (
|
||||
<p id={descriptionId} {...props}>
|
||||
<Slot />
|
||||
</p>
|
||||
);
|
||||
});
|
||||
12
packages/ui/src/modal/modal-footer.tsx
Normal file
12
packages/ui/src/modal/modal-footer.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { type PropsOf, Slot, component$ } from '@builder.io/qwik';
|
||||
|
||||
/**
|
||||
* @deprecated This component is deprecated and will be removed in future releases.
|
||||
*/
|
||||
export const HModalFooter = component$((props: PropsOf<'footer'>) => {
|
||||
return (
|
||||
<footer {...props}>
|
||||
<Slot />
|
||||
</footer>
|
||||
);
|
||||
});
|
||||
12
packages/ui/src/modal/modal-header.tsx
Normal file
12
packages/ui/src/modal/modal-header.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { type PropsOf, Slot, component$ } from '@builder.io/qwik';
|
||||
|
||||
/**
|
||||
* @deprecated This component is deprecated and will be removed in future releases.
|
||||
*/
|
||||
export const HModalHeader = component$((props: PropsOf<'header'>) => {
|
||||
return (
|
||||
<header {...props}>
|
||||
<Slot />
|
||||
</header>
|
||||
);
|
||||
});
|
||||
124
packages/ui/src/modal/modal-panel.tsx
Normal file
124
packages/ui/src/modal/modal-panel.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
$,
|
||||
type PropsOf,
|
||||
type QRL,
|
||||
type Signal,
|
||||
Slot,
|
||||
component$,
|
||||
useSignal,
|
||||
useStyles$,
|
||||
useTask$,
|
||||
sync$,
|
||||
useContext,
|
||||
} from '@builder.io/qwik';
|
||||
|
||||
import { modalContextId } from './modal-context';
|
||||
|
||||
import styles from './modal.css?inline';
|
||||
import { useModal } from './use-modal';
|
||||
|
||||
export type ModalProps = Omit<PropsOf<'dialog'>, 'open'> & {
|
||||
onShow$?: QRL<() => void>;
|
||||
onClose$?: QRL<() => void>;
|
||||
'bind:show': Signal<boolean>;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
};
|
||||
|
||||
export const HModalPanel = component$((props: PropsOf<'dialog'>) => {
|
||||
useStyles$(styles);
|
||||
const {
|
||||
activateFocusTrap,
|
||||
closeModal,
|
||||
deactivateFocusTrap,
|
||||
showModal,
|
||||
trapFocus,
|
||||
wasModalBackdropClicked,
|
||||
} = useModal();
|
||||
const context = useContext(modalContextId);
|
||||
|
||||
const panelRef = useSignal<HTMLDialogElement>();
|
||||
|
||||
useTask$(async function toggleModal({ track, cleanup }) {
|
||||
const isOpen = track(() => context.showSig.value);
|
||||
|
||||
if (!panelRef.value) return;
|
||||
|
||||
const focusTrap = await trapFocus(panelRef.value);
|
||||
|
||||
if (isOpen) {
|
||||
// HACK: keep modal scroll position in place with iOS
|
||||
const storedRequestAnimationFrame = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = () => 42;
|
||||
|
||||
await showModal(panelRef.value);
|
||||
window.requestAnimationFrame = storedRequestAnimationFrame;
|
||||
await context.onShow$?.();
|
||||
activateFocusTrap(focusTrap);
|
||||
} else {
|
||||
await closeModal(panelRef.value);
|
||||
await context.onClose$?.();
|
||||
}
|
||||
|
||||
cleanup(async () => {
|
||||
await deactivateFocusTrap(focusTrap);
|
||||
});
|
||||
});
|
||||
|
||||
const closeOnBackdropClick$ = $(async (e: MouseEvent) => {
|
||||
if (context.alert === true || context.closeOnBackdropClick === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We do not want to close elements that dangle outside of the modal
|
||||
if (!(e.target instanceof HTMLDialogElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await wasModalBackdropClicked(panelRef.value, e)) {
|
||||
context.showSig.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyDownSync$ = sync$((e: KeyboardEvent) => {
|
||||
const keys = [' ', 'Enter'];
|
||||
|
||||
if (e.target instanceof HTMLDialogElement && keys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyDown$ = $((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
context.showSig.value = false;
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<dialog
|
||||
{...props}
|
||||
id={`${context.localId}-root`}
|
||||
aria-labelledby={`${context.localId}-title`}
|
||||
aria-describedby={`${context.localId}-description`}
|
||||
// TODO: deprecate data-state in favor of data-open, data-closing, and data-closed
|
||||
data-state={context.showSig.value ? 'open' : 'closed'}
|
||||
data-open={context.showSig.value && ''}
|
||||
data-closed={!context.showSig.value && ''}
|
||||
role={context.alert === true ? 'alertdialog' : 'dialog'}
|
||||
ref={panelRef}
|
||||
// key={renderKey.value}
|
||||
onKeyDown$={[handleKeyDownSync$, handleKeyDown$, props.onKeyDown$]}
|
||||
onClick$={async (e) => {
|
||||
e.stopPropagation();
|
||||
await closeOnBackdropClick$(e);
|
||||
}}
|
||||
>
|
||||
<Slot />
|
||||
</dialog>
|
||||
);
|
||||
});
|
||||
51
packages/ui/src/modal/modal-root.tsx
Normal file
51
packages/ui/src/modal/modal-root.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
type PropsOf,
|
||||
type QRL,
|
||||
type Signal,
|
||||
Slot,
|
||||
component$,
|
||||
useContextProvider,
|
||||
useId,
|
||||
useSignal,
|
||||
} from '@builder.io/qwik';
|
||||
import { type ModalContext, modalContextId } from './modal-context';
|
||||
|
||||
type ModalRootProps = {
|
||||
onShow$?: QRL<() => void>;
|
||||
onClose$?: QRL<() => void>;
|
||||
'bind:show'?: Signal<boolean>;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
} & PropsOf<'div'>;
|
||||
|
||||
export const HModalRoot = component$((props: ModalRootProps) => {
|
||||
const localId = useId();
|
||||
|
||||
const {
|
||||
'bind:show': givenShowSig,
|
||||
closeOnBackdropClick,
|
||||
onShow$,
|
||||
onClose$,
|
||||
alert,
|
||||
} = props;
|
||||
|
||||
const defaultShowSig = useSignal<boolean>(false);
|
||||
const showSig = givenShowSig ?? defaultShowSig;
|
||||
|
||||
const context: ModalContext = {
|
||||
localId,
|
||||
showSig,
|
||||
closeOnBackdropClick,
|
||||
onShow$,
|
||||
onClose$,
|
||||
alert,
|
||||
};
|
||||
|
||||
useContextProvider(modalContextId, context);
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<Slot />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
16
packages/ui/src/modal/modal-title.tsx
Normal file
16
packages/ui/src/modal/modal-title.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type PropsOf, Slot, component$, useContext } from '@builder.io/qwik';
|
||||
import { modalContextId } from './modal-context';
|
||||
|
||||
export type ModalTitleProps = PropsOf<'h2'>;
|
||||
|
||||
export const HModalTitle = component$((props: ModalTitleProps) => {
|
||||
const context = useContext(modalContextId);
|
||||
|
||||
const titleId = `${context.localId}-title`;
|
||||
|
||||
return (
|
||||
<h2 id={titleId} {...props}>
|
||||
<Slot />
|
||||
</h2>
|
||||
);
|
||||
});
|
||||
23
packages/ui/src/modal/modal-trigger.tsx
Normal file
23
packages/ui/src/modal/modal-trigger.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type PropsOf, Slot, component$, useContext, $ } from '@builder.io/qwik';
|
||||
import { modalContextId } from './modal-context';
|
||||
|
||||
export const HModalTrigger = component$((props: PropsOf<'button'>) => {
|
||||
const context = useContext(modalContextId);
|
||||
|
||||
const handleClick$ = $(() => {
|
||||
context.showSig.value = !context.showSig.value;
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={context.showSig.value}
|
||||
data-open={context.showSig.value ? '' : undefined}
|
||||
data-closed={!context.showSig.value ? '' : undefined}
|
||||
onClick$={[handleClick$, props.onClick$]}
|
||||
{...props}
|
||||
>
|
||||
<Slot />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
9
packages/ui/src/modal/modal.css
Normal file
9
packages/ui/src/modal/modal.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@layer qwik-ui {
|
||||
/* browsers automatically set an interesting max-width and max-height for dialogs
|
||||
https://twitter.com/t3dotgg/status/1774350919133691936
|
||||
*/
|
||||
dialog:modal {
|
||||
max-width: unset;
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
134
packages/ui/src/modal/use-modal.tsx
Normal file
134
packages/ui/src/modal/use-modal.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { $ } from '@builder.io/qwik';
|
||||
import { type FocusTrap, createFocusTrap } from 'focus-trap';
|
||||
import { enableBodyScroll, disableBodyScroll } from 'body-scroll-lock-upgrade';
|
||||
|
||||
export type WidthState = {
|
||||
width: number | null;
|
||||
};
|
||||
|
||||
|
||||
export function useModal() {
|
||||
/**
|
||||
* Listens for animation/transition events in order to
|
||||
* remove Animation-CSS-Classes after animation/transition ended.
|
||||
*/
|
||||
const supportClosingAnimation = $((modal: HTMLDialogElement) => {
|
||||
modal.dataset.closing = '';
|
||||
modal.classList.add('modal-closing');
|
||||
|
||||
const { animationDuration, transitionDuration } = getComputedStyle(modal);
|
||||
|
||||
if (animationDuration !== '0s') {
|
||||
modal.addEventListener(
|
||||
'animationend',
|
||||
(e) => {
|
||||
if (e.target === modal) {
|
||||
delete modal.dataset.closing;
|
||||
modal.classList.remove('modal-closing');
|
||||
enableBodyScroll(modal);
|
||||
modal.close();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else if (transitionDuration !== '0s') {
|
||||
modal.addEventListener(
|
||||
'transitionend',
|
||||
(e) => {
|
||||
if (e.target === modal) {
|
||||
delete modal.dataset.closing;
|
||||
modal.classList.remove('modal-closing');
|
||||
enableBodyScroll(modal);
|
||||
modal.close();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else if (animationDuration === '0s' && transitionDuration === '0s') {
|
||||
delete modal.dataset.closing;
|
||||
modal.classList.remove('modal-closing');
|
||||
enableBodyScroll(modal);
|
||||
modal.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Traps the focus of the given Modal
|
||||
* @returns FocusTrap
|
||||
*/
|
||||
const trapFocus = $((modal: HTMLDialogElement): FocusTrap => {
|
||||
return createFocusTrap(modal, { escapeDeactivates: false });
|
||||
});
|
||||
|
||||
const activateFocusTrap = $((focusTrap: FocusTrap | null) => {
|
||||
try {
|
||||
focusTrap?.activate();
|
||||
} catch {
|
||||
// Activating the focus trap throws if no tabbable elements are inside the container.
|
||||
// If this is the case we are fine with not activating the focus trap.
|
||||
// That's why we ignore the thrown error.
|
||||
}
|
||||
});
|
||||
|
||||
const deactivateFocusTrap = $((focusTrap: FocusTrap | null) => {
|
||||
focusTrap?.deactivate();
|
||||
focusTrap = null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows the given Modal.
|
||||
* Applies a CSS-Class to animate the modal-showing.
|
||||
* Calls the given callback that is executed after the Modal has been opened.
|
||||
*/
|
||||
const showModal = $(async (modal: HTMLDialogElement) => {
|
||||
disableBodyScroll(modal, { reserveScrollBarGap: true });
|
||||
modal.showModal();
|
||||
});
|
||||
|
||||
/**
|
||||
* Closes the given Modal.
|
||||
* Applies a CSS-Class to animate the Modal-closing.
|
||||
* Calls the given callback that is executed after the Modal has been closed.
|
||||
*/
|
||||
const closeModal = $(async (modal: HTMLDialogElement) => {
|
||||
await supportClosingAnimation(modal);
|
||||
});
|
||||
|
||||
/**
|
||||
* Determines if the backdrop of the Modal has been clicked.
|
||||
*/
|
||||
const wasModalBackdropClicked = $(
|
||||
(modal: HTMLDialogElement | undefined, clickEvent: MouseEvent): boolean => {
|
||||
if (!modal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = modal.getBoundingClientRect();
|
||||
|
||||
const wasBackdropClicked =
|
||||
rect.left > clickEvent.clientX ||
|
||||
rect.right < clickEvent.clientX ||
|
||||
rect.top > clickEvent.clientY ||
|
||||
rect.bottom < clickEvent.clientY;
|
||||
|
||||
/**
|
||||
* If the inside focusable elements are not prevented, such as a button it will also fire a click event.
|
||||
*
|
||||
* Hitting the enter or space keys on a button inside of the dialog for example, will fire a "pointer" event. In reality, it fires our onClick$ handler because we have not prevented the default behavior.
|
||||
*
|
||||
* This is why we check if the pointerId is -1.
|
||||
**/
|
||||
return (clickEvent as PointerEvent).pointerId === -1 ? false : wasBackdropClicked;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
trapFocus,
|
||||
activateFocusTrap,
|
||||
deactivateFocusTrap,
|
||||
showModal,
|
||||
closeModal,
|
||||
wasModalBackdropClicked,
|
||||
supportClosingAnimation,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user