feat: Add /home (#111)

This commit is contained in:
Wanjohi
2024-09-13 17:41:34 +03:00
committed by GitHub
parent 1b1bedff36
commit c30673f5a1
48 changed files with 4720 additions and 116 deletions

View 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';

View 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>
);
});

View 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>
);
});

View 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;
};

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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;
}
}

View 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,
};
}