diff --git a/package.json b/package.json index 69e593e..0ad6dc3 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,15 @@ "prepare": "npm run build" }, "devDependencies": { - "@opencast/eslint-config-ts-react": "^0.1.0", + "@opencast/eslint-config-ts-react": "^0.2.0", "@types/react": "^18.2.13", + "@types/react-dom": "^18.2.19", "typescript": "^5.1.3" }, "peerDependencies": { - "@emotion/react": "^11.11.1", + "@emotion/react": "^11.11.4", "@floating-ui/react": "^0.24.3", + "focus-trap-react": "^10.2.3", "react": "^18.2.0", "react-icons": "^4.9.0", "react-merge-refs": "^2.0.2" diff --git a/src/button.tsx b/src/button.tsx index a622214..faad70d 100644 --- a/src/button.tsx +++ b/src/button.tsx @@ -1,4 +1,6 @@ import React from "react"; +import { Interpolation, Theme } from "@emotion/react"; +import { AppkitConfig, focusStyle, match, useAppkitConfig, useColorScheme } from "."; /** * A mostly unstyled button used to build buttons. Always use this instead of @@ -20,3 +22,93 @@ export const ProtoButton = React.forwardRef{children}, ); + +/** The kind of buttons a "Button" can be. Used for styling */ +export type Kind = "normal" | "danger" | "call-to-action"; + +type ButtonProps = JSX.IntrinsicElements["button"] & { + kind?: Kind; + extraCss?: Interpolation; +}; + +/** A styled button */ +export const Button = React.forwardRef( + ({ kind = "normal", extraCss, children, ...rest }, ref) => { + const config = useAppkitConfig(); + const { isHighContrast } = useColorScheme(); + + return ( + + ); + }, +); + +/** + * Returns css for different types of buttons. + */ +const css = ( + config: AppkitConfig, + kind: Kind, + isHighContrast: boolean, + extraCss: Interpolation = {} +): Interpolation => { + const notDisabledStyle = match(kind, { + "normal": () => ({ + border: `1px solid ${config.colors.neutral40}`, + color: config.colors.neutral90, + "&:hover, &:focus-visible": { + border: `1px solid ${config.colors.neutral60}`, + backgroundColor: config.colors.neutral15, + }, + }), + + "danger": () => ({ + border: `1px solid ${config.colors.danger4}`, + color: config.colors.danger4, + fontWeight: isHighContrast ? "bold" : "inherit", + "&:hover, &:focus-visible": { + border: `1px solid ${config.colors.danger5}`, + backgroundColor: config.colors.danger4, + color: config.colors.danger4BwInverted, + }, + }), + + "call-to-action": () => ({ + border: `1px solid ${config.colors.happy8}`, + color: config.colors.happy7BwInverted, + backgroundColor: config.colors.happy7, + "&:hover, &:focus-visible": { + border: `1px solid ${config.colors.happy9}`, + backgroundColor: config.colors.happy8, + color: config.colors.happy8BwInverted, + }, + }), + }); + + return { + borderRadius: 8, + display: "inline-flex", + alignItems: "center", + padding: "7px 14px", + gap: 12, + whiteSpace: "nowrap", + backgroundColor: config.colors.neutral10, + transition: "background-color 0.15s, border-color 0.15s", + textDecoration: "none", + "& > svg": { + fontSize: 20, + }, + "&:disabled": { + border: `1px solid ${config.colors.neutral25}`, + color: config.colors.neutral40, + }, + "&:not([disabled])": { + cursor: "pointer", + ...notDisabledStyle, + ...focusStyle(config, { offset: -1 }), + }, + ...extraCss as Record, + }; +}; + +export const buttonStyle = css; diff --git a/src/card.tsx b/src/card.tsx new file mode 100644 index 0000000..bc29d5f --- /dev/null +++ b/src/card.tsx @@ -0,0 +1,47 @@ +import { LuAlertTriangle, LuInfo } from "react-icons/lu"; +import { match, useAppkitConfig } from "."; + + +type Props = JSX.IntrinsicElements["div"] & { + kind: "error" | "info"; + iconPos?: "left" | "top"; +}; + +/** A styled container for different purposes */ +export const Card: React.FC = ({ kind, iconPos = "left", children, ...rest }) => { + const config = useAppkitConfig(); + + return ( +
svg": { + fontSize: 24, + minWidth: 24, + }, + ...match(kind, { + "error": () => ({ + backgroundColor: config.colors.danger0, + border: `1.5px solid ${config.colors.danger0}`, + color: config.colors.danger0BwInverted, + }) as Record, + "info": () => ({ + backgroundColor: config.colors.neutral10, + }), + }), + }} + {...rest} + > + {match(kind, { + "error": () => , + "info": () => , + })} +
{children}
+
+ ); +}; diff --git a/src/colors.css b/src/colors.css index f57ecad..69c18f5 100644 --- a/src/colors.css +++ b/src/colors.css @@ -14,17 +14,28 @@ html[data-color-scheme="light"], html:not([data-color-scheme]) { --color-neutral90: #181818; --color-danger0: #feedeb; + --color-danger0-bw-inverted: #000; --color-danger1: #ffd2cd; + --color-danger1-bw-inverted: #000; --color-danger2: #feaba1; + --color-danger2-bw-inverted: #000; --color-danger4: #c22a2c; + --color-danger4-bw-inverted: #fff; --color-danger5: #880e11; + --color-danger5-bw-inverted: #fff; --color-accent9: #044a81; + --color-accent9-bw-inverted: #fff; --color-accent8: #215D99; + --color-accent8-bw-inverted: #fff; --color-accent7: #3073B8; + --color-accent7-bw-inverted: #fff; --color-accent6: #3E8AD8; + --color-accent6-bw-inverted: #000; --color-accent5: #4DA1F7; + --color-accent5-bw-inverted: #000; --color-accent4: #71B4F9; + --color-accent4-bw-inverted: #000; --color-focus: #215D99; color-scheme: light; @@ -46,17 +57,28 @@ html[data-color-scheme="dark"] { --color-neutral90: #c4c4c4; --color-danger0: #361314; + --color-danger0-bw-inverted: #fff; --color-danger1: #462522; + --color-danger1-bw-inverted: #fff; --color-danger2: #712f2a; - --color-danger4: #e0584d; - --color-danger5: #fb7c67; + --color-danger2-bw-inverted: #fff; + --color-danger4: #f2685b; + --color-danger4-bw-inverted: #000; + --color-danger5: #ff9581; + --color-danger5-bw-inverted: #000; --color-accent9: #85ace3; + --color-accent9-bw-inverted: #000; --color-accent8: #7da4db; + --color-accent8-bw-inverted: #000; --color-accent7: #588ccd; + --color-accent7-bw-inverted: #000; --color-accent6: #1f72ba; + --color-accent6-bw-inverted: #fff; --color-accent5: #1c619e; + --color-accent5-bw-inverted: #fff; --color-accent4: #195483; + --color-accent4-bw-inverted: #fff; --color-focus: #B8D9FC; color-scheme: dark; @@ -78,16 +100,28 @@ html[data-color-scheme="light-high-contrast"] { --color-neutral90: #000; --color-danger0: #fff; + --color-danger0-bw-inverted: #000; --color-danger1: #fff; + --color-danger1-bw-inverted: #000; --color-danger2: #a50613; + --color-danger2-bw-inverted: #fff; --color-danger4: #a50613; + --color-danger4-bw-inverted: #fff; --color-danger5: #a50613; + --color-danger5-bw-inverted: #fff; + --color-accent9: #000099; + --color-accent9-bw-inverted: #fff; --color-accent8: #000099; + --color-accent8-bw-inverted: #fff; --color-accent7: #000099; + --color-accent7-bw-inverted: #fff; --color-accent6: #000099; + --color-accent6-bw-inverted: #fff; --color-accent5: #000099; + --color-accent5-bw-inverted: #fff; --color-accent4: #000099; + --color-accent4-bw-inverted: #fff; --color-focus: #000099; color-scheme: light; @@ -109,16 +143,28 @@ html[data-color-scheme="dark-high-contrast"] { --color-neutral90: #fff; --color-danger0: #000; + --color-danger0-bw-inverted: #fff; --color-danger1: #000; - --color-danger2: #eb1722; - --color-danger4: #eb1722; - --color-danger5: #eb1722; - + --color-danger1-bw-inverted: #fff; + --color-danger2: #ff9581; + --color-danger2-bw-inverted: #000; + --color-danger4: #ff9581; + --color-danger4-bw-inverted: #000; + --color-danger5: #ff9581; + --color-danger5-bw-inverted: #000; + + --color-accent9: #a6ffea; + --color-accent9-bw-inverted: #000; --color-accent8: #a6ffea; + --color-accent8-bw-inverted: #000; --color-accent7: #a6ffea; + --color-accent7-bw-inverted: #000; --color-accent6: #a6ffea; + --color-accent6-bw-inverted: #000; --color-accent5: #a6ffea; + --color-accent5-bw-inverted: #000; --color-accent4: #a6ffea; + --color-accent4-bw-inverted: #000; --color-focus: #a6ffea; color-scheme: dark; diff --git a/src/config.tsx b/src/config.tsx index 9ff6e0d..b6684b5 100644 --- a/src/config.tsx +++ b/src/config.tsx @@ -28,16 +28,41 @@ export type ColorConfig = { neutral90: string, danger0: string, + danger0BwInverted: string, danger1: string, + danger1BwInverted: string, danger2: string, + danger2BwInverted: string, danger4: string, + danger4BwInverted: string, danger5: string, - + danger5BwInverted: string, + + happy4: string; + happy4BwInverted: string; + happy5: string; + happy5BwInverted: string; + happy6: string; + happy6BwInverted: string; + happy7: string; + happy7BwInverted: string; + happy8: string; + happy8BwInverted: string; + happy9: string; + happy9BwInverted: string; + + accent9: string; + accent9BwInverted: string; accent8: string; + accent8BwInverted: string; accent7: string; + accent7BwInverted: string; accent6: string; + accent6BwInverted: string; accent5: string; + accent5BwInverted: string; accent4: string; + accent4BwInverted: string; focus: string; }; @@ -59,16 +84,41 @@ export const DEFAULT_CONFIG: AppkitConfig = { neutral90: "var(--color-neutral90)", danger0: "var(--color-danger0)", + danger0BwInverted: "var(--color-danger0-bw-inverted)", danger1: "var(--color-danger1)", + danger1BwInverted: "var(--color-danger1-bw-inverted)", danger2: "var(--color-danger2)", + danger2BwInverted: "var(--color-danger2-bw-inverted)", danger4: "var(--color-danger4)", + danger4BwInverted: "var(--color-danger4-bw-inverted)", danger5: "var(--color-danger5)", - + danger5BwInverted: "var(--color-danger5-bw-inverted)", + + happy4: "var(--color-accent4)", + happy4BwInverted: "var(--color-accent4-bw-inverted)", + happy5: "var(--color-accent5)", + happy5BwInverted: "var(--color-accent5-bw-inverted)", + happy6: "var(--color-accent6)", + happy6BwInverted: "var(--color-accent6-bw-inverted)", + happy7: "var(--color-accent7)", + happy7BwInverted: "var(--color-accent7-bw-inverted)", + happy8: "var(--color-accent8)", + happy8BwInverted: "var(--color-accent8-bw-inverted)", + happy9: "var(--color-accent9)", + happy9BwInverted: "var(--color-accent9-bw-inverted)", + + accent9: "var(--color-accent9)", + accent9BwInverted: "var(--color-accent9-bw-inverted)", accent8: "var(--color-accent8)", + accent8BwInverted: "var(--color-accent8-bw-inverted)", accent7: "var(--color-accent7)", + accent7BwInverted: "var(--color-accent7-bw-inverted)", accent6: "var(--color-accent6)", + accent6BwInverted: "var(--color-accent6-bw-inverted)", accent5: "var(--color-accent5)", + accent5BwInverted: "var(--color-accent5-bw-inverted)", accent4: "var(--color-accent4)", + accent4BwInverted: "var(--color-accent4-bw-inverted)", focus: "var(--color-accent8)", }, diff --git a/src/confirmationModal.tsx b/src/confirmationModal.tsx new file mode 100644 index 0000000..a855980 --- /dev/null +++ b/src/confirmationModal.tsx @@ -0,0 +1,111 @@ +import { + ReactNode, + FormEvent, + PropsWithChildren, + forwardRef, + useState, + useRef, + useImperativeHandle, +} from "react"; +import { ModalProps, ModalHandle, Modal, Spinner, Button, boxError } from "."; +import { currentRef } from "./util"; + + +type ConfirmationModalProps = Omit & { + title?: string; + /** What to display in the confirm button. A string can be enough. */ + buttonContent: ReactNode; + onSubmit?: () => void; + /** Strings that will be displayed in the UI */ + text: { + /** Text on the button to cancel the modal */ + cancel: string, + /** Text on the button to close the modal */ + close: string, + /** Text asking the question that should be confirmed or cancelled. */ + areYouSure: string, + }, +}; + +/** + * A component that sits in the middle of the screen, darkens the rest of the + * screen and traps user focus. Also asks for confirmation, and closing can + * be delayed if a time intensive action was triggered. + */ +export type ConfirmationModalHandle = ModalHandle & { + done: () => void; + reportError: (error: JSX.Element) => void; +}; + +export const ConfirmationModal + = forwardRef>( + ({ + title: titleOverride, + buttonContent, + onSubmit, + text, + children, + }, ref) => { + const title = titleOverride ?? text.areYouSure; + + const [inFlight, setInFlight] = useState(false); + const [error, setError] = useState(); + + const modalRef = useRef(null); + + useImperativeHandle(ref, () => ({ + open: () => { + setInFlight(false); + setError(undefined); + currentRef(modalRef).open(); + }, + done: () => { + currentRef(modalRef).close?.(); + }, + reportError: (error: JSX.Element) => { + setInFlight(false); + setError(error); + }, + })); + + const onSubmitWrapper = (event: FormEvent) => { + event.preventDefault(); + // Don't let the event escape the portal, + // which might be sitting inside of other `form` elements. + event.stopPropagation(); + setInFlight(true); + setError(undefined); + onSubmit?.(); + }; + + return + {children} +
+
+ + +
+ {inFlight &&
} +
+ {boxError(error)} +
; + }, + ); diff --git a/src/errorBox.tsx b/src/errorBox.tsx new file mode 100644 index 0000000..57d8568 --- /dev/null +++ b/src/errorBox.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; + +import { Card } from "."; + +export const ErrorBox: React.FC<{ children: ReactNode }> = ({ children }) => ( +
+ {children} +
+); + +/** +* If the given error is not `null` nor `undefined`, returns an `` +* with it as content. Returns `null` otherwise. +*/ +export const boxError = (err: ReactNode): JSX.Element | null => ( + err == null ? null : {err} +); diff --git a/src/index.tsx b/src/index.tsx index 33a43a6..d025739 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,13 +1,17 @@ import { AppkitConfig } from "./config"; export * from "./button"; +export * from "./card"; export * from "./colorScheme"; export * from "./config"; +export * from "./confirmationModal"; export * from "./err"; +export * from "./errorBox"; export * from "./floating"; export * from "./header"; export * from "./spinner"; export * from "./util"; +export * from "./modal"; /** diff --git a/src/modal.tsx b/src/modal.tsx new file mode 100644 index 0000000..9572dce --- /dev/null +++ b/src/modal.tsx @@ -0,0 +1,129 @@ +import { + PropsWithChildren, + forwardRef, + useState, + useImperativeHandle, + useEffect, +} from "react"; +import ReactDOM from "react-dom"; +import { LuX } from "react-icons/lu"; +import FocusTrap from "focus-trap-react"; +import { + useAppkitConfig, ProtoButton, focusStyle, useColorScheme, +} from "."; + +export type ModalProps = { + title: string; + /** Whether the user can close the modal. If false, the application + * will have to close the modal eventually. */ + closable?: boolean; + className?: string; + closeOnOutsideClick?: boolean; + open?: boolean; + /** If true, the first element in the modal automatically be focused. If false, + * no element is initially focused */ + initialFocus?: false; + /** Strings that will be displayed in the UI */ + text: { + /** Text on the button to close the modal */ + close: string, + }, +}; + +export type ModalHandle = { + open: () => void; + close?: () => void; + isOpen?: () => boolean; +}; + +/** + * A component that sits in the middle of the screen, darkens the rest of the + * screen and traps user focus. + */ +export const Modal = forwardRef>(({ + title, + closable = true, + children, + className, + closeOnOutsideClick = false, + open = false, + initialFocus, + text, +}, ref) => { + const config = useAppkitConfig(); + const [isOpen, setOpen] = useState(open); + const isDark = useColorScheme().scheme === "dark"; + + useImperativeHandle(ref, () => ({ + isOpen: () => isOpen, + open: () => setOpen(true), + close: () => setOpen(false), + }), [isOpen]); + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (closable && event.key === "Escape") { + setOpen(false); + } + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [closable]); + + return ReactDOM.createPortal( + isOpen && +
{ + if (e.target === e.currentTarget) { + setOpen(false); + } + } })} + css={{ + position: "fixed", + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: "rgba(0, 0, 0, 0.8)", + display: "flex", + justifyContent: "center", + alignItems: "center", + zIndex: 10001, + }} + > +
+
+

