diff --git a/.changeset/warm-vans-change.md b/.changeset/warm-vans-change.md new file mode 100644 index 000000000..b04ac868a --- /dev/null +++ b/.changeset/warm-vans-change.md @@ -0,0 +1,23 @@ +--- +"@lightsparkdev/ui": patch +--- + +- Remove unused icons (#10179) +- Preload icons (#10182) +- Fix invalid color string (#10231) +- Add ChevronLeft icon (#10198) +- Add InfoIconTooltip component and improve Tooltip (#10236) +- Add Radio component (#10350) +- Typography and theme improvements (#10331) +- Add Banner component (#10262) +- Consolidate specifying full precision for currencies (#1095) +- Add NextLink as a ToReactNode type (#10432) +- Button improvements (#10507) +- Update to latest typography tokens (#10536) +- Add transformGQLName (#10592) +- ToReactNodes improvements and tests (#10562) +- Add icons (#10572) +- Provide icon color inversion when color is specified (#10630) +- Add PhoneInput component (#10702) +- Add useDebounce hook (#10741) +- Add common Drawer component, optionally use in Modal (#10819) diff --git a/.changeset/wild-beans-suffer.md b/.changeset/wild-beans-suffer.md new file mode 100644 index 000000000..40736792e --- /dev/null +++ b/.changeset/wild-beans-suffer.md @@ -0,0 +1,6 @@ +--- +"@lightsparkdev/core": minor +--- + +- Move formatCurrencyStr options into an object (#1095) +- Compress requests with deflate (#10512) diff --git a/.changeset/wise-tables-knock.md b/.changeset/wise-tables-knock.md new file mode 100644 index 000000000..c72505673 --- /dev/null +++ b/.changeset/wise-tables-knock.md @@ -0,0 +1,12 @@ +--- +"@lightsparkdev/ui": major +--- + +- Move PageSectionNav to new UI component (#9971) +- Move all PageSection components and Dropdown to public UI (#9979) +- Allow Modal submit to be a link (#10066) +- Move themes to own file. Simplify colors (#10167) +- Move typography and tokens (#10172) +- Improve theme typography tokens and add bridge tokens (#10189) +- Move typography to components (#10178) +- Update dependencies diff --git a/apps/examples/oauth-app/package.json b/apps/examples/oauth-app/package.json index 547591c57..6921f4b68 100644 --- a/apps/examples/oauth-app/package.json +++ b/apps/examples/oauth-app/package.json @@ -27,7 +27,7 @@ "eslint-watch": "^8.0.0", "tsc-absolute": "^1.0.1", "typescript": "^5.0.0", - "vite": "^5.0.5" + "vite": "^5.1.6" }, "scripts": { "start": "yarn vite", diff --git a/apps/examples/oauth-app/src/index.tsx b/apps/examples/oauth-app/src/index.tsx index 7675e17cc..165ae6832 100644 --- a/apps/examples/oauth-app/src/index.tsx +++ b/apps/examples/oauth-app/src/index.tsx @@ -7,7 +7,7 @@ import "./index.css"; import reportWebVitals from "./reportWebVitals"; import { Root } from "./Root"; import { ThemeProvider } from "@emotion/react"; -import { themes } from "@lightsparkdev/ui/styles/colors"; +import { themes } from "@lightsparkdev/ui/styles/themes"; const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement diff --git a/apps/examples/oauth-app/src/pages/DashboardPage.tsx b/apps/examples/oauth-app/src/pages/DashboardPage.tsx index 0e15dde7d..2eac6aec0 100644 --- a/apps/examples/oauth-app/src/pages/DashboardPage.tsx +++ b/apps/examples/oauth-app/src/pages/DashboardPage.tsx @@ -46,7 +46,7 @@ function DashboardPage() {
); }; @@ -145,7 +145,7 @@ const InitializeWallet = ({ return (

Wallet not yet initialized. Status: {status}

-
); }; diff --git a/apps/examples/react-wallet-app/src/pages/LoginPage.tsx b/apps/examples/react-wallet-app/src/pages/LoginPage.tsx index a026fe55f..f01472ebf 100644 --- a/apps/examples/react-wallet-app/src/pages/LoginPage.tsx +++ b/apps/examples/react-wallet-app/src/pages/LoginPage.tsx @@ -30,14 +30,19 @@ const LoginPage = () => { const generateDemoTokens = async () => { const { token: jwt, accountId: jwtServerAccountId } = await fetch( - `${jwtServerUrl.replace(/\/$/, "")}/getJwt?userId=${userName}&password=${password}`, + `${jwtServerUrl.replace( + /\/$/, + "" + )}/getJwt?userId=${userName}&password=${password}`, { method: "GET", headers: { "ngrok-skip-browser-warning": "true", - } + }, } - ).then((res) => res.json() as Promise<{ token: string, accountId: string }>); + ).then( + (res) => res.json() as Promise<{ token: string; accountId: string }> + ); await auth.login(jwtServerAccountId, jwt); navigate(Routes.Dashboard); }; @@ -62,7 +67,7 @@ const LoginPage = () => { onChange={(e) => setJwt(e.target.value)} /> - Transactions @@ -341,7 +340,7 @@ function LoginScreen(props: { {!isLoading ? ( - + ) : ( <> diff --git a/apps/examples/streaming-wallet-extension/src/components/CirclePlusIcon.tsx b/apps/examples/streaming-wallet-extension/src/components/CirclePlusIcon.tsx deleted file mode 100644 index b4914d6de..000000000 --- a/apps/examples/streaming-wallet-extension/src/components/CirclePlusIcon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved - -const CirclePlusIcon = () => ( - - - -); - -export default CirclePlusIcon; diff --git a/apps/examples/streaming-wallet-extension/src/components/LeftArrow.tsx b/apps/examples/streaming-wallet-extension/src/components/LeftArrow.tsx deleted file mode 100644 index c07251cb0..000000000 --- a/apps/examples/streaming-wallet-extension/src/components/LeftArrow.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved - -const LeftArrow = () => ( - - - -); - -export default LeftArrow; diff --git a/apps/examples/ui-test-app/.eslintrc.js b/apps/examples/ui-test-app/.eslintrc.js new file mode 100644 index 000000000..c0a94025e --- /dev/null +++ b/apps/examples/ui-test-app/.eslintrc.js @@ -0,0 +1,23 @@ +module.exports = { + extends: ["@lightsparkdev/eslint-config/react-lib"], + /* Mainly keeping this file around for reference for future public react lib, ignore in lint: */ + ignorePatterns: ["src/generated/"], + overrides: [ + { + files: ["**/src/**/*.ts?(x)"], + excludedFiles: ["**/tests/**/*.ts?(x)"], + rules: { + /* Temporarily turn off no-explicit-any until these can be resolved LIG-3400: */ + "@typescript-eslint/no-explicit-any": "off", + /* Too many of these type-aware errors, turn off for now: */ + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-misused-promises": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-return": "off", + }, + }, + ], +}; diff --git a/apps/examples/ui-test-app/.prettierignore b/apps/examples/ui-test-app/.prettierignore new file mode 100644 index 000000000..d3db8f9b1 --- /dev/null +++ b/apps/examples/ui-test-app/.prettierignore @@ -0,0 +1 @@ +src/generated/ diff --git a/apps/examples/ui-test-app/.prettierrc b/apps/examples/ui-test-app/.prettierrc new file mode 100644 index 000000000..55c1943ae --- /dev/null +++ b/apps/examples/ui-test-app/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-organize-imports"] +} diff --git a/apps/examples/ui-test-app/gql-codegen.yml b/apps/examples/ui-test-app/gql-codegen.yml new file mode 100644 index 000000000..14875df40 --- /dev/null +++ b/apps/examples/ui-test-app/gql-codegen.yml @@ -0,0 +1,11 @@ +overwrite: true +schema: ["../../../../sparkcore/graphql_schemas/first_party_schema.graphql"] +documents: ["src/**/*.tsx"] +generates: + src/generated/graphql.tsx: + plugins: + - "typescript" + - "typescript-operations" + - "typescript-react-apollo" + config: + nonOptionalTypename: true diff --git a/apps/examples/ui-test-app/index.html b/apps/examples/ui-test-app/index.html new file mode 100644 index 000000000..297ea69dd --- /dev/null +++ b/apps/examples/ui-test-app/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + Lightspark + + + + + + + + +
+ + + + diff --git a/packages/ui/jest.config.ts b/apps/examples/ui-test-app/jest.config.ts similarity index 81% rename from packages/ui/jest.config.ts rename to apps/examples/ui-test-app/jest.config.ts index a4915e613..d2cf931c9 100644 --- a/packages/ui/jest.config.ts +++ b/apps/examples/ui-test-app/jest.config.ts @@ -19,9 +19,6 @@ const config: JestConfigWithTsJest = { testTimeout: 20000, moduleNameMapper: { "^.+\\.(css|svg|png)$": "identity-obj-proxy", - /* ts-jest doesn't support .js imports which are required for making imports work in - the published ui package. See issue https://bit.ly/3TF1uSS */ - "(.+)\\.js": "$1", }, setupFiles: ["/jest/setup.ts"], setupFilesAfterEnv: ["/jest/setupAfterEnv.ts"], diff --git a/apps/examples/ui-test-app/jest/setup.ts b/apps/examples/ui-test-app/jest/setup.ts new file mode 100644 index 000000000..1f5fb2185 --- /dev/null +++ b/apps/examples/ui-test-app/jest/setup.ts @@ -0,0 +1,23 @@ +/* Import node libs to polyfill browser objects */ +import crypto from "crypto"; +import ResizeObserver from "resize-observer-polyfill"; + +import { TextDecoder, TextEncoder } from "util"; + +Object.defineProperties(global.self, { + crypto: { + value: { + getRandomValues: (arr: NodeJS.ArrayBufferView) => + crypto.randomFillSync(arr), + subtle: crypto.webcrypto.subtle, + }, + }, + TextEncoder: { + value: TextEncoder, + }, + TextDecoder: { + value: TextDecoder, + }, +}); + +global.ResizeObserver = ResizeObserver; diff --git a/apps/examples/ui-test-app/jest/setupAfterEnv.ts b/apps/examples/ui-test-app/jest/setupAfterEnv.ts new file mode 100644 index 000000000..d0de870dc --- /dev/null +++ b/apps/examples/ui-test-app/jest/setupAfterEnv.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/apps/examples/ui-test-app/package.json b/apps/examples/ui-test-app/package.json new file mode 100644 index 000000000..ef4866c9a --- /dev/null +++ b/apps/examples/ui-test-app/package.json @@ -0,0 +1,98 @@ +{ + "name": "@lightsparkdev/ui-test-app", + "version": "0.0.0", + "description": "Lightspark UI components", + "author": "Lightspark Inc.", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "scripts": { + "build": "yarn tsc && yarn vite build", + "circular-deps": "madge --circular --extensions ts,tsx src", + "clean": "rm -rf .turbo && rm -rf dist", + "dev": "yarn build --watch", + "format:fix": "prettier src --write", + "format": "prettier src --check", + "lint:fix": "eslint --fix .", + "lint:watch": "esw ./src -w --ext .ts,.tsx,.js --color", + "lint": "eslint .", + "postversion": "yarn build", + "start": "yarn vite", + "preview": "yarn tsc && yarn vite preview", + "test": "node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --bail", + "types": "echo \"TODO: Some imports depend on the inheriting app config. May not be possible to run types here separately unless we fix that.\"", + "types:watch": "echo \"TODO: Some imports depend on the inheriting app config. May not be possible to run types here separately unless we fix that.\"" + }, + "license": "Apache-2.0", + "dependencies": { + "@apollo/client": "^3.9.11", + "@emotion/css": "^11.11.0", + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@lightsparkdev/core": "1.0.22", + "@lightsparkdev/ui": "0.0.13", + "@wojtekmaj/react-datetimerange-picker": "^5.5.0", + "@zxing/browser": "^0.1.1", + "@zxing/library": "^0.19.2", + "auto-bind": "^5.0.1", + "chart.js": "4.2.0", + "dayjs": "^1.11.7", + "deep-object-diff": "^1.1.9", + "nanoid": "^4.0.0", + "qrcode.react": "^3.1.0", + "react": "^18.2.0", + "react-date-picker": "^10.6.0", + "react-datetime-picker": "^5.6.0", + "react-device-detect": "^2.2.3", + "react-dom": "^18.1.0", + "react-router-dom": "6.11.2", + "react-table": "^7.8.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@babel/core": "^7.21.4", + "@emotion/babel-plugin": "^11.11.0", + "@graphql-codegen/cli": "^5.0.2", + "@graphql-codegen/typescript": "^4.0.6", + "@graphql-codegen/typescript-operations": "^4.2.0", + "@graphql-codegen/typescript-react-apollo": "^4.3.0", + "@lightsparkdev/eslint-config": "*", + "@lightsparkdev/tsconfig": "*", + "@lightsparkdev/vite": "*", + "@simbathesailor/use-what-changed": "^2.0.0", + "@testing-library/jest-dom": "^6.1.2", + "@types/jest": "^29.5.3", + "@vitejs/plugin-react": "^4.0.1", + "babel-jest": "^29.6.4", + "eslint": "^8.3.0", + "eslint-watch": "^8.0.0", + "graphql": "^16.6.0", + "graphql-tag": "^2.12.6", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.6.2", + "jest-environment-jsdom": "^29.6.4", + "madge": "^6.1.0", + "prettier": "3.0.3", + "prettier-plugin-organize-imports": "^3.2.4", + "resize-observer-polyfill": "^1.5.1", + "ts-jest": "^29.1.1", + "tsc-absolute": "^1.0.1", + "tsup": "^7.2.0", + "typescript": "^5.0.0", + "vite": "^5.1.6" + }, + "madge": { + "detectiveOptions": { + "ts": { + "skipTypeImports": true + }, + "tsx": { + "skipTypeImports": true + } + } + }, + "engines": { + "node": ">=18" + } +} diff --git a/apps/examples/ui-test-app/public/favicon-16.ico b/apps/examples/ui-test-app/public/favicon-16.ico new file mode 100644 index 000000000..a87e750ee Binary files /dev/null and b/apps/examples/ui-test-app/public/favicon-16.ico differ diff --git a/apps/examples/ui-test-app/public/favicon-32.ico b/apps/examples/ui-test-app/public/favicon-32.ico new file mode 100644 index 000000000..edecb5b07 Binary files /dev/null and b/apps/examples/ui-test-app/public/favicon-32.ico differ diff --git a/apps/examples/ui-test-app/public/logo192.png b/apps/examples/ui-test-app/public/logo192.png new file mode 100644 index 000000000..c31b513a7 Binary files /dev/null and b/apps/examples/ui-test-app/public/logo192.png differ diff --git a/apps/examples/ui-test-app/public/logo512.png b/apps/examples/ui-test-app/public/logo512.png new file mode 100644 index 000000000..230379bde Binary files /dev/null and b/apps/examples/ui-test-app/public/logo512.png differ diff --git a/apps/examples/ui-test-app/public/manifest.json b/apps/examples/ui-test-app/public/manifest.json new file mode 100644 index 000000000..a931ae928 --- /dev/null +++ b/apps/examples/ui-test-app/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "Lightspark", + "name": "Lightspark App", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/apps/examples/ui-test-app/public/robots.txt b/apps/examples/ui-test-app/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/apps/examples/ui-test-app/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/apps/examples/ui-test-app/src/Root.tsx b/apps/examples/ui-test-app/src/Root.tsx new file mode 100644 index 000000000..81e22ada9 --- /dev/null +++ b/apps/examples/ui-test-app/src/Root.tsx @@ -0,0 +1,79 @@ +import { ThemeProvider } from "@emotion/react"; +import styled from "@emotion/styled"; +import { + Button, + Checkbox, + Modal, + TextInput, + Toggle, +} from "@lightsparkdev/ui/components"; +import { Headline } from "@lightsparkdev/ui/components/typography"; +import { GlobalStyles } from "@lightsparkdev/ui/styles/global"; +import { themes } from "@lightsparkdev/ui/styles/themes"; +import { useState } from "react"; + +export function Root() { + const [showModal, setShowModal] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [textInputValue, setTextInputValue] = useState(""); + const [checkboxValue, setCheckboxValue] = useState(true); + const [toggleValue, setToggleValue] = useState(true); + return ( + + + + UI Test App + + + - void; + grabber?: boolean; + closeButton?: boolean; +} + +export const Drawer = (props: Props) => { + const [isOpen, setIsOpen] = useState(true); + const [lastY, setLastY] = useState(null); + const [totalDeltaY, setTotalDeltaY] = useState(0); + const [fractionVisible, setFractionVisible] = useState(1); + const [grabbing, setGrabbing] = useState(false); + const drawerContainerRef = useRef(null); + + const handleClose = () => { + setIsOpen(false); + + setTimeout(() => { + props.onClose && props.onClose(); + setTotalDeltaY(0); + setLastY(null); + }, 300); + }; + + const handleTouchMove = (event: React.TouchEvent) => { + if (lastY === null) { + setLastY(event.touches[0].clientY); + } else { + const deltaY = event.touches[0].clientY - lastY; + + const topOfDrawer = + (drawerContainerRef.current?.getBoundingClientRect().top || 0) + + window.scrollY; + const drawerContainerHeight = + drawerContainerRef.current?.getBoundingClientRect().height || 1; + const newFractionVisible = Math.min( + 1, + (window.innerHeight - topOfDrawer) / drawerContainerHeight, + ); + setFractionVisible(newFractionVisible); + setLastY(event.touches[0].clientY); + setTotalDeltaY((prev) => Math.max(prev + deltaY, 0)); + } + }; + + const handleTouchStart = () => { + setGrabbing(true); + }; + + const handleTouchEnd = () => { + setGrabbing(false); + if (fractionVisible < 0.8) { + handleClose(); + } + setTotalDeltaY(0); + setLastY(null); + }; + + return ( + <> + + + {props.grabber && ( + + + + )} + {props.closeButton && ( + + + + )} + {props.children} + + + ); +}; + +const Background = styled.div<{ isOpen: boolean; fractionVisible: number }>` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: ${z.modalOverlay}; + + // Animate opacity + transition: opacity 0.3s ease-in-out; + opacity: ${(props) => (props.isOpen ? props.fractionVisible : "0")}; + + animation: open 0.3s ease-in-out; + + @keyframes open { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +const DrawerContainer = styled.div<{ + isOpen: boolean; + totalDeltaY: number; + grabbing: boolean; +}>` + position: absolute; + width: 100%; + background-color: ${({ theme }) => theme.bg}; + right: 0; + bottom: 0; + transform: translateY(${(props) => `${props.totalDeltaY}px`}); + z-index: ${z.modalContainer}; + border-radius: ${Spacing.lg} ${Spacing.lg} 0 0; + display: flex; + flex-direction: column; + padding: ${Spacing["6xl"]} ${Spacing.xl} ${Spacing["2xl"]} ${Spacing.xl}; + + // Only smooth transition when not grabbing, otherwise dragging will feel very laggy + ${(props) => + props.grabbing && props.isOpen + ? "" + : "transition: transform 0.3s ease-in-out;"}; + + // Animate the drawer opening and closing, and make sure the the drawer stays closed. + animation: 0.3s ease-in-out + ${(props) => (props.isOpen ? "openDrawer" : "closeDrawer forwards")}; + + @keyframes openDrawer { + from { + transform: translateY(100%); + } + to { + transform: translateY(0%); + } + } + + @keyframes closeDrawer { + from { + transform: translateY(${(props) => `${props.totalDeltaY}px`}); + } + to { + transform: translateY(100%); + } + } +`; + +const Grabber = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: ${Spacing.lg}; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 9px; + cursor: pointer; +`; + +const CloseButtonContainer = styled.div` + position: absolute; + top: 20px; + right: 20px; + border-radius: 50%; + background-color: ${colors.grayBlue94}; + padding: ${Spacing.xs}; + + * > * { + line-height: 14px; + } +`; + +const GrabberBar = styled.div` + width: 36px; + height: 5px; + border-radius: 2.5px; + background: #c0c9d6; +`; diff --git a/packages/ui/src/components/Dropdown.tsx b/packages/ui/src/components/Dropdown.tsx new file mode 100644 index 000000000..1d11c835f --- /dev/null +++ b/packages/ui/src/components/Dropdown.tsx @@ -0,0 +1,498 @@ +import type { CSSInterpolation } from "@emotion/css"; +import { css, useTheme } from "@emotion/react"; +import type { CSSObject } from "@emotion/styled"; +import styled from "@emotion/styled"; +import type { ReactNode, RefObject } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import ReactDOM from "react-dom"; +import { + Link, + type ExternalLink, + type LinkProps, + type RouteHash, + type RouteParams, +} from "../router.js"; +import { bp } from "../styles/breakpoints.js"; +import { + overlaySurface, + standardBorderRadius, + standardFocusOutline, +} from "../styles/common.js"; +import { smHeaderLogoMarginLeft } from "../styles/constants.js"; +import { themeOr, type ThemeProp, type WithTheme } from "../styles/themes.js"; +import { z } from "../styles/z-index.js"; +import { Icon } from "./Icon/Icon.js"; +import { type IconName } from "./Icon/types.js"; +import { UnstyledButton } from "./UnstyledButton.js"; + +type DropdownItemGetProps = WithTheme<{ + dropdownItem: DropdownItemType; +}>; + +type DropdownItemType = { + label: string; + getIcon?: + | (({ theme, dropdownItem }: DropdownItemGetProps) => + | { + name: IconName; + color?: string; + width?: number; + } + | undefined) + | undefined; + onClick?: ((dropdownItem: DropdownItemType) => void) | undefined; + getCSS?: ({ + dropdownItem, + theme, + }: DropdownItemGetProps) => CSSInterpolation; + to?: RoutesType | undefined; + externalLink?: ExternalLink | undefined; + params?: RouteParams | undefined; + selected?: boolean; + hash?: RouteHash; +}; + +type DropdownGetProps = WithTheme<{ isOpen: boolean }>; + +type DropdownProps = { + button: { + label?: string; + id?: string; + getContent?: ({ isOpen, theme }: DropdownGetProps) => ReactNode; + getCSS?: ({ isOpen, theme }: DropdownGetProps) => CSSInterpolation; + }; + dropdownItems?: DropdownItemType[]; + dropdownContent?: ReactNode; + openOnHover?: boolean; + horizontalScrollRef?: RefObject; + minDropdownItemsWidth?: number; + maxDropdownItemsWidth?: number; + onClickDropdownItems?: () => void; + closeOnScroll?: boolean; + align?: "left" | "right" | "center"; + footer?: ReactNode | null; + getCSS?: ({ isOpen, theme }: DropdownGetProps) => CSSObject; + onOpen?: () => void; + onClose?: () => void; +}; + +export function Dropdown({ + button, + dropdownItems, + horizontalScrollRef, + onClickDropdownItems, + getCSS, + onOpen, + onClose, + closeOnScroll = false, + openOnHover = false, + minDropdownItemsWidth = 200, + maxDropdownItemsWidth = 300, + align = "center" as const, + footer = null, + dropdownContent = null, +}: DropdownProps) { + const theme = useTheme(); + const [isOpen, setIsOpen] = useState(false); + const [dropdownButtonHeight, setDropdownButtonHeight] = useState(0); + const [dropdownButtonWidth, setDropdownButtonWidth] = useState(0); + const [dropdownCoords, setDropdownCoords] = useState({ + top: 0, + left: 0, + right: 0, + }); + const dropdownRef = useRef(null); + const dropdownButtonRef = useRef(null); + const dropdownItemsRef = useRef(null); + const [dropdownItemsWidth, setDropdownItemsWidth] = useState(0); + const nodeRef = useRef(null); + const [nodeReady, setNodeReady] = useState(false); + + useEffect(() => { + if (!nodeRef.current) { + nodeRef.current = document.createElement("div"); + document.body.appendChild(nodeRef.current); + } + setNodeReady(true); + return () => { + if (nodeRef.current) { + document.body.removeChild(nodeRef.current); + nodeRef.current = null; + } + }; + }, []); + + const setOpen = useCallback( + (newValue: boolean) => { + setIsOpen(newValue); + if (newValue && onOpen) { + onOpen(); + } else if (!newValue && onClose) { + onClose(); + } + }, + [onOpen, onClose], + ); + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + } else if (["Enter", "Return"].includes(event.key)) { + event.preventDefault(); + setOpen(!isOpen); + } + } + + function handleBlur( + event: React.FocusEvent, + ) { + const targetOutsideDropdownItems = + dropdownItemsRef.current && + !dropdownItemsRef.current.contains(event.relatedTarget) && + event.relatedTarget !== null; + if (targetOutsideDropdownItems) { + setOpen(false); + } + } + + const handleRef = useCallback((ref: HTMLButtonElement | null) => { + if (ref && !dropdownButtonRef.current) { + dropdownButtonRef.current = ref; + } + return ref; + }, []); + + // close dropdown when clicking outside of it + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + const targetOutsideDropdownItems = + dropdownItemsRef.current && + !dropdownItemsRef.current.contains(event.target as Node); + const targetOutsideDropdownButton = + dropdownButtonRef.current && + !dropdownButtonRef.current.contains(event.target as Node); + if ( + isOpen && + event.target && + targetOutsideDropdownItems && + targetOutsideDropdownButton + ) { + setOpen(false); + } + } + window.document.addEventListener("mousedown", handleClickOutside); + return () => { + window.document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, setOpen]); + + const reposition = useCallback(() => { + if (dropdownButtonRef.current && dropdownItemsRef.current) { + const dropdownButtonRect = + dropdownButtonRef.current.getBoundingClientRect(); + setDropdownCoords({ + top: dropdownButtonRect.top, + left: dropdownButtonRect.left, + right: dropdownButtonRect.right, + }); + setDropdownButtonHeight(dropdownButtonRect.height); + setDropdownButtonWidth(dropdownButtonRect.width); + + const dropdownItemsRect = + dropdownItemsRef.current.getBoundingClientRect(); + setDropdownItemsWidth(dropdownItemsRect.width); + } + }, []); + + // handle all cases where a dropdown nav needs to be repositioned: + useLayoutEffect(() => { + function onWindowResize() { + setOpen(false); + reposition(); + } + + function handleScroll() { + if (closeOnScroll) { + setOpen(false); + } + reposition(); + } + + reposition(); + + let dropdownButtonResizeObserver: ResizeObserver | null = null; + if (dropdownButtonRef.current) { + dropdownButtonResizeObserver = new ResizeObserver(reposition); + dropdownButtonResizeObserver.observe(dropdownButtonRef.current); + } + + let dropdownResizeObserver: ResizeObserver | null = null; + if (dropdownButtonRef.current) { + dropdownResizeObserver = new ResizeObserver(reposition); + dropdownResizeObserver.observe(dropdownButtonRef.current); + } + + window.addEventListener("resize", onWindowResize); + + let navItemsResizeObserver: ResizeObserver | null = null; + window.addEventListener("scroll", handleScroll); + if (horizontalScrollRef?.current) { + horizontalScrollRef.current.addEventListener("scroll", handleScroll); + navItemsResizeObserver = new ResizeObserver(reposition); + navItemsResizeObserver.observe(horizontalScrollRef.current); + } + return () => { + window.removeEventListener("resize", onWindowResize); + window.removeEventListener("scroll", handleScroll); + dropdownButtonResizeObserver?.disconnect(); + navItemsResizeObserver?.disconnect(); + dropdownResizeObserver?.disconnect(); + }; + }, [setOpen, horizontalScrollRef, closeOnScroll, reposition]); + + const handleClickDropdownItems = useCallback(() => { + setOpen(false); + onClickDropdownItems && onClickDropdownItems(); + }, [onClickDropdownItems, setOpen]); + + const dropdownTopMargin = 8; + const dropdownItemOffsetTop = + dropdownCoords.top + dropdownButtonHeight + dropdownTopMargin; + + let dropdownItemOffsetLeft = 0; + + if (align === "center") { + dropdownItemOffsetLeft = + dropdownCoords.left - (dropdownItemsWidth - dropdownButtonWidth) / 2; + } else if (align === "right") { + dropdownItemOffsetLeft = dropdownCoords.right - dropdownItemsWidth; + } + + const buttonContent = + button.label || (button.getContent && button.getContent({ isOpen, theme })); + + const containerCSS = getCSS && getCSS({ isOpen, theme }); + + return ( +
+ { + reposition(); + setOpen(true); + } + : undefined + } + onMouseLeave={ + openOnHover + ? () => { + setOpen(false); + } + : undefined + } + onMouseDown={(e) => { + reposition(); + setOpen(!isOpen); + }} + onKeyDown={handleKeyDown} + onFocus={(e) => { + reposition(); + setOpen(true); + }} + onBlur={handleBlur} + ref={handleRef} + css={button.getCSS && button.getCSS({ isOpen, theme })} + > + {buttonContent} + + {nodeReady && nodeRef.current + ? ReactDOM.createPortal( + + {dropdownItems?.map((dropdownItem, i) => { + return ( +
  • + +
  • + ); + })} + {dropdownContent} + {footer && {footer}} +
    , + nodeRef.current, + ) + : null} +
    + ); +} + +const DropdownFooter = styled.div` + padding: 24px 16px 0; + border-top: 1px solid ${({ theme }) => theme.c2Neutral}; + margin-top: 16px; +`; + +type DropdownItemsProps = { + isOpen: boolean; + left: number; + top: number; + minWidth?: number; + maxWidth?: number; +}; + +const DropdownItems = styled.ul` + ${overlaySurface} + color: ${({ theme }) => themeOr(theme.c6Neutral, theme.c8Neutral)}; + ${standardBorderRadius(8)} + padding: 12px 0; + visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")}; + position: fixed; + ${({ minWidth }) => (minWidth ? `min-width: ${minWidth}px;` : "")} + ${({ maxWidth }) => (maxWidth ? `max-width: ${maxWidth}px;` : "")} + left: ${({ left }) => left}px; + top: ${({ top }) => top}px; + z-index: ${z.dropdown}; + list-style: none; + margin: 0; +`; + +type DropdownItemProps = { + dropdownItem: DropdownItemType & { selected?: boolean }; + onClick?: () => void; + getCSS?: ({ + dropdownItem, + theme, + }: DropdownItemGetProps) => CSSInterpolation; +}; + +function DropdownItem({ + dropdownItem, + onClick, +}: DropdownItemProps) { + const theme = useTheme(); + + const dropdownItemIcon = + dropdownItem.getIcon && dropdownItem.getIcon({ dropdownItem, theme }); + const dropdownItemIconNode = dropdownItemIcon ? ( + + ) : null; + + const cssProp = + dropdownItem.getCSS && dropdownItem.getCSS({ theme, dropdownItem }); + + const onClickDropdownItem = useCallback(() => { + dropdownItem.onClick && dropdownItem.onClick(dropdownItem); + onClick && onClick(); + }, [dropdownItem, onClick]); + + // to may be '' for the current route so check !== undefined + if (dropdownItem.to !== undefined || dropdownItem.externalLink) { + // single nav item + + return ( + + selected={Boolean(dropdownItem.selected)} + to={dropdownItem.to} + externalLink={dropdownItem.externalLink} + params={dropdownItem.params} + onClick={onClickDropdownItem} + css={cssProp} + hash={dropdownItem.hash} + > + {dropdownItemIconNode} + {dropdownItem.label} + + ); + } + + return ( + + {dropdownItemIconNode} + {dropdownItem.label} + + ); +} + +const DropdownButton = styled(UnstyledButton)` + ${standardFocusOutline} +`; + +type DropdownItemStyleProps = WithTheme<{ selected?: boolean }>; + +const dropdownItemStyle = ({ selected, theme }: DropdownItemStyleProps) => ` + background-color: ${selected ? theme.primary : "transparent"}; + box-sizing: border-box; + cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; + height: 100%; + outline-color: ${selected ? theme.onPrimaryText : theme.hcNeutral} !important; + white-space: nowrap; + + padding: 8px 16px; + ${bp.sm(`padding-left: ${smHeaderLogoMarginLeft};`)} + + &:hover { + color: ${theme.hcNeutral} !important; + } +`; + +type StyledDropdownItemProps = { + selected?: boolean; +}; + +const cssProp = ({ + theme, + selected, +}: ThemeProp & StyledDropdownItemProps) => css` + ${dropdownItemStyle({ theme, selected: Boolean(selected) })} + ${standardFocusOutline({ theme })} +`; + +function DropdownItemLink( + props: StyledDropdownItemProps & LinkProps, +) { + const theme = useTheme(); + return ( + + {...props} + css={cssProp({ theme, selected: Boolean(props.selected) })} + /> + ); +} + +const DropdownItemDiv = styled.div` + ${dropdownItemStyle} + ${standardFocusOutline} +`; diff --git a/packages/ui/src/components/FileInput.tsx b/packages/ui/src/components/FileInput.tsx index 53a889578..a3e9abc34 100644 --- a/packages/ui/src/components/FileInput.tsx +++ b/packages/ui/src/components/FileInput.tsx @@ -1,7 +1,7 @@ import styled from "@emotion/styled"; import { Fragment, useRef, useState } from "react"; import { InputSubtext, inputBlockStyle } from "../styles/fields.js"; -import { Icon } from "./Icon.js"; +import { Icon } from "./Icon/Icon.js"; import { Pill } from "./Pill.js"; type FileInputProps = { diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx deleted file mode 100644 index 7cb949cc0..000000000 --- a/packages/ui/src/components/Icon.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import styled from "@emotion/styled"; -import { useLayoutEffect, useState } from "react"; -import { rootFontSizePx } from "../styles/common.js"; -import { isString } from "../utils/strings.js"; - -type Component = () => JSX.Element; - -type IconMap = { - [key: string]: Component; -}; - -const iconMap: IconMap = {}; - -async function loadIcon(iconName: string) { - let IconComp: Component; - try { - ({ default: IconComp } = (await import( - /* webpackMode: "eager" */ - // this can't be a variable only, needs to have some string constraints: - `./icons/${iconName}.js` - )) as { default: Component }); - } catch (e) { - throw new Error(`Icon ${iconName} not found`); - } - iconMap[iconName] = IconComp; -} - -type IconProps = { - className?: string; - name: string; - width: number; - mr?: number; - ml?: number; - verticalAlign?: "middle" | "top" | "bottom" | "super" | number; - color?: string | undefined; - tutorialStep?: number; -}; - -export function Icon({ - className, - name, - width, - tutorialStep, - mr = 0, - ml = 0, - verticalAlign = "middle", - color = undefined, -}: IconProps) { - const [, setLoading] = useState(false); - - const IconComponent = iconMap[name] || null; - - useLayoutEffect(() => { - void (async () => { - if (!iconMap[name]) { - setLoading(true); - await loadIcon(name); - setLoading(false); - } - })(); - }, [name]); - - // Assume width is px relative to the root font size but specify in ems to - // preserve scale for larger font sizes - const w = parseFloat((width / rootFontSizePx).toFixed(2)); - const mrRems = parseFloat((mr / rootFontSizePx).toFixed(2)); - const mlRems = parseFloat((ml / rootFontSizePx).toFixed(2)); - const va = - typeof verticalAlign === "string" - ? verticalAlign - : parseFloat((verticalAlign / rootFontSizePx).toFixed(2)); - - return ( - - {IconComponent ? : null} - - ); -} - -type IconContainerProps = { - w: number; - mr: number; - ml: number; - verticalAlign: string | number; - fontColor?: string | undefined; -}; - -export const IconContainer = styled.span` - pointer-events: none; - display: inline-flex; - ${({ mr, ml, w }) => ` - width: ${w}em; - /* ensure no shrink in flex containers: */ - min-width: ${w}em; - ${mr ? `margin-right: ${mr}em;` : ""} - ${ml ? `margin-left: ${ml}em;` : ""} - `} - - vertical-align: ${({ verticalAlign }) => - isString(verticalAlign) ? verticalAlign : `${verticalAlign}em`}; - - ${({ fontColor }) => ` - & svg { - color: ${fontColor || "inherit"}; - } - `} -`; diff --git a/packages/ui/src/components/Icon/Icon.tsx b/packages/ui/src/components/Icon/Icon.tsx new file mode 100644 index 000000000..72a31124a --- /dev/null +++ b/packages/ui/src/components/Icon/Icon.tsx @@ -0,0 +1,107 @@ +"use client"; + +import styled from "@emotion/styled"; +import { invertFillColor, invertStrokeColor } from "../../icons/constants.js"; +import * as icons from "../../icons/index.js"; +import { rootFontSizePx } from "../../styles/common.js"; +import { getFontColor, type FontColorKey } from "../../styles/themes.js"; +import { isString } from "../../utils/strings.js"; +import type { IconName } from "./types.js"; + +type IconProps = { + className?: string | undefined; + name: IconName; + width: number; + mr?: number | undefined; + ml?: number | undefined; + verticalAlign?: "middle" | "top" | "bottom" | "super" | number; + color?: FontColorKey | undefined; + tutorialStep?: number; +}; + +export function Icon({ + className, + name, + width, + tutorialStep, + mr = 0, + ml = 0, + color = undefined, + verticalAlign = "middle", +}: IconProps) { + const IconComponent = icons[name] || null; + + /** Assume width is px relative to the root font size but specify + * in ems to preserve scale for larger font sizes */ + const w = parseFloat((width / rootFontSizePx).toFixed(2)); + const mrRems = parseFloat((mr / rootFontSizePx).toFixed(2)); + const mlRems = parseFloat((ml / rootFontSizePx).toFixed(2)); + const va = + typeof verticalAlign === "string" + ? verticalAlign + : parseFloat((verticalAlign / rootFontSizePx).toFixed(2)); + + return ( + + {IconComponent ? : null} + + ); +} + +type IconContainerProps = { + w: number; + mr: number; + ml: number; + verticalAlign: string | number; + fontColor?: FontColorKey | undefined; +}; + +export const IconContainer = styled.span` + pointer-events: none; + display: inline-flex; + ${({ mr, ml, w }) => ` + width: ${w}em; + /* ensure no shrink in flex containers: */ + min-width: ${w}em; + ${mr ? `margin-right: ${mr}em;` : ""} + ${ml ? `margin-left: ${ml}em;` : ""} + `} + + vertical-align: ${({ verticalAlign }) => + isString(verticalAlign) ? verticalAlign : `${verticalAlign}em`}; + + ${({ theme, fontColor }) => { + const color = getFontColor(theme, fontColor, "inherit"); + return ` + & svg { + color: ${getFontColor(theme, fontColor, "inherit")}; + + /* + Provide a way for an SVG to invert relative to a specified color for + dark mode support. Ideally we would just use filters, which works in + Chrome even if the color is not specified, but doesn't work at all for + SVG paths in Safari: + filter: invert(100%); -webkit-filter: invert(100%); + + Instead we'll need a class for fills and a class for strokes. Icons + that need inverted colors should use these classes on their paths. + */ + + .${invertFillColor} { + fill: ${fontColor ? theme.hcNeutralFromBg(color) : "currentColor"}; + } + .${invertStrokeColor} { + stroke: ${fontColor ? theme.hcNeutralFromBg(color) : "currentColor"}; + } + } + `; + }} +`; diff --git a/packages/ui/src/components/Icon/types.tsx b/packages/ui/src/components/Icon/types.tsx new file mode 100644 index 000000000..a3e94fd9c --- /dev/null +++ b/packages/ui/src/components/Icon/types.tsx @@ -0,0 +1,3 @@ +import type * as icons from "../../icons/index.js"; + +export type IconName = keyof typeof icons; diff --git a/packages/ui/src/components/InfoIconTooltip.tsx b/packages/ui/src/components/InfoIconTooltip.tsx new file mode 100644 index 000000000..5ef37e620 --- /dev/null +++ b/packages/ui/src/components/InfoIconTooltip.tsx @@ -0,0 +1,30 @@ +import { Fragment } from "react"; +import { type TypographyTypeKey } from "../styles/tokens/typography.js"; +import { toReactNodes, type ToReactNodesArgs } from "../utils/toReactNodes.js"; +import { Icon } from "./Icon/Icon.js"; +import { Tooltip } from "./Tooltip.js"; + +type InfoIconTooltipProps = { + id: string; + content: ToReactNodesArgs; + verticalAlign?: "middle" | number; +}; + +export function InfoIconTooltip({ + id, + content, + verticalAlign = "middle", +}: InfoIconTooltipProps) { + const contentNodes = toReactNodes(content); + return ( + +
    + +
    + contentNodes} clickable /> +
    + ); +} diff --git a/packages/ui/src/components/LightsparkProvider.tsx b/packages/ui/src/components/LightsparkProvider.tsx index 2c306a10e..b885b25a8 100644 --- a/packages/ui/src/components/LightsparkProvider.tsx +++ b/packages/ui/src/components/LightsparkProvider.tsx @@ -1,6 +1,6 @@ import { ThemeProvider } from "@emotion/react"; -import { themes } from "../styles/colors.js"; import { GlobalStyles } from "../styles/global.js"; +import { themes } from "../styles/themes.js"; type LightsparkProviderProps = { children: React.ReactNode; diff --git a/packages/ui/src/components/Loading.tsx b/packages/ui/src/components/Loading.tsx index 80b916939..0522a53da 100644 --- a/packages/ui/src/components/Loading.tsx +++ b/packages/ui/src/components/Loading.tsx @@ -1,5 +1,6 @@ +import { useTheme } from "@emotion/react"; import styled from "@emotion/styled"; -import { Icon } from "./Icon.js"; +import { Icon } from "./Icon/Icon.js"; type Props = { center?: boolean; @@ -18,10 +19,11 @@ export function Loading({ size = defaultProps.size, ml = defaultProps.ml, }: Props) { + const theme = useTheme(); return ( - + ); diff --git a/packages/ui/src/components/Modal.tsx b/packages/ui/src/components/Modal.tsx index 0222eb7fb..4d5f40932 100644 --- a/packages/ui/src/components/Modal.tsx +++ b/packages/ui/src/components/Modal.tsx @@ -12,20 +12,46 @@ import { standardContentInset, standardFocusOutline, } from "../styles/common.js"; -import { overflowAutoWithoutScrollbars, pxToRems } from "../styles/utils.js"; +import { Spacing } from "../styles/tokens/spacing.js"; +import { TokenSize } from "../styles/tokens/typography.js"; +import { overflowAutoWithoutScrollbars } from "../styles/utils.js"; import { z } from "../styles/z-index.js"; import { select } from "../utils/emotion.js"; -import { toReactNodes } from "../utils/toReactNodes.js"; -import { Button } from "./Button.js"; -import { Icon } from "./Icon.js"; +import { toReactNodes, type ToReactNodesArgs } from "../utils/toReactNodes.js"; +import { Button, ButtonSelector } from "./Button.js"; +import { Drawer } from "./Drawer.js"; +import { Icon } from "./Icon/Icon.js"; import { ProgressBar, type ProgressBarProps } from "./ProgressBar.js"; import { UnstyledButton } from "./UnstyledButton.js"; +import { Body } from "./typography/Body.js"; +import { Headline, headlineSelector } from "./typography/Headline.js"; -type ModalProps = { +export interface ExtraAction { + /** Determines the placement relative to the submission/cancel buttons. */ + placement: "above" | "below"; + content: React.ReactNode; +} + +type SubmitLinkWithRoute = { + to: RoutesType; +}; + +type SubmitLinkWithHref = { + href: string; + filename?: string; +}; + +function isSubmitLinkWithHref( + submitLink: SubmitLinkWithRoute | SubmitLinkWithHref | undefined, +): submitLink is SubmitLinkWithHref { + return Boolean(submitLink && "href" in submitLink); +} + +type ModalProps = { visible: boolean; onClose: () => void; - title?: string; - description?: string | undefined; + title?: ToReactNodesArgs; + description?: ToReactNodesArgs; cancelText?: string | undefined; cancelDisabled?: boolean; cancelHidden?: boolean; @@ -35,6 +61,11 @@ type ModalProps = { submitDisabled?: boolean; submitLoading?: boolean; submitText?: string; + submitLink?: + | { + to: RoutesType; + } + | SubmitLinkWithHref; children?: React.ReactNode; /* most of the time this is an Element but not in the case of Select - it * just extends .focus method: */ @@ -42,12 +73,17 @@ type ModalProps = { /* This should always be true for accessibility purposes unless you are * managing focus in a child component */ autoFocus?: boolean; + smDrawer?: boolean; nonDismissable?: boolean; width?: 460 | 600; progressBar?: ProgressBarProps; + /** Determines if buttons are laid out horizontally or vertically */ + buttonLayout?: "horizontal" | "vertical"; + /** Allows placing extra buttons in the same button layout */ + extraActions?: ExtraAction[]; }; -export function Modal({ +export function Modal({ visible, title, description, @@ -61,13 +97,17 @@ export function Modal({ submitDisabled, submitLoading, submitText, + submitLink, cancelText = "Cancel", firstFocusRef, + smDrawer, nonDismissable = false, autoFocus = true, width = 460, progressBar, -}: ModalProps) { + buttonLayout = "horizontal", + extraActions, +}: ModalProps) { const visibleChangedRef = useRef(false); const nodeRef = useRef(null); const [defaultFirstFocusRef, defaultFirstFocusRefCb] = useLiveRef(); @@ -78,7 +118,6 @@ export function Modal({ const modalContainerRef = useRef(null); const bp = useBreakpoints(); const isSm = bp.current(Breakpoints.sm); - const formattedDescription = description ? toReactNodes(description) : null; useEffect(() => { if (visible !== visibleChangedRef.current) { @@ -160,8 +199,8 @@ export function Modal({ } }, [visible, visibleChanged, ref, autoFocus]); - function onClickCloseButton(event: React.MouseEvent) { - event.stopPropagation(); + function onClickCloseButton(event?: React.MouseEvent) { + event?.stopPropagation(); onClose(); } @@ -180,75 +219,134 @@ export function Modal({ } } + const linkIsHref = isSubmitLinkWithHref(submitLink); + const linkIsRoute = !linkIsHref && submitLink; + + const buttonContent = ( + <> + {extraActions + ?.filter((action) => action.placement === "above") + .map((action) => action.content)} + {!isSm && !cancelHidden && ( +