mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
⭐ feat(www): Finish up on the onboarding (#210)
Merging this prematurely to make sure the team is on the same boat... like dang! We need to find a better way to do this. Plus it has become too big
This commit is contained in:
97
packages/www/src/ui/avatar.tsx
Normal file
97
packages/www/src/ui/avatar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
const DEFAULT_COLORS = ['#6A5ACD', '#E63525', '#20B2AA', '#E87D58'];
|
||||
|
||||
const getModulo = (value: number, divisor: number, useEvenCheck?: number) => {
|
||||
const remainder = value % divisor;
|
||||
if (useEvenCheck && Math.floor(value / Math.pow(10, useEvenCheck) % 10) % 2 === 0) {
|
||||
return -remainder;
|
||||
}
|
||||
return remainder;
|
||||
};
|
||||
|
||||
const generateColors = (name: string, colors = DEFAULT_COLORS) => {
|
||||
const hashCode = name.split('').reduce((acc, char) => {
|
||||
acc = ((acc << 5) - acc) + char.charCodeAt(0);
|
||||
return acc & acc;
|
||||
}, 0);
|
||||
|
||||
const hash = Math.abs(hashCode);
|
||||
const numColors = colors.length;
|
||||
|
||||
return Array.from({ length: 3 }, (_, index) => ({
|
||||
color: colors[(hash + index) % numColors],
|
||||
translateX: getModulo(hash * (index + 1), 4, 1),
|
||||
translateY: getModulo(hash * (index + 1), 4, 2),
|
||||
scale: 1.2 + getModulo(hash * (index + 1), 2) / 10,
|
||||
rotate: getModulo(hash * (index + 1), 360, 1)
|
||||
}));
|
||||
};
|
||||
type Props = {
|
||||
name: string;
|
||||
size?: number;
|
||||
class?: string;
|
||||
colors?: string[]
|
||||
}
|
||||
|
||||
export default function Avatar({ class: className, name, size = 80, colors = DEFAULT_COLORS }: Props) {
|
||||
const colorData = generateColors(name, colors);
|
||||
|
||||
const blurValue = Math.max(1, Math.min(7, size / 10));
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
role="img"
|
||||
class={className}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-describedby={name}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<title id={name}>{`Fallback avatar for ${name}`}</title>
|
||||
<mask
|
||||
id="mask__marble"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x={0}
|
||||
y={0}
|
||||
width={80}
|
||||
height={80}
|
||||
>
|
||||
<rect width={80} height={80} rx={160} fill="#FFFFFF" />
|
||||
</mask>
|
||||
<g mask="url(#mask__marble)">
|
||||
<rect width={80} height={80} rx={160} fill={colorData[0].color} />
|
||||
<path
|
||||
filter="url(#prefix__filter0_f)"
|
||||
d="M32.414 59.35L50.376 70.5H72.5v-71H33.728L26.5 13.381l19.057 27.08L32.414 59.35z"
|
||||
fill={colorData[1].color}
|
||||
transform={
|
||||
`translate(${colorData[1].translateX} ${colorData[1].translateY})
|
||||
rotate(${colorData[1].rotate} 40 40)
|
||||
scale(${colorData[1].scale})`}
|
||||
/>
|
||||
<path
|
||||
filter="url(#prefix__filter0_f)"
|
||||
style={{ "mix-blend-mode": "overlay" }}
|
||||
d="M22.216 24L0 46.75l14.108 38.129L78 86l-3.081-59.276-22.378 4.005 12.972 20.186-23.35 27.395L22.215 24z"
|
||||
fill={colorData[2].color}
|
||||
transform={
|
||||
`translate(${colorData[2].translateX} ${colorData[2].translateY})
|
||||
rotate(${colorData[2].rotate} 40 40)
|
||||
scale(${colorData[2].scale})`}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="prefix__filter0_f"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="s-rGB"
|
||||
>
|
||||
<feFlood flood-opacity={0} result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation={blurValue} result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const Button = styled("button", {
|
||||
lineHeight: "normal",
|
||||
fontFamily: theme.font.family.heading,
|
||||
textAlign: "center",
|
||||
cursor: "pointer",
|
||||
transitionDelay: "0s, 0s",
|
||||
transitionDuration: "0.2s, 0.2s",
|
||||
transitionProperty: "background-color, border",
|
||||
|
||||
160
packages/www/src/ui/custom-qr.tsx
Normal file
160
packages/www/src/ui/custom-qr.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import QRCodeUtil from 'qrcode';
|
||||
import { createMemo, JSXElement } from "solid-js"
|
||||
|
||||
const generateMatrix = (
|
||||
value: string,
|
||||
errorCorrectionLevel: QRCodeUtil.QRCodeErrorCorrectionLevel
|
||||
) => {
|
||||
const arr = Array.prototype.slice.call(
|
||||
QRCodeUtil.create(value, { errorCorrectionLevel }).modules.data,
|
||||
0
|
||||
);
|
||||
const sqrt = Math.sqrt(arr.length);
|
||||
return arr.reduce(
|
||||
(rows, key, index) =>
|
||||
(index % sqrt === 0
|
||||
? rows.push([key])
|
||||
: rows[rows.length - 1].push(key)) && rows,
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ecl?: QRCodeUtil.QRCodeErrorCorrectionLevel;
|
||||
size?: number;
|
||||
uri: string;
|
||||
clearArea?: boolean;
|
||||
image?: HTMLImageElement;
|
||||
imageBackground?: string;
|
||||
};
|
||||
|
||||
export function QRCode({
|
||||
ecl = 'M',
|
||||
size: sizeProp = 200,
|
||||
uri,
|
||||
clearArea = false,
|
||||
image,
|
||||
imageBackground = 'transparent',
|
||||
}: Props) {
|
||||
const logoSize = clearArea ? 32 : 0;
|
||||
const size = sizeProp - 10 * 2;
|
||||
|
||||
const dots = createMemo(() => {
|
||||
const dots: JSXElement[] = [];
|
||||
const matrix = generateMatrix(uri, ecl);
|
||||
const cellSize = size / matrix.length;
|
||||
let qrList = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
];
|
||||
|
||||
qrList.forEach(({ x, y }) => {
|
||||
const x1 = (matrix.length - 7) * cellSize * x;
|
||||
const y1 = (matrix.length - 7) * cellSize * y;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
dots.push(
|
||||
<rect
|
||||
id={`${i}-${x}-${y}`}
|
||||
fill={
|
||||
i % 2 !== 0
|
||||
? 'var(--nestri-qr-background, var(--nestri-body-background))'
|
||||
: 'var(--nestri-qr-dot-color)'
|
||||
}
|
||||
rx={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||
ry={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||
width={cellSize * (7 - i * 2)}
|
||||
height={cellSize * (7 - i * 2)}
|
||||
x={x1 + cellSize * i}
|
||||
y={y1 + cellSize * i}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (image) {
|
||||
const x1 = (matrix.length - 7) * cellSize * 1;
|
||||
const y1 = (matrix.length - 7) * cellSize * 1;
|
||||
dots.push(
|
||||
<>
|
||||
<rect
|
||||
fill={imageBackground}
|
||||
rx={(0 - 2) * -5 + 2}
|
||||
ry={(0 - 2) * -5 + 2}
|
||||
width={cellSize * (7 - 0 * 2)}
|
||||
height={cellSize * (7 - 0 * 2)}
|
||||
x={x1 + cellSize * 0}
|
||||
y={y1 + cellSize * 0}
|
||||
/>
|
||||
<foreignObject
|
||||
width={cellSize * (7 - 0 * 2)}
|
||||
height={cellSize * (7 - 0 * 2)}
|
||||
x={x1 + cellSize * 0}
|
||||
y={y1 + cellSize * 0}
|
||||
>
|
||||
<div style={{ "border-radius": `${(0 - 2) * -5 + 2}px`, overflow: 'hidden' }}>
|
||||
{image}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const clearArenaSize = Math.floor((logoSize + 25) / cellSize);
|
||||
const matrixMiddleStart = matrix.length / 2 - clearArenaSize / 2;
|
||||
const matrixMiddleEnd = matrix.length / 2 + clearArenaSize / 2 - 1;
|
||||
|
||||
matrix.forEach((row: QRCodeUtil.QRCode[], i: number) => {
|
||||
row.forEach((_: any, j: number) => {
|
||||
if (matrix[i][j]) {
|
||||
// Do not render dots under position squares
|
||||
if (
|
||||
!(
|
||||
(i < 7 && j < 7) ||
|
||||
(i > matrix.length - 8 && j < 7) ||
|
||||
(i < 7 && j > matrix.length - 8)
|
||||
)
|
||||
) {
|
||||
//if (image && i > matrix.length - 9 && j > matrix.length - 9) return;
|
||||
if (
|
||||
image ||
|
||||
!(
|
||||
i > matrixMiddleStart &&
|
||||
i < matrixMiddleEnd &&
|
||||
j > matrixMiddleStart &&
|
||||
j < matrixMiddleEnd
|
||||
)
|
||||
) {
|
||||
dots.push(
|
||||
<circle
|
||||
id={`circle-${i}-${j}`}
|
||||
cx={i * cellSize + cellSize / 2}
|
||||
cy={j * cellSize + cellSize / 2}
|
||||
fill="var(--nestri-qr-dot-color)"
|
||||
r={cellSize / 3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return dots;
|
||||
}, [ecl, size, uri]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}}
|
||||
>
|
||||
<rect fill="transparent" height={size} width={size} />
|
||||
{dots()}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { theme } from "./theme";
|
||||
import { utility } from "./utility";
|
||||
import { Container } from "./layout";
|
||||
import { styled } from "@macaron-css/solid"
|
||||
import { CSSProperties } from "@macaron-css/core";
|
||||
import { ComponentProps, createMemo, For, JSX, Show, splitProps } from "solid-js";
|
||||
import { Container } from "./layout";
|
||||
import { utility } from "./utility";
|
||||
import { ComponentProps, For, JSX, Show, splitProps } from "solid-js";
|
||||
|
||||
// FIXME: Make sure the focus ring goes to red when the input is invalid
|
||||
|
||||
export const inputStyles: CSSProperties = {
|
||||
lineHeight: theme.font.lineHeight,
|
||||
appearance: "none",
|
||||
width: "100%",
|
||||
fontSize: theme.font.size.sm,
|
||||
borderRadius: theme.borderRadius,
|
||||
padding: `0 ${theme.space[3]}`,
|
||||
@@ -57,12 +58,7 @@ export const Root = styled("label", {
|
||||
color: theme.color.gray.d900
|
||||
},
|
||||
danger: {
|
||||
color: theme.color.red.d900,
|
||||
// selectors: {
|
||||
// "&:has(input)": {
|
||||
// ...inputDangerFocusStyles
|
||||
// }
|
||||
// }
|
||||
color: theme.color.gray.d900,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -88,6 +84,12 @@ export const Input = styled("input", {
|
||||
"::placeholder": {
|
||||
color: theme.color.gray.d800
|
||||
},
|
||||
selectors: {
|
||||
"[data-type='url'] &": {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}
|
||||
}
|
||||
// ":invalid":{
|
||||
// ...inputDangerFocusStyles
|
||||
// },
|
||||
@@ -121,11 +123,8 @@ export const Input = styled("input", {
|
||||
export const InputRadio = styled("input", {
|
||||
base: {
|
||||
padding: 0,
|
||||
// borderRadius: 0,
|
||||
WebkitAppearance: "none",
|
||||
appearance: "none",
|
||||
/* For iOS < 15 to remove gradient background */
|
||||
backgroundColor: theme.color.background.d100,
|
||||
/* Not removed via appearance */
|
||||
margin: 0,
|
||||
font: "inherit",
|
||||
@@ -179,6 +178,7 @@ const InputLabel = styled("label", {
|
||||
borderColor: theme.color.gray.d400,
|
||||
color: theme.color.gray.d800,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -197,7 +197,8 @@ const InputLabel = styled("label", {
|
||||
borderBottomLeftRadius: theme.borderRadius,
|
||||
},
|
||||
":hover": {
|
||||
backgroundColor: theme.color.background.d200,
|
||||
backgroundColor: theme.color.grayAlpha.d200,
|
||||
color: theme.color.d1000.gray
|
||||
},
|
||||
selectors: {
|
||||
"&:has(input:checked)": {
|
||||
@@ -243,9 +244,9 @@ export function FormField(props: FormFieldProps) {
|
||||
}
|
||||
|
||||
type SelectProps = {
|
||||
ref: (element: HTMLInputElement) => void;
|
||||
name: string;
|
||||
value: any;
|
||||
ref: (element: HTMLInputElement) => void;
|
||||
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
|
||||
onChange: JSX.EventHandler<HTMLInputElement, Event>;
|
||||
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
|
||||
@@ -276,7 +277,6 @@ const Badge = styled("div", {
|
||||
padding: "0 6px",
|
||||
fontSize: theme.font.size.xs
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
export function Select(props: SelectProps) {
|
||||
|
||||
@@ -3,18 +3,41 @@ import { styled } from "@macaron-css/solid";
|
||||
|
||||
export const FullScreen = styled("div", {
|
||||
base: {
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
position: "fixed",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.color.background.d200,
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
justifyContent: "center"
|
||||
},
|
||||
variants: {
|
||||
inset: {
|
||||
none: {},
|
||||
header: {
|
||||
top: theme.headerHeight.root,
|
||||
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
|
||||
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const Screen = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
justifyContent: "center"
|
||||
},
|
||||
variants: {
|
||||
inset: {
|
||||
none: {},
|
||||
header: {
|
||||
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
|
||||
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
4
packages/www/src/ui/modal/index.ts
Normal file
4
packages/www/src/ui/modal/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { HModalRoot as Root } from './modal-root';
|
||||
export { HModalPanel as Panel } from './modal-panel';
|
||||
export { HModalTrigger as Trigger } from './modal-trigger';
|
||||
export * as Modal from "."
|
||||
20
packages/www/src/ui/modal/modal-context.tsx
Normal file
20
packages/www/src/ui/modal/modal-context.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Accessor, createContext, Setter, useContext } from "solid-js";
|
||||
|
||||
export const ModalContext = createContext<ModalContext>();
|
||||
|
||||
export function useModal() {
|
||||
const ctx = useContext(ModalContext);
|
||||
if (!ctx) throw new Error("No modal context");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export type ModalContext = {
|
||||
// core state
|
||||
localId: string;
|
||||
show: Accessor<boolean>;
|
||||
setShow: Setter<boolean>;
|
||||
onShow?: () => void;
|
||||
onClose?: () => void;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
};
|
||||
117
packages/www/src/ui/modal/modal-panel.tsx
Normal file
117
packages/www/src/ui/modal/modal-panel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useModal } from './use-modal';
|
||||
import { useModal as useModalContext } from './modal-context';
|
||||
import { Accessor, ComponentProps, createEffect, createSignal, onCleanup } from 'solid-js';
|
||||
|
||||
export type ModalProps = Omit<ComponentProps<'dialog'>, 'open'> & {
|
||||
onShow?: () => void;
|
||||
onClose?: () => void;
|
||||
onKeyDown?: () => void;
|
||||
'bind:show': Accessor<boolean>;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
};
|
||||
|
||||
export const HModalPanel = (props: ComponentProps<'dialog'>) => {
|
||||
const {
|
||||
activateFocusTrap,
|
||||
closeModal,
|
||||
deactivateFocusTrap,
|
||||
showModal,
|
||||
trapFocus,
|
||||
wasModalBackdropClicked,
|
||||
} = useModal();
|
||||
const context = useModalContext();
|
||||
|
||||
const [panelRef, setPanelRef] = createSignal<HTMLDialogElement>();
|
||||
let focusTrapRef: any = null;
|
||||
|
||||
createEffect(async () => {
|
||||
const dialog = panelRef();
|
||||
if (!dialog) return;
|
||||
|
||||
if (context.show()) {
|
||||
// Handle iOS scroll position issue
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
let originalRAF;
|
||||
|
||||
if (isIOS) {
|
||||
originalRAF = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = () => 42;
|
||||
}
|
||||
|
||||
await showModal(dialog);
|
||||
|
||||
if (isIOS && originalRAF) {
|
||||
window.requestAnimationFrame = originalRAF;
|
||||
}
|
||||
|
||||
// Setup focus trap after showing modal
|
||||
focusTrapRef = await trapFocus(dialog);
|
||||
activateFocusTrap(focusTrapRef);
|
||||
|
||||
// Trigger show callback
|
||||
context.onShow?.();
|
||||
} else {
|
||||
await closeModal(dialog);
|
||||
// Trigger close callback
|
||||
context.onClose?.();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusTrapRef) {
|
||||
deactivateFocusTrap(focusTrapRef);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBackdropClick = async (e: MouseEvent) => {
|
||||
if (context.alert === true || context.closeOnBackdropClick === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only close if the backdrop itself was clicked (not content)
|
||||
if (e.target instanceof HTMLDialogElement && await wasModalBackdropClicked(panelRef(), e)) {
|
||||
context.setShow(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Prevent spacebar/enter from triggering if dialog itself is focused
|
||||
if (e.target instanceof HTMLDialogElement && [' ', 'Enter'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Handle escape key to close modal
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
context.setShow(false);
|
||||
}
|
||||
|
||||
// Allow other keydown handlers to run
|
||||
// props.onKeyDown?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
{...props}
|
||||
id={`${context.localId}-root`}
|
||||
aria-labelledby={`${context.localId}-title`}
|
||||
aria-describedby={`${context.localId}-description`}
|
||||
data-state={context.show() ? 'open' : 'closed'}
|
||||
data-open={context.show() ? '' : undefined}
|
||||
data-closed={!context.show() ? '' : undefined}
|
||||
role={context.alert === true ? 'alertdialog' : 'dialog'}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={setPanelRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleBackdropClick(e);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
34
packages/www/src/ui/modal/modal-root.tsx
Normal file
34
packages/www/src/ui/modal/modal-root.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import { ModalContext } from './modal-context';
|
||||
import { Accessor, ComponentProps, createSignal, createUniqueId, splitProps } from 'solid-js';
|
||||
|
||||
type ModalRootProps = {
|
||||
onShow?: () => void;
|
||||
onClose?: () => void;
|
||||
'bind:show'?: Accessor<boolean>;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
} & ComponentProps<'div'>;
|
||||
|
||||
export const HModalRoot = (props: ModalRootProps) => {
|
||||
const localId = createUniqueId();
|
||||
|
||||
const [modalProps, divProps] = splitProps(props, [
|
||||
'bind:show',
|
||||
'closeOnBackdropClick',
|
||||
'onShow',
|
||||
'onClose',
|
||||
'alert',
|
||||
]);
|
||||
|
||||
const [defaultShowSig, setDefaultShowSig] = createSignal<boolean>(false);
|
||||
const show = props["bind:show"] ?? defaultShowSig;
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ ...modalProps, setShow: setDefaultShowSig, show, localId }} >
|
||||
<div {...divProps}>
|
||||
{props.children}
|
||||
</div>
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
24
packages/www/src/ui/modal/modal-trigger.tsx
Normal file
24
packages/www/src/ui/modal/modal-trigger.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useModal } from './modal-context';
|
||||
import { ComponentProps } from 'solid-js';
|
||||
|
||||
export const HModalTrigger = (props: ComponentProps<"button">) => {
|
||||
const modal = useModal();
|
||||
|
||||
const handleClick = () => {
|
||||
modal.setShow((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Open Theme Customization Panel"
|
||||
aria-expanded={modal.show()}
|
||||
data-open={modal.show() ? '' : undefined}
|
||||
data-closed={!modal.show() ? '' : undefined}
|
||||
onClick={[handleClick, props.onClick]}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
131
packages/www/src/ui/modal/use-modal.tsx
Normal file
131
packages/www/src/ui/modal/use-modal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { FocusTrap, createFocusTrap } from 'focus-trap';
|
||||
|
||||
export type WidthState = {
|
||||
width: number | null;
|
||||
};
|
||||
|
||||
import { enableBodyScroll, disableBodyScroll } from 'body-scroll-lock-upgrade';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -89,7 +89,7 @@ const font = {
|
||||
mono_2xl: "1.375rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "1.875rem",
|
||||
"4xl": "2.25rem",
|
||||
"4xl": "2rem",
|
||||
"5xl": "3rem",
|
||||
"6xl": "3.75rem",
|
||||
"7xl": "4.5rem",
|
||||
@@ -218,13 +218,16 @@ const light = (() => {
|
||||
const brand = "#FF4F01"
|
||||
|
||||
const background = {
|
||||
d100: '#f5f5f5',
|
||||
d200: 'oklch(from #f5f5f5 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)'
|
||||
d100: 'rgba(255,255,255,0.8)',
|
||||
d200: '#f4f5f6',
|
||||
};
|
||||
|
||||
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(206, 127, 243, 0.1) 52.58%, rgba(248, 236, 215, 0) 100%)"
|
||||
|
||||
const contrastFg = '#ffffff';
|
||||
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(0,0,0,0.16)`;
|
||||
const focusColor = blue.d700
|
||||
const hoverColor = "hsl(0,0%,22%)"
|
||||
|
||||
const text = {
|
||||
primary: {
|
||||
@@ -257,7 +260,9 @@ const light = (() => {
|
||||
focusColor,
|
||||
d1000,
|
||||
brand,
|
||||
text
|
||||
text,
|
||||
headerGradient,
|
||||
hoverColor
|
||||
};
|
||||
})()
|
||||
|
||||
@@ -380,13 +385,15 @@ const dark = (() => {
|
||||
const brand = "#FF4F01"
|
||||
|
||||
const background = {
|
||||
d200: '#171717',
|
||||
d100: "oklch(from #171717 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)"
|
||||
d100: "rgba(255,255,255,0.04)",
|
||||
d200: 'rgb(19,21,23)',
|
||||
};
|
||||
|
||||
const contrastFg = '#ffffff';
|
||||
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(255,255,255,0.24)`;
|
||||
const focusColor = blue.d900
|
||||
const hoverColor = "hsl(0,0%,80%)"
|
||||
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(239, 148, 225, 0.1) 50%, rgba(191, 124, 7, 0) 100%)"
|
||||
|
||||
const text = {
|
||||
primary: {
|
||||
@@ -419,7 +426,9 @@ const dark = (() => {
|
||||
focusColor,
|
||||
d1000,
|
||||
text,
|
||||
brand
|
||||
brand,
|
||||
headerGradient,
|
||||
hoverColor
|
||||
};
|
||||
})()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user