{title}

+ {closable && setOpen(false)} + css={{ + fontSize: 32, + cursor: "pointer", + display: "inline-flex", + borderRadius: 4, + ...focusStyle(config), + }} + >} +
+
{children}
+
+
+
, + document.body, + ); +}); diff --git a/src/spinner.tsx b/src/spinner.tsx index aaa3867..fcd4fc0 100644 --- a/src/spinner.tsx +++ b/src/spinner.tsx @@ -1,33 +1,39 @@ import { keyframes } from "@emotion/react"; import React from "react"; - type Props = JSX.IntrinsicElements["svg"] & { size?: number | string; + strokeColor?: string, }; -export const Spinner = React.forwardRef(({ size = "1em", ...rest }, ref) => ( - circle": { - fill: "none", - stroke: "currentcolor", - strokeWidth: 4, - strokeDasharray: 83, // 2/3 of circumference - strokeLinecap: "round", - }, +export const Spinner = React.forwardRef(({ + size = "1em", + strokeColor = "currentcolor", + ...rest +}, ref) => { + return ( + circle": { + fill: "none", + stroke: strokeColor, + strokeWidth: 4, + strokeDasharray: 83, // 2/3 of circumference + strokeLinecap: "round", + }, - }} - {...rest} - > - - -)); + }} + {...rest} + > + + + ); +}); diff --git a/src/util.tsx b/src/util.tsx index df992ab..0e9e2fe 100644 --- a/src/util.tsx +++ b/src/util.tsx @@ -1,4 +1,5 @@ import { MutableRefObject, useEffect } from "react"; +import { bug } from "./err"; /** * A switch-case-like expression with exhaustiveness check (or fallback value). @@ -69,3 +70,13 @@ export const useOnOutsideClick = ( return () => document.removeEventListener("mousedown", handler); }); }; + +/** + * Accesses the current value of a ref, signaling an error when it is unbound. + * Note: **Don't** use this if you expect the ref to be unbound temporarily. + * This is mainly for accessing refs in event handlers for elements + * that are guaranteed to be alive as long as the ref itself. + */ +export const currentRef = (ref: React.RefObject): T => ( + ref.current ?? bug("ref unexpectedly unbound") +);