├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── main.yml │ └── tests.yaml ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── CONTRIBUTING.MD ├── LICENSE ├── README.md ├── gh-logo.jpg ├── package.json ├── packages ├── react-laag │ ├── create-entry.js │ ├── package.json │ ├── rollup.config.js │ ├── sandbox │ │ ├── InfoBox.tsx │ │ ├── OptionsPanel │ │ │ ├── PlacementSelect.tsx │ │ │ ├── RadioGroup.tsx │ │ │ └── index.tsx │ │ ├── TestCase.tsx │ │ ├── constants.ts │ │ ├── index.html │ │ ├── main.tsx │ │ ├── options.ts │ │ └── util-hooks.ts │ ├── snowpack.config.js │ ├── src │ │ ├── Arrow.ts │ │ ├── Bounds.ts │ │ ├── BoundsOffsets.ts │ │ ├── Placement.ts │ │ ├── PlacementType.ts │ │ ├── Placements.ts │ │ ├── Sides.ts │ │ ├── SubjectsBounds.ts │ │ ├── Transition.ts │ │ ├── getArrowStyle.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── types.ts │ │ ├── useGroup.ts │ │ ├── useHover.ts │ │ ├── useLayer.ts │ │ ├── useTrackElements.ts │ │ └── util.ts │ ├── ssr-test.js │ ├── tests │ │ ├── Bounds.spec.ts │ │ ├── BoundsOffsets.spec.ts │ │ ├── Placement.spec.ts │ │ ├── Placements.spec.ts │ │ ├── SubjectsBounds.spec.ts │ │ ├── integration │ │ │ ├── misc-behavior.spec.tsx │ │ │ ├── no-overflow-container.spec.tsx │ │ │ ├── overflow-container.spec.tsx │ │ │ └── util.tsx │ │ ├── test-util.ts │ │ └── useTrackElements.spec.tsx │ ├── tsconfig.json │ └── web-test-runner.config.js ├── storybook │ ├── code-block.js │ ├── components │ │ ├── Button.tsx │ │ ├── Input.tsx │ │ ├── Menu.tsx │ │ ├── ScrollBox.tsx │ │ └── Tooltip.tsx │ ├── main.css │ ├── main.js │ ├── manager-head.html │ ├── manager.js │ ├── package.json │ ├── preview.js │ ├── sblogo.png │ ├── stories │ │ ├── Autocomplete │ │ │ ├── autocomplete.stories.mdx │ │ │ └── example.tsx │ │ ├── CircularMenu │ │ │ ├── Button.tsx │ │ │ ├── MenuItem.tsx │ │ │ ├── circular-menu.stories.mdx │ │ │ ├── constants.ts │ │ │ └── example.tsx │ │ ├── MouseFollow │ │ │ ├── example.tsx │ │ │ └── mouse-follow.stories.mdx │ │ ├── NestedMenus │ │ │ ├── example.tsx │ │ │ └── nested-menus.stories.mdx │ │ ├── PasswordValidation │ │ │ ├── example.tsx │ │ │ └── password-validation.stories.mdx │ │ ├── PopoverMenu │ │ │ ├── example.tsx │ │ │ └── popover-menu.stories.mdx │ │ ├── TextSelection │ │ │ ├── example.tsx │ │ │ └── text-selection.stories.mdx │ │ └── Tooltip │ │ │ └── tooltip.stories.mdx │ ├── theme.js │ └── tsconfig.json └── website │ ├── .eslintrc │ ├── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico │ ├── fonts │ ├── luckiest-guy-v10-latin-regular.eot │ ├── luckiest-guy-v10-latin-regular.svg │ ├── luckiest-guy-v10-latin-regular.ttf │ ├── luckiest-guy-v10-latin-regular.woff │ ├── luckiest-guy-v10-latin-regular.woff2 │ ├── noto-sans-v9-latin-700.eot │ ├── noto-sans-v9-latin-700.svg │ ├── noto-sans-v9-latin-700.ttf │ ├── noto-sans-v9-latin-700.woff │ ├── noto-sans-v9-latin-700.woff2 │ ├── noto-sans-v9-latin-regular.eot │ ├── noto-sans-v9-latin-regular.svg │ ├── noto-sans-v9-latin-regular.ttf │ ├── noto-sans-v9-latin-regular.woff │ └── noto-sans-v9-latin-regular.woff2 │ ├── gatsby-browser.js │ ├── gatsby-config.js │ ├── logo.svg │ ├── package.json │ ├── src │ ├── components │ │ ├── CopyButton.tsx │ │ ├── Features.tsx │ │ ├── InstallBox.tsx │ │ ├── Logo.tsx │ │ ├── NotifyTip.tsx │ │ ├── Playground │ │ │ ├── Checkbox.tsx │ │ │ ├── Code.tsx │ │ │ ├── Label.tsx │ │ │ ├── Options.tsx │ │ │ ├── PlacementSelect.tsx │ │ │ ├── Preview.tsx │ │ │ ├── Radio.tsx │ │ │ ├── RadioGroup.tsx │ │ │ ├── Range.tsx │ │ │ ├── Slider.tsx │ │ │ ├── index.tsx │ │ │ └── types.ts │ │ ├── PrimaryButton.tsx │ │ ├── SecondaryButton.tsx │ │ ├── WithTooltip.tsx │ │ └── seo.tsx │ ├── main.css │ ├── pages │ │ ├── 404.tsx │ │ └── index.tsx │ ├── prism.css │ ├── theme.ts │ ├── useMedia.tsx │ └── util.ts │ └── tsconfig.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package.json 3 | public 4 | externs.js 5 | ssr-test.js 6 | dist 7 | node_modules 8 | storybook-static 9 | 10 | # eslint has somehow trouble with ts enums 11 | # useHover.ts -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | parserOptions: { 4 | ecmaVersion: 2020, 5 | sourceType: "module" 6 | }, 7 | extends: ["react-app", "prettier", "plugin:prettier/recommended"], 8 | settings: { 9 | react: { 10 | version: "detect" 11 | } 12 | }, 13 | rules: { 14 | "react-hooks/exhaustive-deps": "error", 15 | "no-unused-vars": "off", 16 | "@typescript-eslint/no-redeclare": "off", 17 | "@typescript-eslint/no-unused-vars": "off", 18 | "no-use-before-define": "off", 19 | "@typescript-eslint/no-use-before-define": ["error"] 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Browser / OS (please complete the following information):** 27 | - OS: [e.g. windows] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **(Optional) Do you want to work on this feature?** 20 | Do you already have ideas / strategies how to solve the problem? Does it impact the API? In which way? 21 | 22 | **Additional context** 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: master-workflow 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - name: Get yarn cache directory path 15 | id: yarn-cache-dir-path 16 | run: echo "::set-output name=dir::$(yarn cache dir)" 17 | 18 | - name: restore yarn cache directory 19 | uses: actions/cache@v2 20 | id: yarn-cache 21 | with: 22 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 23 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.os }}-yarn- 26 | 27 | - name: Install Dependencies 28 | run: yarn install --frozen-lockfile --prefer-offline 29 | 30 | - name: Run tests 31 | run: yarn test 32 | 33 | - name: Build react-laag 34 | run: yarn build 35 | 36 | - name: Build website 37 | run: "yarn website:build" 38 | 39 | - name: Deploy website 40 | uses: garygrossgarten/github-action-scp@release 41 | with: 42 | local: packages/website/public 43 | remote: /home/webdev/react-laag-docs 44 | rmRemote: true 45 | host: ${{ secrets.SERVER_HOST }} 46 | username: ${{ secrets.SERVER_USER }} 47 | password: ${{ secrets.SERVER_PASSWORD }} 48 | 49 | - name: Build storybook 50 | run: "yarn storybook:build" 51 | 52 | - name: Deploy storybook 53 | uses: garygrossgarten/github-action-scp@release 54 | with: 55 | local: packages/storybook/storybook-static 56 | remote: /home/webdev/storybook-react-laag 57 | rmRemote: true 58 | host: ${{ secrets.SERVER_HOST }} 59 | username: ${{ secrets.SERVER_USER }} 60 | password: ${{ secrets.SERVER_PASSWORD }} 61 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | on: [pull_request] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v2 9 | 10 | - name: Get yarn cache directory path 11 | id: yarn-cache-dir-path 12 | run: echo "::set-output name=dir::$(yarn cache dir)" 13 | 14 | - name: restore yarn cache directory 15 | uses: actions/cache@v2 16 | id: yarn-cache 17 | with: 18 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 19 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 20 | restore-keys: | 21 | ${{ runner.os }}-yarn- 22 | 23 | - name: Install Dependencies 24 | run: yarn install --frozen-lockfile --prefer-offline 25 | 26 | - name: Run tests 27 | run: yarn test 28 | 29 | - name: Typecheck 30 | run: yarn typecheck 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.log 3 | .DS_Store 4 | node_modules 5 | .cache 6 | 7 | # misc 8 | .DS_Store 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # yarn 16 | yarn-error.log 17 | .yarn-integrity 18 | 19 | # build artifacts 20 | dist 21 | public 22 | storybook-static -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package.json 3 | public 4 | externs.js 5 | ssr-test.js 6 | dist 7 | yarn.lock -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.tabSize": 2, 4 | "typescript.tsdk": "node_modules/typescript/lib", 5 | "typescript.preferences.importModuleSpecifier": "relative", 6 | "typescript.enablePromptUseWorkspaceTsdk": true, 7 | "typescript.reportStyleChecksAsWarnings": false 8 | } 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## General 4 | 5 | ### Bugs 6 | 7 | Open issues can be found on the GitHub [issues](https://github.com/everweij/react-laag/labels/bug) page with a "Bug" label. 8 | 9 | These are a great place to start contributing to the repo! 10 | 11 | Once you start work on a bug, post your intent on the issue itself. This will prevent more than one person working on it at once. 12 | 13 | If the bug you want to work on doesn't have a related issue, open one, and attach the "Bug" label. 14 | 15 | ### New features 16 | 17 | Before adding any features, open a [Feature Proposal](https://github.com/everweij/react-laag/issues/new/choose). 18 | 19 | This will let us talk through your proposal before you spend time on it. 20 | 21 | react-laag consists of various low-level building blocks. This is a huge part in what makes react-laag so flexible. Keep this in mind when proposing a new feature -> does this benefit all of us, or is the feature a more specific use-case? 22 | 23 | ### Documentation 24 | 25 | If a PR introduces or changes API, please make sure to update the relevant docs (readme) or examples (storybook) as well. 26 | 27 | ## Development 28 | 29 | ### Getting started 30 | 31 | In order to get started with your PR: 32 | 33 | 1. Fork the react-laag repo 34 | 2. Clone york fork locally 35 | 3. run `yarn install` (make sure you have `yarn` installed on your machine) 36 | 4. See the scripts-section of the workspace `package.json` for a list of available commands 37 | 5. Add your code and supporting tests 38 | 6. If this is a feature that requires doc changes, make as necessary. 39 | 7. You're ready! 40 | 41 | ### Repo structure 42 | 43 | This repo leverages yarn workspaces to split this repo into multiple packages: 44 | 45 | - react-laag -> source code of the actual library 46 | - website -> the front page of react-laag.com, built with Gatsby 47 | - storybook -> an isolated place to develop examples 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Erik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /gh-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/gh-logo.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "react-laag", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "husky": { 10 | "hooks": { 11 | "pre-commit": "eslint packages --fix" 12 | } 13 | }, 14 | "prettier": { 15 | "printWidth": 80, 16 | "semi": true, 17 | "singleQuote": false, 18 | "tabWidth": 2, 19 | "trailingComma": "none", 20 | "arrowParens": "avoid" 21 | }, 22 | "scripts": { 23 | "lint": "eslint packages --fix", 24 | "build": "yarn workspace react-laag build", 25 | "release": "yarn workspace react-laag publish", 26 | "test": "yarn workspace react-laag test", 27 | "test:watch": "yarn workspace react-laag test:watch", 28 | "sandbox": "yarn workspace react-laag sandbox", 29 | "website": "yarn workspace react-laag-website start", 30 | "website:build": "yarn workspace react-laag-website build", 31 | "website:serve": "yarn workspace react-laag-website serve", 32 | "storybook": "yarn workspace react-laag-storybook start", 33 | "storybook:build": "yarn workspace react-laag-storybook build", 34 | "typecheck": "yarn workspace react-laag typecheck" 35 | }, 36 | "devDependencies": { 37 | "@typescript-eslint/eslint-plugin": "^4.27.0", 38 | "@typescript-eslint/parser": "^4.27.0", 39 | "babel-eslint": "^10.0.0", 40 | "eslint": "^7.28.0", 41 | "eslint-config-prettier": "^8.3.0", 42 | "eslint-config-react-app": "^6.0.0", 43 | "eslint-plugin-flowtype": "^5.7.2", 44 | "eslint-plugin-import": "^2.23.4", 45 | "eslint-plugin-jsx-a11y": "^6.4.1", 46 | "eslint-plugin-prettier": "^3.4.0", 47 | "eslint-plugin-react": "^7.24.0", 48 | "eslint-plugin-react-hooks": "^4.2.0", 49 | "husky": "^4.3.0", 50 | "prettier": "^2.3.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/react-laag/create-entry.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const content = ` 5 | if (process.env.NODE_ENV === "production") { 6 | module.exports = require("./react-laag.cjs.production.min.js"); 7 | } else { 8 | module.exports = require("./react-laag.cjs.development.js"); 9 | } 10 | `.trim(); 11 | 12 | fs.writeFileSync(path.join(__dirname, "dist", "index.js"), content, { 13 | encoding: "utf-8" 14 | }); 15 | -------------------------------------------------------------------------------- /packages/react-laag/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.5", 3 | "license": "MIT", 4 | "name": "react-laag", 5 | "author": "Erik Verweij", 6 | "homepage": "https://www.react-laag.com/", 7 | "repository": "everweij/react-laag", 8 | "module": "dist/react-laag.esm.js", 9 | "main": "dist/index.js", 10 | "typings": "dist/index.d.ts", 11 | "sideEffects": false, 12 | "files": [ 13 | "dist" 14 | ], 15 | "scripts": { 16 | "start": "tsc --noEmit", 17 | "build": "rimraf dist && rollup -c && node create-entry.js && yarn ssr-test && yarn size", 18 | "prepare": "yarn build", 19 | "ssr-test": "node ssr-test.js", 20 | "sandbox": "snowpack dev", 21 | "size": "size-limit", 22 | "test": "wtr \"./tests/**/*.spec.(ts|tsx)\" --node-resolve --puppeteer", 23 | "test:watch": "wtr \"./tests/**/*.spec.(ts|tsx)\" --node-resolve --puppeteer --watch", 24 | "typecheck": "tsc --noEmit" 25 | }, 26 | "size-limit": [ 27 | { 28 | "path": "dist/react-laag.cjs.production.min.js", 29 | "limit": "10 KB" 30 | }, 31 | { 32 | "path": "dist/react-laag.esm.js", 33 | "limit": "10 KB" 34 | } 35 | ], 36 | "devDependencies": { 37 | "@babel/core": "^7.14.6", 38 | "@babel/plugin-proposal-class-properties": "^7.14.5", 39 | "@babel/preset-env": "^7.14.5", 40 | "@rollup/plugin-babel": "^5.3.0", 41 | "@rollup/plugin-commonjs": "^19.0.0", 42 | "@rollup/plugin-node-resolve": "^13.0.0", 43 | "@rollup/plugin-replace": "^2.4.2", 44 | "@size-limit/preset-small-lib": "^4.12.0", 45 | "@snowpack/plugin-react-refresh": "^2.5.0", 46 | "@snowpack/plugin-typescript": "^1.2.1", 47 | "@snowpack/web-test-runner-plugin": "0.2.2", 48 | "@testing-library/react": "^11.2.7", 49 | "@types/react": "^17.0.11", 50 | "@types/react-dom": "^17.0.7", 51 | "@web/test-runner": "^0.13.5", 52 | "@web/test-runner-commands": "0.5.0", 53 | "@web/test-runner-puppeteer": "^0.10.0", 54 | "babel-plugin-annotate-pure-calls": "^0.4.0", 55 | "babel-plugin-dev-expression": "^0.2.2", 56 | "babel-plugin-macros": "^3.1.0", 57 | "babel-plugin-polyfill-regenerator": "^0.2.2", 58 | "babel-plugin-transform-rename-import": "^2.3.0", 59 | "copy-text-to-clipboard": "^3.0.1", 60 | "expect": "^25.5.0", 61 | "react": "^17.0.2", 62 | "react-dom": "^17.0.2", 63 | "react-is": "^17.0.2", 64 | "rimraf": "^3.0.2", 65 | "rollup": "^2.51.2", 66 | "rollup-plugin-sourcemaps": "^0.6.3", 67 | "rollup-plugin-terser": "^7.0.2", 68 | "rollup-plugin-typescript2": "^0.30.0", 69 | "size-limit": "^4.11.0", 70 | "snowpack": "3.5.4", 71 | "tslib": "^2.3.0", 72 | "typescript": "^4.3.2" 73 | }, 74 | "dependencies": { 75 | "tiny-warning": "^1.0.3" 76 | }, 77 | "peerDependencies": { 78 | "react": "^16.0.0 || ^17.0.0 || ^18.0.0", 79 | "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/react-laag/rollup.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { terser } = require("rollup-plugin-terser"); 3 | const { DEFAULT_EXTENSIONS: DEFAULT_BABEL_EXTENSIONS } = require("@babel/core"); 4 | const commonjs = require("@rollup/plugin-commonjs"); 5 | const replace = require("@rollup/plugin-replace"); 6 | const { default: resolve } = require("@rollup/plugin-node-resolve"); 7 | const sourceMaps = require("rollup-plugin-sourcemaps"); 8 | const typescript = require("rollup-plugin-typescript2"); 9 | const { babel } = require("@rollup/plugin-babel"); 10 | const ts = require("typescript"); 11 | 12 | const tsConfigPath = path.join(__dirname, "tsconfig.json"); 13 | const tsconfigJSON = ts.readConfigFile(tsConfigPath, ts.sys.readFile).config; 14 | 15 | const tsCompilerOptions = ts.parseJsonConfigFileContent( 16 | tsconfigJSON, 17 | ts.sys, 18 | "./" 19 | ).options; 20 | 21 | const external = id => !id.startsWith(".") && !path.isAbsolute(id); 22 | 23 | const getOutputName = (format, minify, env) => 24 | [ 25 | path.join(__dirname, "dist", "react-laag"), 26 | format, 27 | env, 28 | minify ? "min" : "", 29 | "js" 30 | ] 31 | .filter(Boolean) 32 | .join("."); 33 | 34 | const outputBase = { 35 | freeze: false, 36 | esModule: Boolean(tsCompilerOptions?.esModuleInterop), 37 | name: "react-laag", 38 | sourcemap: true, 39 | globals: { react: "React" }, 40 | exports: "named" 41 | }; 42 | 43 | /** @type {import("rollup").RollupOptions } */ 44 | const options = { 45 | input: path.join(__dirname, "src", "index.ts"), 46 | 47 | external: id => (id.startsWith("regenerator-runtime") ? false : external(id)), 48 | 49 | treeshake: { 50 | propertyReadSideEffects: false 51 | }, 52 | 53 | output: [ 54 | { 55 | file: getOutputName("cjs", true, "production"), 56 | format: "cjs", 57 | ...outputBase, 58 | plugins: [ 59 | terser({ 60 | output: { comments: false }, 61 | compress: { 62 | keep_infinity: true, 63 | pure_getters: true, 64 | passes: 10 65 | }, 66 | ecma: 5, 67 | toplevel: true, 68 | warnings: true 69 | }) 70 | ] 71 | }, 72 | { 73 | file: getOutputName("cjs", false, "development"), 74 | format: "cjs", 75 | ...outputBase 76 | }, 77 | { 78 | file: getOutputName("esm", false, undefined), 79 | format: "esm", 80 | ...outputBase 81 | } 82 | ], 83 | 84 | plugins: [ 85 | resolve({ 86 | mainFields: ["module", "main", "browser"] 87 | }), 88 | commonjs({ 89 | include: /\/regenerator-runtime\// 90 | }), 91 | typescript({ 92 | typescript: ts, 93 | tsconfig: tsConfigPath, 94 | tsconfigDefaults: { 95 | exclude: [ 96 | "**/*.spec.ts", 97 | "**/*.test.ts", 98 | "**/*.spec.tsx", 99 | "**/*.test.tsx", 100 | "node_modules", 101 | "dist" 102 | ], 103 | compilerOptions: { 104 | sourceMap: true, 105 | declaration: true, 106 | jsx: "react" 107 | } 108 | }, 109 | tsconfigOverride: { 110 | compilerOptions: { 111 | target: "esnext" 112 | } 113 | }, 114 | check: true, 115 | useTsconfigDeclarationDir: Boolean(tsCompilerOptions?.declarationDir) 116 | }), 117 | babel({ 118 | extensions: [...DEFAULT_BABEL_EXTENSIONS, "ts", "tsx"], 119 | exclude: "node_modules/**", 120 | plugins: [ 121 | "babel-plugin-macros", 122 | "babel-plugin-annotate-pure-calls", 123 | "babel-plugin-dev-expression", 124 | ["babel-plugin-polyfill-regenerator", { method: "usage-pure" }], 125 | ["@babel/plugin-proposal-class-properties", { loose: true }] 126 | ], 127 | presets: [ 128 | [ 129 | "@babel/preset-env", 130 | { 131 | modules: false, 132 | loose: true, 133 | targets: "last 3 versions, IE 11, not dead" 134 | } 135 | ] 136 | ], 137 | babelHelpers: "bundled" 138 | }), 139 | sourceMaps() 140 | ] 141 | }; 142 | 143 | module.exports = options; 144 | -------------------------------------------------------------------------------- /packages/react-laag/sandbox/InfoBox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { LayerSide } from "../src"; 3 | import { IBounds } from "../src/Bounds"; 4 | import copyText from "copy-text-to-clipboard"; 5 | 6 | const baseStyle: React.CSSProperties = { 7 | position: "fixed", 8 | top: 0, 9 | right: 0, 10 | border: "1px solid black", 11 | padding: 8, 12 | backgroundColor: "white" 13 | }; 14 | 15 | const bold: React.CSSProperties = { fontWeight: "bold" }; 16 | 17 | type Bounds = { 18 | trigger: IBounds | null; 19 | layer: IBounds | null; 20 | arrow: IBounds | null; 21 | }; 22 | 23 | function createBoundsFromElement( 24 | element: HTMLElement | SVGElement 25 | ): IBounds | null { 26 | if (!element || !element.isConnected) { 27 | return null; 28 | } 29 | 30 | const { top, left, bottom, right, width, height } = 31 | element.getBoundingClientRect(); 32 | return { 33 | top, 34 | left, 35 | bottom, 36 | right, 37 | width, 38 | height 39 | }; 40 | } 41 | 42 | function areBoundsEqual(prev: Bounds, next: Bounds): boolean { 43 | const keys: (keyof IBounds)[] = [ 44 | "top", 45 | "bottom", 46 | "left", 47 | "right", 48 | "width", 49 | "height" 50 | ]; 51 | for (const key of keys) { 52 | if ( 53 | Object.keys(prev).some(type => prev[type]?.[key] !== next[type]?.[key]) 54 | ) { 55 | return false; 56 | } 57 | } 58 | 59 | return true; 60 | } 61 | 62 | type InfoBoxProps = { 63 | renderCount: number; 64 | triggerRef: React.MutableRefObject; 65 | layerRef: React.MutableRefObject; 66 | arrowRef: React.MutableRefObject; 67 | layerSide: LayerSide; 68 | }; 69 | 70 | export function InfoBox({ 71 | renderCount, 72 | layerRef, 73 | triggerRef, 74 | arrowRef, 75 | layerSide 76 | }: InfoBoxProps) { 77 | const boundsRef = React.useRef({ 78 | layer: null, 79 | trigger: null, 80 | arrow: null 81 | }); 82 | 83 | // eslint-disable-next-line react-hooks/exhaustive-deps 84 | React.useEffect(() => { 85 | const prev = boundsRef.current; 86 | 87 | const next: Bounds = { 88 | trigger: createBoundsFromElement(triggerRef.current), 89 | layer: createBoundsFromElement(layerRef.current), 90 | arrow: createBoundsFromElement(arrowRef.current) 91 | }; 92 | 93 | if (areBoundsEqual(prev, next)) { 94 | return; 95 | } 96 | 97 | for (const [type, bounds] of Object.entries(next)) { 98 | document.getElementById(`${type}-bounds`).innerHTML = JSON.stringify( 99 | bounds, 100 | null, 101 | 2 102 | ); 103 | } 104 | 105 | boundsRef.current = next; 106 | }); 107 | 108 | function handleCopyExpectBounds() { 109 | const layer = createBoundsFromElement(layerRef.current); 110 | const arrow = createBoundsFromElement(arrowRef.current); 111 | 112 | const payload = ` 113 | await tools.expectBounds({ 114 | layerSide: "${layerSide}", 115 | layer: { 116 | top: ${layer.top}, 117 | left: ${layer.left}, 118 | bottom: ${layer.bottom}, 119 | right: ${layer.right}, 120 | width: ${layer.width}, 121 | height: ${layer.height} 122 | }, 123 | arrow: { 124 | top: ${arrow.top}, 125 | left: ${arrow.left}, 126 | bottom: ${arrow.bottom}, 127 | right: ${arrow.right}, 128 | width: ${arrow.width}, 129 | height: ${arrow.height} 130 | } 131 | }); 132 | `; 133 | 134 | copyText(payload); 135 | } 136 | 137 | function handleCopyExpectBoundsProps() { 138 | const layer = createBoundsFromElement(layerRef.current); 139 | const arrow = createBoundsFromElement(arrowRef.current); 140 | 141 | const payload = `{ 142 | layerSide: "${layerSide}", 143 | layer: { 144 | top: ${layer.top}, 145 | left: ${layer.left}, 146 | bottom: ${layer.bottom}, 147 | right: ${layer.right}, 148 | width: ${layer.width}, 149 | height: ${layer.height} 150 | }, 151 | arrow: { 152 | top: ${arrow.top}, 153 | left: ${arrow.left}, 154 | bottom: ${arrow.bottom}, 155 | right: ${arrow.right}, 156 | width: ${arrow.width}, 157 | height: ${arrow.height} 158 | } 159 | } 160 | `; 161 | 162 | copyText(payload); 163 | } 164 | 165 | return ( 166 |
167 |
168 |
scroll-container offsets
169 |
170 | Top: 0 171 |
172 |
173 | Left: 0 174 |
175 |
176 |
177 | Render count: 178 | {renderCount} 179 |
180 |
181 | layer-side: 182 | {layerSide} 183 |
184 |
185 |
Trigger bounds
186 |

187 |       
188 |
189 |
Layer bounds
190 |

191 |       
192 |
193 |
Arrow Bounds
194 |

195 |       
196 |
197 | 198 | 201 |
202 |
203 | ); 204 | } 205 | -------------------------------------------------------------------------------- /packages/react-laag/sandbox/OptionsPanel/RadioGroup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | type RadioGroupProps = { 4 | items: { value: any; display: string }[]; 5 | value: any; 6 | onChange: (value: string) => void; 7 | disabled?: boolean; 8 | }; 9 | 10 | export function RadioGroup({ 11 | items, 12 | value, 13 | onChange, 14 | disabled 15 | }: RadioGroupProps) { 16 | return ( 17 |
18 | {items.map(item => { 19 | return ( 20 |
{ 24 | if (disabled) { 25 | return; 26 | } 27 | 28 | onChange(item.value); 29 | }} 30 | > 31 | 38 |
{item.display}
39 |
40 | ); 41 | })} 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /packages/react-laag/sandbox/OptionsPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { TestCaseOptions } from "../options"; 4 | import { PlacementSelect } from "./PlacementSelect"; 5 | import { RadioGroup } from "./RadioGroup"; 6 | 7 | type Props = { 8 | options: TestCaseOptions; 9 | onOptionsChange: (options: TestCaseOptions) => void; 10 | }; 11 | 12 | export function OptionsPanel({ options, onOptionsChange }: Props) { 13 | function handleOptionsChange( 14 | key: T, 15 | value: TestCaseOptions[T] 16 | ) { 17 | onOptionsChange({ 18 | ...options, 19 | [key]: value 20 | }); 21 | } 22 | 23 | return ( 24 |
37 |

Options

38 | 39 | 44 | handleOptionsChange("overflowContainer", !options.overflowContainer) 45 | } 46 | /> 47 | 48 | handleOptionsChange("placement", placement)} 52 | /> 53 | 54 | handleOptionsChange("auto", !options.auto)} 59 | /> 60 | 61 | 66 | handleOptionsChange("possiblePlacements", possiblePlacements) 67 | } 68 | /> 69 | 70 | handleOptionsChange("snap", !options.snap)} 76 | /> 77 | 78 | handleOptionsChange("preferX", value as any)} 86 | /> 87 | 88 | handleOptionsChange("preferY", value as any)} 96 | /> 97 | 98 | 105 | handleOptionsChange("triggerOffset", Number(value)) 106 | } 107 | /> 108 | 109 | 116 | handleOptionsChange("containerOffset", Number(value)) 117 | } 118 | /> 119 | 120 | 127 | handleOptionsChange("arrowOffset", Number(value)) 128 | } 129 | /> 130 | 131 | 136 | handleOptionsChange( 137 | "closeOnOutsideClick", 138 | !options.closeOnOutsideClick 139 | ) 140 | } 141 | /> 142 | 143 | 151 | handleOptionsChange("closeOnDisappear", value as any) 152 | } 153 | /> 154 | 155 | 160 | handleOptionsChange("triggerIsBigger", !options.triggerIsBigger) 161 | } 162 | /> 163 |
164 | ); 165 | } 166 | -------------------------------------------------------------------------------- /packages/react-laag/sandbox/constants.ts: -------------------------------------------------------------------------------- 1 | export const constants = { 2 | triggerWidth: 100, 3 | triggerHeight: 50, 4 | scrollContainerSize: 600, 5 | scrollContainerInnerSize: 2000, 6 | layerSize: 240, 7 | childLayerSize: 200 8 | }; 9 | -------------------------------------------------------------------------------- /packages/react-laag/sandbox/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /packages/react-laag/sandbox/main.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | 4 | import { TestCase } from "./TestCase"; 5 | import { OptionsPanel } from "./OptionsPanel"; 6 | import { baseOptions, TestCaseOptions } from "./options"; 7 | 8 | function App() { 9 | const [state, setState] = React.useState({ ...baseOptions }); 10 | 11 | return ( 12 | <> 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | ReactDOM.render(, document.getElementById("root")); 20 | -------------------------------------------------------------------------------- /packages/react-laag/sandbox/options.ts: -------------------------------------------------------------------------------- 1 | import { DisappearType, Options } from "../src/types"; 2 | import { PLACEMENT_TYPES } from "../src/PlacementType"; 3 | 4 | export type TestCaseOptions = Required< 5 | Omit< 6 | Options, 7 | | "isOpen" 8 | | "ResizeObserver" 9 | | "environment" 10 | | "onDisappear" 11 | | "onOutsideClick" 12 | | "onParentClose" 13 | | "container" 14 | | "trigger" 15 | > & { 16 | closeOnDisappear?: false | DisappearType; 17 | closeOnOutsideClick?: boolean; 18 | triggerIsBigger?: boolean; 19 | initialOpen?: boolean; 20 | } 21 | >; 22 | 23 | export const baseOptions: TestCaseOptions = { 24 | auto: false, 25 | layerDimensions: null, 26 | closeOnDisappear: false, 27 | closeOnOutsideClick: false, 28 | overflowContainer: true, 29 | placement: "top-center", 30 | possiblePlacements: PLACEMENT_TYPES, 31 | preferX: "right", 32 | preferY: "bottom", 33 | snap: false, 34 | arrowOffset: 8, 35 | triggerOffset: 12, 36 | containerOffset: 16, 37 | triggerIsBigger: false, 38 | initialOpen: false 39 | }; 40 | -------------------------------------------------------------------------------- /packages/react-laag/sandbox/util-hooks.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { constants } from "./constants"; 3 | 4 | export function useCenterScrollContainer( 5 | ref: React.MutableRefObject 6 | ) { 7 | React.useLayoutEffect(() => { 8 | const pos = 9 | constants.scrollContainerInnerSize / 2 - 10 | constants.scrollContainerSize / 2; 11 | ref.current.scrollTop = pos; 12 | ref.current.scrollLeft = pos; 13 | }, [ref]); 14 | } 15 | 16 | export function useTrackScrollContainerOffsets( 17 | ref: React.MutableRefObject 18 | ) { 19 | React.useEffect(() => { 20 | const element = ref.current; 21 | 22 | function handleScroll() { 23 | const offsetBox = document.getElementById("scroll-container-offsets"); 24 | const [top, left] = offsetBox.querySelectorAll("span"); 25 | top.innerText = String(element.scrollTop); 26 | left.innerText = String(element.scrollLeft); 27 | } 28 | 29 | element.addEventListener("scroll", handleScroll); 30 | 31 | return () => element.removeEventListener("scroll", handleScroll); 32 | }, [ref]); 33 | } 34 | 35 | export function useRenderCount() { 36 | const renderCountRef = React.useRef(0); 37 | renderCountRef.current++; 38 | 39 | return renderCountRef.current; 40 | } 41 | -------------------------------------------------------------------------------- /packages/react-laag/snowpack.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("snowpack").SnowpackUserConfig } */ 2 | const config = { 3 | mount: { 4 | sandbox: "/", 5 | src: "/src" 6 | }, 7 | packageOptions: { 8 | external: ["/__web-dev-server__web-socket.js"] 9 | }, 10 | workspaceRoot: "../../" 11 | }; 12 | 13 | if (process.env.NODE_ENV === "test") { 14 | config.mount.tests = "/"; 15 | } else { 16 | config.plugins = [ 17 | "@snowpack/plugin-react-refresh", 18 | "@snowpack/plugin-typescript" 19 | ]; 20 | } 21 | 22 | module.exports = config; 23 | -------------------------------------------------------------------------------- /packages/react-laag/src/BoundsOffsets.ts: -------------------------------------------------------------------------------- 1 | export interface IBoundsOffsets { 2 | top: number; 3 | left: number; 4 | right: number; 5 | bottom: number; 6 | } 7 | 8 | const SIDES = ["top", "left", "bottom", "right"] as (keyof IBoundsOffsets)[]; 9 | 10 | /** 11 | * A class containing the positional properties which represent the distance 12 | * between two Bounds instances for each side 13 | */ 14 | export class BoundsOffsets implements IBoundsOffsets { 15 | top!: number; 16 | left!: number; 17 | right!: number; 18 | bottom!: number; 19 | 20 | constructor(offsets: IBoundsOffsets) { 21 | return Object.assign(this, offsets); 22 | } 23 | 24 | /** 25 | * Takes multiple BoundsOffets instances and creates a new BoundsOffsets instance 26 | * by taking the smallest value for each side 27 | * @param boundsOffsets list of BoundsOffsets instances 28 | */ 29 | static mergeSmallestSides(boundsOffsets: BoundsOffsets[]): BoundsOffsets { 30 | const [first, ...rest] = boundsOffsets; 31 | 32 | if (!first) { 33 | throw new Error( 34 | "Please provide at least 1 bounds objects in order to merge" 35 | ); 36 | } 37 | 38 | const result: IBoundsOffsets = Object.fromEntries( 39 | SIDES.map(side => [side, first[side]]) 40 | ) as any; 41 | 42 | for (const boundsOffset of rest) { 43 | for (const side of SIDES) { 44 | result[side] = Math.min(result[side], boundsOffset[side]); 45 | } 46 | } 47 | 48 | return new BoundsOffsets(result); 49 | } 50 | 51 | /** 52 | * Checks whether all sides sides are positive, meaning the corresponding Bounds instance 53 | * fits perfectly within a parent Bounds instance 54 | */ 55 | get allSidesArePositive(): boolean { 56 | return SIDES.every(side => this[side] >= 0); 57 | } 58 | 59 | /** 60 | * Returns a partial IBoundsOffsets with sides that are negative, meaning sides aren't entirely 61 | * visible in respect to a parent Bounds instance 62 | */ 63 | get negativeSides(): Partial { 64 | return Object.fromEntries( 65 | SIDES.filter(side => this[side] < 0).map(side => [side, this[side]]) 66 | ) as Partial; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/react-laag/src/PlacementType.ts: -------------------------------------------------------------------------------- 1 | export const PLACEMENT_TYPES: PlacementType[] = [ 2 | "bottom-start", 3 | "bottom-end", 4 | "bottom-center", 5 | "top-start", 6 | "top-center", 7 | "top-end", 8 | "left-end", 9 | "left-center", 10 | "left-start", 11 | "right-end", 12 | "right-center", 13 | "right-start", 14 | "center" 15 | ]; 16 | 17 | export type PlacementType = 18 | | "bottom-start" 19 | | "bottom-end" 20 | | "bottom-center" 21 | | "top-start" 22 | | "top-center" 23 | | "top-end" 24 | | "left-end" 25 | | "left-center" 26 | | "left-start" 27 | | "right-end" 28 | | "right-center" 29 | | "right-start" 30 | | "center"; 31 | -------------------------------------------------------------------------------- /packages/react-laag/src/Sides.ts: -------------------------------------------------------------------------------- 1 | export type BoundSideProp = "top" | "left" | "bottom" | "right"; 2 | export type SideProp = BoundSideProp | "center"; 3 | type SizeProp = "width" | "height"; 4 | type CssProp = "top" | "left"; 5 | 6 | const OPPOSITES: Record = { 7 | top: "bottom", 8 | left: "right", 9 | bottom: "top", 10 | right: "left", 11 | center: "center" 12 | }; 13 | 14 | class SideBase { 15 | constructor( 16 | readonly prop: T, 17 | readonly opposite: SideBase, 18 | readonly isHorizontal: boolean, 19 | readonly sizeProp: SizeProp, 20 | readonly oppositeSizeProp: SizeProp, 21 | readonly cssProp: CssProp, 22 | readonly oppositeCssProp: CssProp, 23 | readonly isCenter: boolean, 24 | readonly isPush: boolean // left | top 25 | ) {} 26 | 27 | factor(value: number) { 28 | return value * (this.isPush ? 1 : -1); 29 | } 30 | 31 | isOppositeDirection(side: SideBase) { 32 | return this.isHorizontal !== side.isHorizontal; 33 | } 34 | } 35 | 36 | function createSide( 37 | prop: T, 38 | recursive = true 39 | ): SideBase { 40 | const isHorizontal = ["left", "right"].includes(prop); 41 | 42 | return new SideBase( 43 | prop, 44 | recursive ? createSide((OPPOSITES as any)[prop], false) : null!, 45 | isHorizontal, 46 | isHorizontal ? "width" : "height", 47 | isHorizontal ? "height" : "width", 48 | isHorizontal ? "left" : "top", 49 | isHorizontal ? "top" : "left", 50 | prop === "center", 51 | !["right", "bottom"].includes(prop) 52 | ); 53 | } 54 | 55 | export type BoundSideType = SideBase; 56 | export type SideType = SideBase; 57 | 58 | export const BoundSide = { 59 | top: createSide("top") as BoundSideType, 60 | bottom: createSide("bottom") as BoundSideType, 61 | left: createSide("left") as BoundSideType, 62 | right: createSide("right") as BoundSideType 63 | }; 64 | 65 | export const Side = { 66 | ...(BoundSide as { 67 | top: SideType; 68 | left: SideType; 69 | bottom: SideType; 70 | right: SideType; 71 | }), 72 | center: createSide("center") 73 | }; 74 | -------------------------------------------------------------------------------- /packages/react-laag/src/SubjectsBounds.ts: -------------------------------------------------------------------------------- 1 | import { Bounds, IBounds, boundsToObject } from "./Bounds"; 2 | 3 | interface ISubjectsBounds { 4 | trigger: IBounds; 5 | layer: IBounds; 6 | arrow: IBounds; 7 | parent: IBounds; 8 | window: IBounds; 9 | scrollContainers: IBounds[]; 10 | } 11 | 12 | export class SubjectsBounds implements ISubjectsBounds { 13 | public readonly trigger!: Bounds; 14 | public readonly layer!: Bounds; 15 | public readonly arrow!: Bounds; 16 | public readonly parent!: Bounds; 17 | public readonly window!: Bounds; 18 | public readonly scrollContainers!: Bounds[]; 19 | 20 | private constructor( 21 | subjectsBounds: ISubjectsBounds, 22 | private readonly overflowContainer: boolean 23 | ) { 24 | Object.assign(this, subjectsBounds); 25 | } 26 | 27 | static create( 28 | environment: Window, 29 | layer: HTMLElement, 30 | trigger: HTMLElement, 31 | parent: HTMLElement | undefined, 32 | arrow: HTMLElement | null, 33 | scrollContainers: HTMLElement[], 34 | overflowContainer: boolean, 35 | getTriggerBounds?: () => IBounds 36 | ) { 37 | const window = Bounds.fromWindow(environment); 38 | 39 | return new SubjectsBounds( 40 | { 41 | layer: Bounds.fromElement(layer, { 42 | environment, 43 | withTransform: false 44 | }), 45 | trigger: getTriggerBounds 46 | ? Bounds.create(boundsToObject(getTriggerBounds())) 47 | : Bounds.fromElement(trigger), 48 | arrow: arrow ? Bounds.fromElement(arrow) : Bounds.empty(), 49 | parent: parent ? Bounds.fromElement(parent) : window, 50 | window, 51 | scrollContainers: [ 52 | window, 53 | ...scrollContainers.map(container => 54 | Bounds.fromElement(container, { withScrollbars: false }) 55 | ) 56 | ] 57 | }, 58 | overflowContainer 59 | ); 60 | } 61 | 62 | merge(subjectsBounds: Partial) { 63 | return new SubjectsBounds( 64 | { 65 | ...this, 66 | ...subjectsBounds 67 | }, 68 | this.overflowContainer 69 | ); 70 | } 71 | 72 | get layerOffsetsToScrollContainers() { 73 | return this.offsetsToScrollContainers(this.layer); 74 | } 75 | 76 | get triggerHasBiggerWidth() { 77 | return this.trigger.width > this.layer.width; 78 | } 79 | 80 | get triggerHasBiggerHeight() { 81 | return this.trigger.height > this.layer.height; 82 | } 83 | 84 | offsetsToScrollContainers(subject: Bounds, allContainers = false) { 85 | const scrollContainers = 86 | this.overflowContainer && !allContainers 87 | ? [this.window] 88 | : this.scrollContainers; 89 | 90 | return scrollContainers.map(scrollContainer => 91 | scrollContainer.offsetsTo(subject) 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/react-laag/src/Transition.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import warning from "tiny-warning"; 3 | 4 | export type TransitionProps = { 5 | isOpen: boolean; 6 | children: ( 7 | isOpen: boolean, 8 | onTransitionEnd: any, 9 | isLeaving: boolean 10 | ) => React.ReactElement; 11 | }; 12 | 13 | /** 14 | * @deprecated 15 | * Note: this component is marked as deprecated and will be removed and a possible 16 | * future release 17 | */ 18 | export function Transition({ 19 | isOpen: isOpenExternal, 20 | children 21 | }: TransitionProps) { 22 | const [state, setState] = useState({ 23 | isOpenInternal: isOpenExternal, 24 | isLeaving: false 25 | }); 26 | 27 | const didMount = useRef(false); 28 | 29 | useEffect(() => { 30 | if (isOpenExternal) { 31 | setState({ 32 | isOpenInternal: true, 33 | isLeaving: false 34 | }); 35 | } else if (didMount.current) { 36 | setState({ 37 | isOpenInternal: false, 38 | isLeaving: true 39 | }); 40 | } 41 | }, [isOpenExternal, setState]); 42 | 43 | useEffect(() => { 44 | warning( 45 | children, 46 | `react-laag: You are using 'Transition'. Note that this component is marked as deprecated and will be removed at future releases` 47 | ); 48 | }, [children]); 49 | 50 | useEffect(() => { 51 | didMount.current = true; 52 | }, []); 53 | 54 | if (!isOpenExternal && !state.isOpenInternal && !state.isLeaving) { 55 | return null; 56 | } 57 | 58 | return children( 59 | state.isOpenInternal, 60 | () => { 61 | if (!state.isOpenInternal) { 62 | setState(s => ({ ...s, isLeaving: false })); 63 | } 64 | }, 65 | state.isLeaving 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /packages/react-laag/src/getArrowStyle.ts: -------------------------------------------------------------------------------- 1 | import { limit } from "./util"; 2 | import { SubjectsBounds } from "./SubjectsBounds"; 3 | import { Placement } from "./Placement"; 4 | 5 | // how much pixels is the center of layer removed from edge of trigger? 6 | function getNegativeOffsetBetweenLayerCenterAndTrigger( 7 | subjectsBounds: SubjectsBounds, 8 | placement: Placement, 9 | arrowOffset: number 10 | ) { 11 | const { layer, trigger, arrow } = subjectsBounds; 12 | 13 | const sizeProperty = placement.primary.oppositeSizeProp; 14 | 15 | const [sideA, sideB] = !placement.primary.isHorizontal 16 | ? (["left", "right"] as const) 17 | : (["top", "bottom"] as const); 18 | 19 | const offsetA = 20 | layer[sideA] + 21 | layer[sizeProperty] / 2 - 22 | trigger[sideA] - 23 | arrow[sizeProperty] / 2 - 24 | arrowOffset; 25 | const offsetB = 26 | layer[sideB] - 27 | layer[sizeProperty] / 2 - 28 | trigger[sideB] + 29 | arrow[sizeProperty] / 2 + 30 | arrowOffset; 31 | 32 | return (offsetA < 0 ? -offsetA : 0) + (offsetB > 0 ? -offsetB : 0); 33 | } 34 | 35 | const STYLE_BASE: React.CSSProperties = { 36 | position: "absolute", 37 | willChange: "top, left", 38 | left: null!, 39 | right: null!, 40 | top: null!, 41 | bottom: null! 42 | }; 43 | 44 | export function getArrowStyle( 45 | subjectsBounds: SubjectsBounds, 46 | placement: Placement, 47 | arrowOffset: number 48 | ): React.CSSProperties { 49 | if (placement.primary.isCenter) { 50 | return STYLE_BASE; 51 | } 52 | 53 | const { layer, trigger, arrow } = subjectsBounds; 54 | 55 | const sizeProperty = placement.primary.oppositeSizeProp; 56 | const triggerIsBigger = trigger[sizeProperty] > layer[sizeProperty]; 57 | 58 | const min = arrowOffset + arrow[sizeProperty] / 2; 59 | const max = layer[sizeProperty] - arrow[sizeProperty] / 2 - arrowOffset; 60 | 61 | const negativeOffset = getNegativeOffsetBetweenLayerCenterAndTrigger( 62 | subjectsBounds, 63 | placement, 64 | arrowOffset 65 | ); 66 | 67 | const primarySide = placement.primary.prop; 68 | const secondarySide = placement.primary.oppositeCssProp; 69 | 70 | const secondaryValue = triggerIsBigger 71 | ? layer[sizeProperty] / 2 + negativeOffset 72 | : trigger[secondarySide] + trigger[sizeProperty] / 2 - layer[secondarySide]; 73 | 74 | return { 75 | ...STYLE_BASE, 76 | [primarySide]: "100%", 77 | [secondarySide]: limit(secondaryValue, min, max) 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /packages/react-laag/src/index.ts: -------------------------------------------------------------------------------- 1 | export { useLayer, DEFAULT_OPTIONS, setGlobalContainer } from "./useLayer"; 2 | // eslint-disable-next-line prettier/prettier 3 | export type { 4 | UseLayerProps, 5 | LayerProps, 6 | TriggerProps, 7 | UseLayerArrowProps 8 | } from "./useLayer"; 9 | export { Arrow } from "./Arrow"; 10 | export type { ArrowProps } from "./Arrow"; 11 | export { useHover } from "./useHover"; 12 | export type { UseHoverProps, UseHoverOptions, PlainCallback } from "./useHover"; 13 | 14 | export type { 15 | LayerSide, 16 | DisappearType, 17 | ResizeObserverClass, 18 | Options as UseLayerOptions 19 | } from "./types"; 20 | export { PLACEMENT_TYPES } from "./PlacementType"; 21 | export type { PlacementType as Placement } from "./PlacementType"; 22 | export { mergeRefs } from "./util"; 23 | export type { IBounds } from "./Bounds"; 24 | export { useMousePositionAsTrigger } from "./hooks"; 25 | export type { 26 | UseMousePositionAsTriggerOptions, 27 | UseMousePositionAsTriggerProps 28 | } from "./hooks"; 29 | export { Transition } from "./Transition"; 30 | export type { TransitionProps } from "./Transition"; 31 | -------------------------------------------------------------------------------- /packages/react-laag/src/useHover.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useCallback, useEffect, MouseEvent } from "react"; 2 | 3 | export type UseHoverOptions = { 4 | /** 5 | * Amount of milliseconds to wait while hovering before opening. 6 | * Default is `0` 7 | */ 8 | delayEnter?: number; 9 | /** 10 | * Amount of milliseconds to wait when mouse has left the trigger before closing. 11 | * Default is `0` 12 | */ 13 | delayLeave?: number; 14 | /** 15 | * Determines whether the layer should hide when the user starts scrolling. 16 | * Default is `true` 17 | */ 18 | hideOnScroll?: boolean; 19 | }; 20 | 21 | export type PlainCallback = (...args: any[]) => void; 22 | 23 | export type UseHoverProps = { 24 | onMouseEnter: PlainCallback; 25 | onMouseLeave: PlainCallback; 26 | onTouchStart: PlainCallback; 27 | onTouchMove: PlainCallback; 28 | onTouchEnd: PlainCallback; 29 | }; 30 | 31 | enum Status { 32 | ENTERING, 33 | LEAVING, 34 | IDLE 35 | } 36 | 37 | export function useHover({ 38 | delayEnter = 0, 39 | delayLeave = 0, 40 | hideOnScroll = true 41 | }: UseHoverOptions = {}): readonly [boolean, UseHoverProps, () => void] { 42 | const [show, setShow] = useState(false); 43 | 44 | const timeout = useRef(null); 45 | 46 | const status = useRef(Status.IDLE); 47 | 48 | const hasTouchMoved = useRef(false); 49 | 50 | const removeTimeout = useCallback(function removeTimeout() { 51 | clearTimeout(timeout.current!); 52 | timeout.current = null; 53 | status.current = Status.IDLE; 54 | }, []); 55 | 56 | function onMouseEnter() { 57 | // if was leaving, stop leaving 58 | if (status.current === Status.LEAVING && timeout.current) { 59 | removeTimeout(); 60 | } 61 | 62 | if (show) { 63 | return; 64 | } 65 | 66 | status.current = Status.ENTERING; 67 | timeout.current = window.setTimeout(() => { 68 | setShow(true); 69 | timeout.current = null; 70 | status.current = Status.IDLE; 71 | }, delayEnter); 72 | } 73 | 74 | function onMouseLeave(_: MouseEvent, immediate?: boolean) { 75 | // if was waiting for entering, 76 | // clear timeout 77 | if (status.current === Status.ENTERING && timeout.current) { 78 | removeTimeout(); 79 | } 80 | 81 | if (!show) { 82 | return; 83 | } 84 | 85 | if (immediate) { 86 | setShow(false); 87 | timeout.current = null; 88 | status.current = Status.IDLE; 89 | return; 90 | } 91 | 92 | status.current = Status.LEAVING; 93 | timeout.current = window.setTimeout(() => { 94 | setShow(false); 95 | timeout.current = null; 96 | status.current = Status.IDLE; 97 | }, delayLeave); 98 | } 99 | 100 | // make sure to clear timeout on unmount 101 | useEffect(() => { 102 | function onScroll() { 103 | if (show && hideOnScroll) { 104 | removeTimeout(); 105 | setShow(false); 106 | } 107 | } 108 | 109 | window.addEventListener("scroll", onScroll, true); 110 | 111 | return () => { 112 | window.removeEventListener("scroll", onScroll, true); 113 | 114 | if (timeout.current) { 115 | clearTimeout(timeout.current); 116 | } 117 | }; 118 | }, [show, hideOnScroll, removeTimeout]); 119 | 120 | const hoverProps: UseHoverProps = { 121 | onMouseEnter, 122 | onMouseLeave, 123 | onTouchStart: () => { 124 | hasTouchMoved.current = false; 125 | }, 126 | onTouchMove: () => { 127 | hasTouchMoved.current = true; 128 | }, 129 | onTouchEnd: () => { 130 | if (!hasTouchMoved.current && !show) { 131 | setShow(true); 132 | } 133 | 134 | hasTouchMoved.current = false; 135 | } 136 | }; 137 | 138 | return [show, hoverProps, () => onMouseLeave(null!, true)] as const; 139 | } 140 | -------------------------------------------------------------------------------- /packages/react-laag/src/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a pixel value into a numeric value 3 | * @param value string value (ie. '12px') 4 | */ 5 | export function getPixelValue(value: string) { 6 | return parseFloat(value.replace("px", "")); 7 | } 8 | 9 | /** 10 | * Returns a numeric value that doesn't exceed min or max 11 | */ 12 | export function limit(value: number, min: number, max: number): number { 13 | return value < min ? min : value > max ? max : value; 14 | } 15 | 16 | /** 17 | * Utility function which ensures whether a value is truthy 18 | */ 19 | export function isSet(value: T | null | undefined): value is T { 20 | return value === null || value === undefined ? false : true; 21 | } 22 | 23 | /** 24 | * Utility function that let's you assign multiple references to a 'ref' prop 25 | * @param refs list of MutableRefObject's and / or callbacks 26 | */ 27 | export function mergeRefs(...refs: any[]) { 28 | return (element: HTMLElement | null) => { 29 | for (const ref of refs) { 30 | if (!ref) { 31 | continue; 32 | } 33 | 34 | if (typeof ref === "function") { 35 | ref(element); 36 | } else { 37 | ref.current = element!; 38 | } 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /packages/react-laag/ssr-test.js: -------------------------------------------------------------------------------- 1 | const React = require("react"); 2 | const ReactDOMServer = require("react-dom/server"); 3 | const { useLayer } = require("./dist"); 4 | 5 | function Test({ initialOpen }) { 6 | const [isOpen] = React.useState(initialOpen); 7 | 8 | const { layerProps, triggerProps, renderLayer } = useLayer({ 9 | isOpen 10 | }); 11 | 12 | return React.createElement( 13 | "div", 14 | {}, 15 | isOpen && renderLayer(React.createElement("div", layerProps, "layer")), 16 | React.createElement("div", triggerProps, "trigger") 17 | ); 18 | } 19 | 20 | for (const initialOpen of [false, true]) { 21 | ReactDOMServer.renderToString(React.createElement(Test, { initialOpen })); 22 | } 23 | 24 | console.log("\n SSR TEST: SUCCESS\n"); 25 | -------------------------------------------------------------------------------- /packages/react-laag/tests/BoundsOffsets.spec.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { BoundsOffsets } from "../src/BoundsOffsets"; 4 | 5 | describe("BoundsOffsets", () => { 6 | it("instantiates a new intance", () => { 7 | expect( 8 | () => new BoundsOffsets({ top: 1, bottom: 2, left: 3, right: 4 }) 9 | ).not.toThrowError(); 10 | }); 11 | 12 | it("creates a new instance based on other instances by picking the smallest value for each side", () => { 13 | const a = new BoundsOffsets({ top: 10, bottom: 20, left: 30, right: 40 }); 14 | const b = new BoundsOffsets({ top: 5, bottom: 50, left: 500, right: 100 }); 15 | const c = new BoundsOffsets({ top: 20, bottom: 10, left: 10, right: 10 }); 16 | 17 | expect(BoundsOffsets.mergeSmallestSides([a, b, c])).toEqual( 18 | new BoundsOffsets({ top: 5, bottom: 10, left: 10, right: 10 }) 19 | ); 20 | }); 21 | 22 | it("creates a new instance based on other instances by picking the smallest value for each side", () => { 23 | const a = new BoundsOffsets({ top: 10, bottom: 20, left: 30, right: 40 }); 24 | const b = new BoundsOffsets({ top: 5, bottom: 50, left: 500, right: 100 }); 25 | const c = new BoundsOffsets({ top: 20, bottom: 10, left: 10, right: -10 }); 26 | 27 | expect(BoundsOffsets.mergeSmallestSides([a, b, c])).toEqual( 28 | new BoundsOffsets({ top: 5, bottom: 10, left: 10, right: -10 }) 29 | ); 30 | }); 31 | 32 | it("checks whether all sides are positive", () => { 33 | expect( 34 | new BoundsOffsets({ top: 20, bottom: 10, left: 10, right: -10 }) 35 | .allSidesArePositive 36 | ).toEqual(false); 37 | expect( 38 | new BoundsOffsets({ top: 20, bottom: 10, left: 10, right: 10 }) 39 | .allSidesArePositive 40 | ).toEqual(true); 41 | }); 42 | 43 | it("returns a partial IBoundsOffsets-object with only the negative sides", () => { 44 | expect( 45 | new BoundsOffsets({ top: 20, bottom: 10, left: 10, right: -10 }) 46 | .negativeSides 47 | ).toEqual({ right: -10 }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/react-laag/tests/Placement.spec.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { Placement } from "../src/Placement"; 3 | import { BoundSide, Side } from "../src/Sides"; 4 | import { SubjectsBounds } from "../src/SubjectsBounds"; 5 | import { Bounds } from "../src/Bounds"; 6 | import { Offsets } from "../src/types"; 7 | import { BoundsOffsets } from "../src/BoundsOffsets"; 8 | 9 | describe("Placement", () => { 10 | const window = Bounds.create({ 11 | top: 0, 12 | left: 0, 13 | right: 800, 14 | bottom: 600, 15 | width: 800, 16 | height: 600 17 | }); 18 | 19 | const TRIGGER_SIZE = 100; 20 | const TRIGGER_START = 100; 21 | 22 | const trigger = Bounds.create({ 23 | top: TRIGGER_START, 24 | left: TRIGGER_START, 25 | right: TRIGGER_START + TRIGGER_SIZE, 26 | bottom: TRIGGER_START + TRIGGER_SIZE, 27 | height: TRIGGER_SIZE, 28 | width: TRIGGER_SIZE 29 | }); 30 | 31 | const LAYER_HEIGHT = 200; 32 | 33 | const layer = Bounds.create({ 34 | top: trigger.bottom, 35 | left: TRIGGER_START - TRIGGER_SIZE, 36 | right: trigger.right + 100, 37 | bottom: trigger.bottom + LAYER_HEIGHT, 38 | height: LAYER_HEIGHT, 39 | width: trigger.right + 100 - (TRIGGER_START - TRIGGER_SIZE) 40 | }); 41 | 42 | const SCROLL_CONTAINER_SIZE = 500; 43 | 44 | const scrollContainer = Bounds.create({ 45 | top: 0, 46 | left: 0, 47 | right: SCROLL_CONTAINER_SIZE, 48 | bottom: SCROLL_CONTAINER_SIZE, 49 | width: SCROLL_CONTAINER_SIZE, 50 | height: SCROLL_CONTAINER_SIZE 51 | }); 52 | 53 | const ARROW_SIZE = 10; 54 | 55 | // @ts-expect-error 56 | const sb = new SubjectsBounds( 57 | { 58 | trigger, 59 | layer, 60 | arrow: Bounds.create({ 61 | top: trigger.bottom - ARROW_SIZE, 62 | left: trigger.left + trigger.width / 2 - ARROW_SIZE / 2, 63 | right: trigger.left + trigger.width / 2 + ARROW_SIZE / 2, 64 | bottom: trigger.bottom, 65 | height: ARROW_SIZE, 66 | width: ARROW_SIZE 67 | }), 68 | parent: scrollContainer, 69 | window, 70 | scrollContainers: [window, scrollContainer] 71 | }, 72 | false 73 | ); 74 | 75 | const offsets: Offsets = { 76 | arrow: 0, 77 | container: 0, 78 | trigger: 0 79 | }; 80 | 81 | const placement = new Placement( 82 | BoundSide.bottom, 83 | Side.center, 84 | sb, 85 | null, 86 | offsets 87 | ); 88 | 89 | it("checks whether the layer visually fits within the scroll-containers given it's current position", () => { 90 | expect(placement.fitsContainer).toBe(true); 91 | 92 | // lets decrease the size of the scroll-container, so that the layer no longer fits... 93 | const newContainers = sb.scrollContainers.slice(0); 94 | newContainers[1] = Bounds.create({ 95 | top: 0, 96 | left: 0, 97 | right: 200, 98 | bottom: 200, 99 | width: 200, 100 | height: 200 101 | }); 102 | const newPlacement = new Placement( 103 | BoundSide.bottom, 104 | Side.center, 105 | sb.merge({ 106 | scrollContainers: newContainers 107 | }), 108 | null, 109 | offsets 110 | ); 111 | 112 | expect(newPlacement.fitsContainer).toBe(false); 113 | }); 114 | 115 | it("Gives back the correct closest offsets to the scroll-containers", () => { 116 | expect(placement.getContainerOffsets()).toEqual( 117 | new BoundsOffsets({ 118 | top: layer.top - scrollContainer.top, 119 | left: layer.left - scrollContainer.left, 120 | right: scrollContainer.right - layer.right, 121 | bottom: scrollContainer.bottom - layer.bottom 122 | }) 123 | ); 124 | }); 125 | 126 | it("returns the layer-bounds given its current placement", () => { 127 | expect(placement.getLayerBounds()).toEqual(layer); 128 | 129 | // checking another placement -> left-start 130 | const otherPlacement = new Placement( 131 | BoundSide.left, 132 | Side.top, 133 | sb, 134 | null, 135 | offsets 136 | ); 137 | expect(otherPlacement.getLayerBounds()).toEqual( 138 | Bounds.create({ 139 | top: trigger.top, 140 | left: trigger.left - layer.width, 141 | right: trigger.left, 142 | bottom: trigger.top + layer.height, 143 | width: layer.width, 144 | height: layer.height 145 | }) 146 | ); 147 | }); 148 | 149 | it("checks whether the trigger has a bigger size on the secondary side", () => { 150 | expect(placement.triggerIsBigger).toEqual(false); 151 | }); 152 | 153 | it("returns the visible surface", () => { 154 | expect(placement.visibleSurface).toEqual(layer.width * layer.height); 155 | 156 | // checking another placement -> left-start 157 | const otherPlacement = new Placement( 158 | BoundSide.left, 159 | Side.top, 160 | sb, 161 | null, 162 | offsets 163 | ); 164 | 165 | expect(otherPlacement.visibleSurface).toEqual(20_000); // trigger.left * layer.height 166 | }); 167 | 168 | it("returns the secondary side with the most negative offset to its scroll-container", () => { 169 | // left side should be -100 170 | const placementWithNegativeLeft = new Placement( 171 | BoundSide.bottom, 172 | Side.right, 173 | sb, 174 | null, 175 | offsets 176 | ); 177 | 178 | expect(placementWithNegativeLeft.secondaryOffsetSide).toEqual(Side.left); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /packages/react-laag/tests/Placements.spec.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { PLACEMENT_TYPES } from "../src/PlacementType"; 3 | import { Bounds } from "../src/Bounds"; 4 | import { Placements } from "../src/Placements"; 5 | import { SubjectsBounds } from "../src/SubjectsBounds"; 6 | import { boundsByDimensions } from "./test-util"; 7 | import { PositionConfig } from "../src/types"; 8 | import { Placement } from "../src/Placement"; 9 | 10 | function getType(placement: Placement) { 11 | return placement.type; 12 | } 13 | 14 | describe("Placements", () => { 15 | const window = Bounds.create({ 16 | top: 0, 17 | left: 0, 18 | right: 800, 19 | bottom: 600, 20 | width: 800, 21 | height: 600 22 | }); 23 | 24 | // @ts-expect-error 25 | const sb: SubjectsBounds = new SubjectsBounds({ 26 | trigger: boundsByDimensions(100, 100), 27 | layer: boundsByDimensions(300, 300), 28 | arrow: Bounds.empty(), 29 | parent: boundsByDimensions(600, 600), 30 | window, 31 | scrollContainers: [window, boundsByDimensions(600, 600)] 32 | }); 33 | 34 | const baseConfig: PositionConfig = { 35 | placement: "top-start", 36 | arrowOffset: 0, 37 | containerOffset: 0, 38 | triggerOffset: 0, 39 | layerDimensions: null, 40 | auto: true, 41 | overflowContainer: false, 42 | snap: false, 43 | possiblePlacements: PLACEMENT_TYPES as any, 44 | preferX: "right", 45 | preferY: "bottom" 46 | }; 47 | 48 | describe("placement priority", () => { 49 | it("returns the correct priority when placement is 'top-start'", () => { 50 | const { placements } = Placements.create(sb, { 51 | ...baseConfig, 52 | placement: "top-start" 53 | }); 54 | expect(placements.map(getType)).toEqual([ 55 | "top-start", 56 | "top-center", 57 | "top-end", 58 | "right-end", 59 | "right-center", 60 | "right-start", 61 | "left-end", 62 | "left-center", 63 | "left-start", 64 | "bottom-start", 65 | "bottom-center", 66 | "bottom-end" 67 | ]); 68 | }); 69 | 70 | it("returns the correct priority when placement is 'left-center'", () => { 71 | const { placements } = Placements.create(sb, { 72 | ...baseConfig, 73 | placement: "left-center" 74 | }); 75 | expect(placements.map(getType)).toEqual([ 76 | "left-center", 77 | "left-end", 78 | "left-start", 79 | "bottom-end", 80 | "bottom-center", 81 | "bottom-start", 82 | "top-end", 83 | "top-center", 84 | "top-start", 85 | "right-center", 86 | "right-end", 87 | "right-start" 88 | ]); 89 | }); 90 | 91 | it("returns the correct priority when the trigger is bigger", () => { 92 | const { placements } = Placements.create( 93 | sb.merge({ trigger: boundsByDimensions(800, 800) }), 94 | baseConfig 95 | ); 96 | expect(placements.map(getType)).toEqual([ 97 | "top-start", 98 | "top-center", 99 | "top-end", 100 | "right-start", 101 | "right-center", 102 | "right-end", 103 | "left-start", 104 | "left-center", 105 | "left-end", 106 | "bottom-start", 107 | "bottom-center", 108 | "bottom-end" 109 | ]); 110 | }); 111 | 112 | it("returns the correct priority when preffered sides are swapped", () => { 113 | const { placements } = Placements.create( 114 | sb.merge({ trigger: boundsByDimensions(800, 800) }), 115 | { 116 | ...baseConfig, 117 | preferX: "left", 118 | preferY: "top" 119 | } 120 | ); 121 | expect(placements.map(getType)).toEqual([ 122 | "top-start", 123 | "top-center", 124 | "top-end", 125 | "left-start", 126 | "left-center", 127 | "left-end", 128 | "right-start", 129 | "right-center", 130 | "right-end", 131 | "bottom-start", 132 | "bottom-center", 133 | "bottom-end" 134 | ]); 135 | }); 136 | }); 137 | 138 | describe("results", () => { 139 | it("returns proper results about positioning", () => { 140 | const placements = Placements.create(sb, baseConfig); 141 | const result = placements.result( 142 | { top: 0, left: 0 }, 143 | { top: 0, left: 0 } 144 | ); 145 | 146 | expect(result.placement.type).toBe("right-start"); 147 | expect(result.styles).toEqual({ 148 | arrow: { 149 | position: "absolute", 150 | willChange: "top, left", 151 | left: null, 152 | right: "100%", 153 | top: 50, 154 | bottom: null 155 | }, 156 | layer: { 157 | willChange: "top, left, width, height", 158 | position: "absolute", 159 | top: 0, 160 | left: 100 161 | } 162 | }); 163 | 164 | expect(result.layerSide).toBe("right"); 165 | expect(result.layerBounds).toEqual({ 166 | top: 0, 167 | left: 100, 168 | right: 400, 169 | bottom: 300, 170 | width: 300, 171 | height: 300 172 | }); 173 | expect(result.hasDisappeared).toBe(null); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /packages/react-laag/tests/SubjectsBounds.spec.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { Bounds } from "../src/Bounds"; 3 | import { SubjectsBounds } from "../src/SubjectsBounds"; 4 | import { createElement, clearBody } from "./test-util"; 5 | 6 | describe("SubjectsBounds", () => { 7 | after(clearBody); 8 | 9 | const trigger = createElement("div", { width: "100px", height: "100px" }); 10 | const layer = createElement("div", { width: "200px", height: "200px" }); 11 | const scrollContainer1 = createElement("div", { 12 | width: "800px", 13 | height: "600px" 14 | }); 15 | const scrollContainer2 = createElement("div", { 16 | width: "800px", 17 | height: "600px" 18 | }); 19 | 20 | const subjects = [trigger, layer, scrollContainer1, scrollContainer2]; 21 | subjects.forEach(el => document.body.appendChild(el)); 22 | 23 | const sb = SubjectsBounds.create( 24 | window, 25 | layer, 26 | trigger, 27 | scrollContainer1, 28 | null, 29 | [scrollContainer1, scrollContainer2], 30 | false 31 | ); 32 | 33 | it("creates a collection of bounds from relevant subjects", () => { 34 | // including window bounds 35 | expect(sb.scrollContainers.length).toBe(3); 36 | // checking if a valid Bounds-instance was instantiated 37 | expect(sb.layer.width).toBe(200); 38 | }); 39 | 40 | it("creates a collection of bounds by merging another partial ISubjectBounds-object", () => { 41 | const merged = sb.merge({ layer: Bounds.empty() }); 42 | 43 | expect(merged).not.toBe(sb); 44 | expect(merged.layer).toEqual(Bounds.empty()); 45 | }); 46 | 47 | it("tells if the trigger has a bigger width than the layer", () => { 48 | expect(sb.triggerHasBiggerWidth).toBe(false); 49 | }); 50 | it("tells if the trigger has a bigger height than the layer", () => { 51 | expect(sb.triggerHasBiggerHeight).toBe(false); 52 | }); 53 | 54 | it("returns the BoundsOffsets for each scroll-container relative to the layer", () => { 55 | expect(sb.layerOffsetsToScrollContainers).toEqual([ 56 | { top: 100, left: 0, right: 600, bottom: 300 }, 57 | { top: -200, left: 0, right: 600, bottom: 600 }, 58 | { top: -800, left: 0, right: 600, bottom: 1200 } 59 | ]); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/react-laag/tests/integration/misc-behavior.spec.tsx: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { fireEvent, waitFor } from "@testing-library/react"; 3 | import { render, cleanup, fixViewport } from "./util"; 4 | 5 | before(fixViewport); 6 | 7 | afterEach(cleanup); 8 | 9 | it("fires an callback (closes layer) when clicked outside", async () => { 10 | const tools = render({ closeOnOutsideClick: true }); 11 | 12 | tools.clickTrigger(); 13 | 14 | await waitFor(() => tools.getLayer()); 15 | 16 | // We should be able to click the layer without closing 17 | tools.clickLayer(); 18 | tools.getLayer(); 19 | 20 | tools.clickOutside(); 21 | await waitFor(() => expect(tools.queryByTestId("layer")).toBeNull()); 22 | }); 23 | 24 | it("handles nested layers with outside click", async () => { 25 | const tools = render({ closeOnOutsideClick: true }); 26 | 27 | tools.clickTrigger(); 28 | 29 | await waitFor(() => tools.getLayer()); 30 | 31 | tools.clickLayer(); 32 | 33 | await waitFor(() => tools.queryByTestId("nested-layer")); 34 | 35 | // we should be able to click the nested layer without closing 36 | fireEvent.click(tools.queryByTestId("nested-layer")); 37 | // nested layer should be there 38 | tools.getByTestId("nested-layer"); 39 | 40 | tools.clickOutside(); 41 | 42 | // both layers should be closed 43 | await waitFor(() => expect(tools.queryByTestId("layer")).toBeNull()); 44 | await waitFor(() => expect(tools.queryByTestId("nested-layer")).toBeNull()); 45 | }); 46 | 47 | it("closes on disappearance when the layer is partially hidden and overflowContainer is false", async () => { 48 | const tools = render({ 49 | overflowContainer: false, 50 | closeOnDisappear: "partial" 51 | }); 52 | 53 | tools.clickTrigger(); 54 | 55 | // just entirely visible 56 | tools.scrollContainer(722, 700); 57 | 58 | tools.getLayer(); 59 | 60 | // scroll a bit further so that layer isn't entirely visible 61 | tools.scrollContainer(724, 700); 62 | 63 | await waitFor(() => expect(tools.queryByTestId("layer")).toBeNull()); 64 | }); 65 | 66 | it("closes on disappearance when the layer is fully hidden and overflowContainer is false", async () => { 67 | const tools = render({ 68 | overflowContainer: false, 69 | closeOnDisappear: "full" 70 | }); 71 | 72 | tools.clickTrigger(); 73 | 74 | tools.getLayer(); 75 | 76 | // scroll so that layer is completely hidde 77 | tools.scrollContainer(975, 700); 78 | 79 | await waitFor(() => expect(tools.queryByTestId("layer")).toBeNull()); 80 | }); 81 | 82 | it("closes on disappearance when the trigger is partially hidden and overflowContainer is true", async () => { 83 | const tools = render({ 84 | closeOnDisappear: "partial" 85 | }); 86 | 87 | tools.clickTrigger(); 88 | 89 | // just entirely visible 90 | tools.scrollContainer(970, 700); 91 | 92 | tools.getLayer(); 93 | 94 | // scroll a bit further so that trigger isn't entirely visible 95 | tools.scrollContainer(978, 700); 96 | 97 | await waitFor(() => expect(tools.queryByTestId("layer")).toBeNull()); 98 | }); 99 | 100 | it("closes on disappearance when the trigger is fully hidden and overflowContainer is false", async () => { 101 | const tools = render({ 102 | overflowContainer: false, 103 | closeOnDisappear: "full" 104 | }); 105 | 106 | tools.clickTrigger(); 107 | 108 | tools.getLayer(); 109 | 110 | // scroll so that layer is completely hidde 111 | tools.scrollContainer(1027, 700); 112 | 113 | await waitFor(() => expect(tools.queryByTestId("layer")).toBeNull()); 114 | }); 115 | -------------------------------------------------------------------------------- /packages/react-laag/tests/integration/util.tsx: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import * as React from "react"; 3 | import { 4 | RenderResult, 5 | render as baseRender, 6 | fireEvent, 7 | cleanup as baseCleanup, 8 | waitFor 9 | } from "@testing-library/react"; 10 | import { baseOptions, TestCaseOptions } from "../../sandbox/options"; 11 | import { TestCase } from "../../sandbox/TestCase"; 12 | import { LayerSide, Placement, PLACEMENT_TYPES } from "../../src"; 13 | import { IBounds } from "../../src/Bounds"; 14 | import { setViewport } from "@web/test-runner-commands"; 15 | 16 | export async function fixViewport() { 17 | return setViewport({ width: 1400, height: 900 }); 18 | } 19 | 20 | function elementToBounds(element: HTMLElement): IBounds { 21 | const { top, left, bottom, right, width, height } = 22 | element.getBoundingClientRect(); 23 | 24 | return { top, left, bottom, right, width, height }; 25 | } 26 | 27 | interface CustomRenderResult extends RenderResult { 28 | getTrigger: () => HTMLElement; 29 | getLayer: () => HTMLElement; 30 | getArrow: () => HTMLElement; 31 | clickTrigger: () => void; 32 | clickLayer: () => void; 33 | clickOutside: () => void; 34 | scrollContainer: (top: number, left: number) => void; 35 | scrollWindow: (top: number, left: number) => void; 36 | getLayerBounds: () => IBounds; 37 | getArrowBounds: () => IBounds; 38 | reRender: (options: Partial) => void; 39 | getLayerSide: () => LayerSide; 40 | expectBounds: (props: { 41 | layerSide: LayerSide; 42 | layer: IBounds; 43 | arrow: IBounds; 44 | }) => Promise; 45 | } 46 | 47 | function scrollWindow(top: number, left: number) { 48 | window.scrollTo(left, top); 49 | } 50 | 51 | export type ExpectBoundsProps = { 52 | layerSide: LayerSide; 53 | layer: IBounds; 54 | arrow: IBounds; 55 | }; 56 | 57 | export function render( 58 | options: Partial = {} 59 | ): CustomRenderResult { 60 | const tools = baseRender(); 61 | 62 | function getTrigger() { 63 | return tools.getByTestId("trigger"); 64 | } 65 | 66 | function getLayer() { 67 | return tools.getByTestId("layer"); 68 | } 69 | 70 | function getArrow() { 71 | return tools.getByTestId("arrow"); 72 | } 73 | 74 | function clickTrigger() { 75 | fireEvent.click(getTrigger()); 76 | } 77 | 78 | async function clickLayer() { 79 | fireEvent.click(getLayer()); 80 | } 81 | 82 | async function clickOutside() { 83 | fireEvent.click(document.body); 84 | } 85 | 86 | function scrollContainer(top: number, left: number) { 87 | const scrollContainer = tools.getByTestId("scroll-container"); 88 | scrollContainer.scrollTop = top; 89 | scrollContainer.scrollLeft = left; 90 | } 91 | 92 | function getLayerBounds() { 93 | const layer = getLayer(); 94 | return elementToBounds(layer); 95 | } 96 | 97 | function getArrowBounds() { 98 | const arrow = getArrow(); 99 | return elementToBounds(arrow); 100 | } 101 | 102 | function getLayerSide() { 103 | const element = tools.getByTestId("layer-side"); 104 | return element.innerText as LayerSide; 105 | } 106 | 107 | async function expectBounds(props: ExpectBoundsProps) { 108 | return waitFor(() => { 109 | expect(getLayerSide()).toEqual(props.layerSide); 110 | expect(getLayerBounds()).toEqual(props.layer); 111 | expect(getArrowBounds()).toEqual(props.arrow); 112 | }); 113 | } 114 | 115 | function reRender(options: Partial = {}) { 116 | tools.rerender(); 117 | } 118 | 119 | return { 120 | ...tools, 121 | getTrigger, 122 | getLayer, 123 | clickTrigger, 124 | clickLayer, 125 | clickOutside, 126 | scrollContainer, 127 | scrollWindow, 128 | getLayerBounds, 129 | reRender, 130 | expectBounds, 131 | getLayerSide, 132 | getArrowBounds, 133 | getArrow 134 | }; 135 | } 136 | 137 | export function getLayerSideByPlacement(placement: Placement): LayerSide { 138 | return placement.split("-")[0] as LayerSide; 139 | } 140 | 141 | export async function cleanup() { 142 | baseCleanup(); 143 | scrollWindow(0, 0); 144 | } 145 | 146 | export const PLACEMENTS_NO_CENTER = PLACEMENT_TYPES.filter( 147 | placement => placement !== "center" 148 | ); 149 | -------------------------------------------------------------------------------- /packages/react-laag/tests/test-util.ts: -------------------------------------------------------------------------------- 1 | import { Bounds } from "../src/Bounds"; 2 | import { act } from "@testing-library/react"; 3 | 4 | export function createElement( 5 | tag: K, 6 | style: Partial = {}, 7 | rest: Record = {}, 8 | children: HTMLElement[] = [] 9 | ) { 10 | const element = document.createElement(tag); 11 | 12 | for (const [key, value] of Object.entries(style)) { 13 | (element as any).style[key] = value; 14 | } 15 | for (const [key, value] of Object.entries(rest)) { 16 | (element as any)[key] = value; 17 | } 18 | for (const child of children) { 19 | element.appendChild(child); 20 | } 21 | 22 | return element; 23 | } 24 | 25 | export function clearBody() { 26 | Array.from(document.body.children).forEach(child => { 27 | document.body.removeChild(child); 28 | }); 29 | } 30 | 31 | export function boundsByDimensions(width: number, height: number) { 32 | return Bounds.create({ 33 | left: 0, 34 | top: 0, 35 | right: width, 36 | bottom: height, 37 | width, 38 | height 39 | }); 40 | } 41 | 42 | export function scroll( 43 | element: HTMLElement, 44 | options: { top?: number; left?: number } 45 | ) { 46 | act(() => { 47 | if (options.top) { 48 | element.scrollTop = options.top; 49 | } 50 | if (options.left) { 51 | element.scrollLeft = options.left; 52 | } 53 | }); 54 | } 55 | 56 | export function mockFn() { 57 | let callCount = 0; 58 | let lastArgs: any = null; 59 | 60 | function mock() { 61 | callCount++; 62 | lastArgs = arguments; 63 | } 64 | 65 | mock.callCount = () => callCount; 66 | mock.lastArgs = () => lastArgs; 67 | return mock; 68 | } 69 | -------------------------------------------------------------------------------- /packages/react-laag/tests/useTrackElements.spec.tsx: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import * as React from "react"; 3 | import { render, cleanup, waitFor } from "@testing-library/react"; 4 | import { scroll } from "./test-util"; 5 | import { 6 | useTrackElements, 7 | UseTrackElementsProps 8 | } from "../src/useTrackElements"; 9 | import { ResizeObserver, ResizeObserverCallback } from "../src/types"; 10 | import { mockFn } from "./test-util"; 11 | 12 | class MockResizeObserver implements ResizeObserver { 13 | static observer: ResizeObserverCallback; 14 | 15 | constructor(observer: ResizeObserverCallback) { 16 | MockResizeObserver.observer = observer; 17 | // call the observer initially to fake default behavior 18 | observer(null!, null!); 19 | } 20 | 21 | observe() {} 22 | 23 | disconnect() {} 24 | 25 | unobserve() {} 26 | 27 | static triggerResize() { 28 | this.observer(null!, null!); 29 | } 30 | } 31 | 32 | const SCROLL_CONTAINER_SIZE = 1000; 33 | const FILLER_SIZE = 2000; 34 | const TRIGGER_SIZE = 100; 35 | const LAYER_SIZE = 200; 36 | 37 | const TRIGGER_STYLE: React.CSSProperties = { 38 | position: "relative", 39 | top: 500, 40 | left: 500, 41 | width: TRIGGER_SIZE, 42 | height: TRIGGER_SIZE, 43 | backgroundColor: "green" 44 | }; 45 | 46 | const CONTAINER_STYLE: React.CSSProperties = { 47 | backgroundColor: "lightgrey", 48 | position: "relative", 49 | top: 0, 50 | left: 0, 51 | width: SCROLL_CONTAINER_SIZE, 52 | height: SCROLL_CONTAINER_SIZE, 53 | overflow: "auto" 54 | }; 55 | 56 | const FILLER_STYLE: React.CSSProperties = { 57 | position: "absolute", 58 | top: 0, 59 | left: 0, 60 | height: FILLER_SIZE, 61 | width: FILLER_SIZE 62 | }; 63 | 64 | const LAYER_STYLE: React.CSSProperties = { 65 | width: LAYER_SIZE, 66 | height: LAYER_SIZE, 67 | backgroundColor: "blue" 68 | }; 69 | 70 | type TestCaseProps = { 71 | onChange: UseTrackElementsProps["onChange"]; 72 | showLayer: boolean; 73 | swapTrigger?: boolean; 74 | }; 75 | 76 | function TestCase({ onChange, showLayer, swapTrigger }: TestCaseProps) { 77 | const { triggerRef, layerRef } = useTrackElements({ 78 | overflowContainer: false, 79 | onChange, 80 | enabled: showLayer, 81 | environment: window, 82 | ResizeObserverPolyfill: MockResizeObserver 83 | }); 84 | 85 | return ( 86 |
87 | {swapTrigger ? ( 88 | 57 | 58 | ); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/storybook/stories/PopoverMenu/popover-menu.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Canvas } from "@storybook/addon-docs/blocks"; 2 | import { Button } from "../../components/Button"; 3 | import { ScrollBox } from "../../components/ScrollBox"; 4 | import { PopoverMenu } from "./example"; 5 | 6 | 7 | 8 | # Popover Menu 9 | 10 | This examples showcase one of the most used cases involving layers: a popover select-menu. 11 | A couple of noticeable behaviors: 12 | 13 | - when you scroll inside the box, the position will automatically adjust itself 14 | - when you click outside the trigger / menu the menu will close itself 15 | - when you scroll the menu out of sight the menu will close itself 16 | 17 | 18 | 19 | 20 | 21 | ## The code 22 | 23 | ```jsx 24 | import { useLayer, Arrow } from "react-laag"; 25 | import { motion, AnimatePresence } from "framer-motion"; 26 | 27 | function PopoverMenu() { 28 | const [isOpen, setOpen] = React.useState(false); 29 | 30 | // helper function to close the menu 31 | function close() { 32 | setOpen(false); 33 | } 34 | 35 | const { renderLayer, triggerProps, layerProps, arrowProps } = useLayer({ 36 | isOpen, 37 | onOutsideClick: close, // close the menu when the user clicks outside 38 | onDisappear: close, // close the menu when the menu gets scrolled out of sight 39 | overflowContainer: false, // keep the menu positioned inside the container 40 | auto: true, // automatically find the best placement 41 | placement: "top-end", // we prefer to place the menu "top-end" 42 | triggerOffset: 12, // keep some distance to the trigger 43 | containerOffset: 16, // give the menu some room to breath relative to the container 44 | arrowOffset: 16 // let the arrow have some room to breath also 45 | }); 46 | 47 | // Again, we're using framer-motion for the transition effect 48 | return ( 49 | <> 50 | 53 | {renderLayer( 54 | 55 | {isOpen && ( 56 | 57 |
  • Item 1
  • 58 |
  • Item 2
  • 59 |
  • Item 3
  • 60 |
  • Item 4
  • 61 | 62 | 63 | )} 64 |
    65 | )} 66 | 67 | ); 68 | } 69 | ``` 70 | -------------------------------------------------------------------------------- /packages/storybook/stories/TextSelection/example.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useLayer, Arrow } from "react-laag"; 3 | import { TooltipBox, BG_COLOR, BORDER_COLOR } from "../../components/Tooltip"; 4 | 5 | function useSelection() { 6 | const ref = React.useRef(null!); 7 | 8 | const [range, setRange] = React.useState(null); 9 | 10 | React.useEffect(() => { 11 | function handleChange() { 12 | const selection = window.getSelection(); 13 | if ( 14 | !selection || 15 | selection.isCollapsed || 16 | !selection.containsNode(ref.current, true) 17 | ) { 18 | setRange(null); 19 | return; 20 | } 21 | 22 | setRange(selection.getRangeAt(0)); 23 | } 24 | 25 | document.addEventListener("selectionchange", handleChange); 26 | return () => document.removeEventListener("selectionchange", handleChange); 27 | }, []); 28 | 29 | return { range, ref }; 30 | } 31 | 32 | export function TextSelection() { 33 | const { range, ref } = useSelection(); 34 | 35 | const isOpen = Boolean(range); 36 | 37 | const { renderLayer, layerProps, arrowProps } = useLayer({ 38 | isOpen, 39 | overflowContainer: true, 40 | placement: "top-center", 41 | triggerOffset: 12, 42 | containerOffset: 16, 43 | arrowOffset: 16, 44 | trigger: { 45 | getBounds: () => range!.getBoundingClientRect() 46 | } 47 | }); 48 | 49 | return ( 50 | <> 51 |
    52 | A wonderful serenity has taken possession of my entire soul, like these 53 | sweet mornings of spring which I enjoy with my whole heart. I am alone, 54 | and feel the charm of existence in this spot, which was created for the 55 | bliss of souls like mine. I am so happy, my dear friend, so absorbed in 56 | the exquisite sense of mere tranquil existence, that I neglect my 57 | talents. I should be incapable of drawing a single stroke at the present 58 | moment; and yet I feel that I never was a greater artist than now. When, 59 | while the lovely valley teems with vapour around me, and the meridian 60 | sun strikes the upper surface of the impenetrable foliage of my trees, 61 | and but a few stray gleams steal into the inner sanctuary, I throw 62 | myself down among the tall grass by the trickling stream; and, as I lie 63 | close to the earth, a thousand unknown plants are noticed by me: when I 64 | hear the buzz of the little world among the stalks, and grow familiar 65 | with the countless indescribable forms of the insects and flies, then I 66 | feel the presence of the Almighty, who formed us in his own image, and 67 | the breath 68 |
    69 | {isOpen && 70 | renderLayer( 71 | 72 | {range?.toString().replace(/\s/g, "").length} characters 73 | 80 | 81 | )} 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /packages/storybook/stories/TextSelection/text-selection.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Canvas } from "@storybook/addon-docs/blocks"; 2 | import { Button } from "../../components/Button"; 3 | import { TextSelection } from "./example"; 4 | 5 | 6 | 7 | # Text Selection 8 | 9 | This example demonstrates that the trigger doesn't always have to be an element. In the case of text selection we want the 10 | layer to 'attach' itself to the boundaries given by the selection-api on the window. 11 | 12 | Select some text below in order to see how many characters your selection contains: 13 | 14 | 15 | 16 | 17 | 18 | ## The code 19 | 20 | ### Getting information about our selection 21 | 22 | In order to show information about our selection and where to place the layer we need some logic. 23 | Let's build a hook for this purpose -> `useTextSelection()`. 24 | 25 | ```jsx 26 | function useTextSelection() { 27 | // we need a reference to the element wrapping the text in order to determine 28 | // if the selection is the selection we are after 29 | const ref = React.useRef(); 30 | 31 | // we store info about the current Range here 32 | const [range, setRange] = React.useState(null); 33 | 34 | // In this effect we're registering for the documents "selectionchange" event 35 | React.useEffect(() => { 36 | function handleChange() { 37 | // get selection information from the browser 38 | const selection = window.getSelection(); 39 | 40 | // we only want to proceed when we have a valid selection 41 | if ( 42 | !selection || 43 | selection.isCollapsed || 44 | !selection.containsNode(ref.current, true) 45 | ) { 46 | setRange(null); 47 | return; 48 | } 49 | 50 | setRange(selection.getRangeAt(0)); 51 | } 52 | 53 | document.addEventListener("selectionchange", handleChange); 54 | return () => document.removeEventListener("selectionchange", handleChange); 55 | }, []); 56 | 57 | return { range, ref }; 58 | } 59 | ``` 60 | 61 | ### The final component 62 | 63 | Ok, so we're creating a pretty dumb component. It takes content via the `children` prop and shows the number 64 | of chars when the user has selected something. 65 | 66 | ```jsx 67 | function SelectionInfo({ children }) { 68 | // The hook we've created earlier 69 | const { range, ref } = useTextSelection(); 70 | 71 | const showSelectionInfo = Boolean(range); 72 | 73 | // The most important part here is the `trigger` option. 74 | // Since we don't have any concrete trigger-element, we can tell react-laag 75 | // where to position the layer this way. 76 | // What's pretty cool is the fact that a Range object has a `getBoundingClientRect()` 77 | // as well! 78 | const { renderLayer, layerProps, arrowProps } = useLayer({ 79 | isOpen: showSelectionInfo, 80 | trigger: { 81 | getBounds: () => range.getBoundingClientRect() 82 | } 83 | }); 84 | 85 | return ( 86 | <> 87 |
    {children}
    88 | {showSelectionInfo && 89 | renderLayer( 90 |
    91 | {range.toString().replace(/\s/g, "").length} characters 92 | 93 |
    94 | )} 95 | 96 | ); 97 | } 98 | ``` 99 | -------------------------------------------------------------------------------- /packages/storybook/stories/Tooltip/tooltip.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Canvas } from "@storybook/addon-docs/blocks"; 2 | import { Tooltip } from "../../components/Tooltip"; 3 | import { Button } from "../../components/Button"; 4 | 5 | 6 | 7 | # Tooltip 8 | 9 | A tooltip is perhaps the most often used ui-pattern involving layers. 10 | react-laag exposes the right building blocks to create the perfect tooltip component for your own use-cases. 11 | 12 | Let's create such a tooltip component right now! Basically, we want a `` component that can wrap all kinds of things, such as plain text or another component. 13 | We want the tooltip to show when the user hovers over the wrapped content, but only when a certain time has elapsed. So preferably not straight away, because we don't want to introduce chaos when to user is just moving the cursor across the page. 14 | Next, a bit of fade-in/fade-out animation would be nice. 15 | 16 | This is what our `` component will look like: 17 | 18 | ```jsx 19 | 20 | 21 | 22 | ``` 23 | 24 | This is an example of the `` component with various wrapped contents in action: 25 | 26 | 27 |
    28 | A wonderful serenity has taken{" "} 29 | possession of my entire soul, like 30 | these sweet mornings of spring which I enjoy with my whole heart. I am 31 | alone, and feel the charm of existence in this spot, which was{" "} 32 | created for the bliss of souls 33 | like mine. I am so happy, my dear friend, so{" "} 34 | absorbed in the exquisite sense of 35 | mere tranquil existence, that I neglect my talents. 36 |
    37 |
    38 | 39 | 40 | 41 |
    42 |
    43 | 44 | ## The code 45 | 46 | ```jsx 47 | import * as React from "react"; 48 | import { useLayer, useHover, Arrow } from "react-laag"; 49 | import { motion, AnimatePresence } from "framer-motion"; 50 | 51 | export function Tooltip({ children, text }) { 52 | // We use `useHover()` to determine whether we should show the tooltip. 53 | // Notice how we're configuring a small delay on enter / leave. 54 | const [isOver, hoverProps] = useHover({ delayEnter: 100, delayLeave: 300 }); 55 | 56 | // Tell `useLayer()` how we would like to position our tooltip 57 | const { triggerProps, layerProps, arrowProps, renderLayer } = useLayer({ 58 | isOpen: isOver, 59 | placement: "top-center", 60 | triggerOffset: 8 // small gap between wrapped content and the tooltip 61 | }); 62 | 63 | // when children equals text (string | number), we need to wrap it in an 64 | // extra span-element in order to attach props 65 | let trigger; 66 | if (isReactText(children)) { 67 | trigger = ( 68 | 69 | {children} 70 | 71 | ); 72 | } else { 73 | // In case of an react-element, we need to clone it in order to attach our own props 74 | trigger = React.cloneElement(children, { 75 | ...triggerProps, 76 | ...hoverProps 77 | }); 78 | } 79 | 80 | // We're using framer-motion for our enter / exit animations. 81 | // This is why we need to wrap our actual tooltip inside ``. 82 | // The only thing left is to describe which styles we would like to animate. 83 | return ( 84 | <> 85 | {trigger} 86 | {renderLayer( 87 | 88 | {isOver && ( 89 | 97 | {text} 98 | 105 | 106 | )} 107 | 108 | )} 109 | 110 | ); 111 | } 112 | 113 | function isReactText(children) { 114 | return ["string", "number"].includes(typeof children); 115 | } 116 | ``` -------------------------------------------------------------------------------- /packages/storybook/theme.js: -------------------------------------------------------------------------------- 1 | import { create } from "@storybook/theming/create"; 2 | import sbLogo from "./sblogo.png"; 3 | 4 | export const theme = create({ 5 | base: "light", 6 | 7 | colorPrimary: "green", 8 | colorSecondary: "#dc68b3", 9 | 10 | // UI 11 | appBg: "#fdf7fb", 12 | appContentBg: "white", 13 | appBorderColor: "#f7e0ef", 14 | appBorderRadius: 4, 15 | 16 | // Typography 17 | fontCode: "Consolas, Menlo, Monaco, source-code-pro, Courier New, monospace", 18 | 19 | // Text colors 20 | textColor: "black", 21 | textInverseColor: "rgba(255,255,255,0.9)", 22 | 23 | // Toolbar default and active colors 24 | barTextColor: "#e0b9d1", 25 | barSelectedColor: "#b72c86", 26 | 27 | // Form colors 28 | inputBg: "white", 29 | inputBorder: "purple", 30 | inputTextColor: "black", 31 | inputBorderRadius: 4, 32 | 33 | brandTitle: "react-laag", 34 | brandUrl: "https://www.react-laag.com", 35 | brandImage: sbLogo 36 | }); 37 | -------------------------------------------------------------------------------- /packages/storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["stories", "components"], 4 | "exclude": ["node_modules"], 5 | "compilerOptions": { 6 | "skipLibCheck": true, 7 | "skipDefaultLibCheck": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/website/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc" 3 | } 4 | -------------------------------------------------------------------------------- /packages/website/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/website/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/website/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/website/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /packages/website/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /packages/website/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/favicon/favicon.ico -------------------------------------------------------------------------------- /packages/website/fonts/luckiest-guy-v10-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/luckiest-guy-v10-latin-regular.eot -------------------------------------------------------------------------------- /packages/website/fonts/luckiest-guy-v10-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/luckiest-guy-v10-latin-regular.ttf -------------------------------------------------------------------------------- /packages/website/fonts/luckiest-guy-v10-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/luckiest-guy-v10-latin-regular.woff -------------------------------------------------------------------------------- /packages/website/fonts/luckiest-guy-v10-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/luckiest-guy-v10-latin-regular.woff2 -------------------------------------------------------------------------------- /packages/website/fonts/noto-sans-v9-latin-700.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/noto-sans-v9-latin-700.eot -------------------------------------------------------------------------------- /packages/website/fonts/noto-sans-v9-latin-700.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/noto-sans-v9-latin-700.ttf -------------------------------------------------------------------------------- /packages/website/fonts/noto-sans-v9-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/noto-sans-v9-latin-700.woff -------------------------------------------------------------------------------- /packages/website/fonts/noto-sans-v9-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/noto-sans-v9-latin-700.woff2 -------------------------------------------------------------------------------- /packages/website/fonts/noto-sans-v9-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/noto-sans-v9-latin-regular.eot -------------------------------------------------------------------------------- /packages/website/fonts/noto-sans-v9-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/noto-sans-v9-latin-regular.ttf -------------------------------------------------------------------------------- /packages/website/fonts/noto-sans-v9-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/noto-sans-v9-latin-regular.woff -------------------------------------------------------------------------------- /packages/website/fonts/noto-sans-v9-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everweij/react-laag/400f215f32077a69fd0b44e400003818895ec265/packages/website/fonts/noto-sans-v9-latin-regular.woff2 -------------------------------------------------------------------------------- /packages/website/gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import "core-js/stable"; 2 | import "regenerator-runtime/runtime"; 3 | import "normalize.css"; 4 | import "./src/main.css"; 5 | import "./src/prism.css"; 6 | 7 | if (!Element.prototype.matches) { 8 | Element.prototype.matches = 9 | Element.prototype.msMatchesSelector || 10 | Element.prototype.webkitMatchesSelector; 11 | } 12 | -------------------------------------------------------------------------------- /packages/website/gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteMetadata: { 3 | title: `react-laag`, 4 | description: `Hooks for positioning tooltips & popovers`, 5 | author: `Erik Verweij`, 6 | siteUrl: "https://www.react-laag.com", 7 | keywords: [ 8 | "react", 9 | "hook", 10 | "layer", 11 | "tooltip", 12 | "popover", 13 | "dropdown", 14 | "menu" 15 | ], 16 | image: "/logo.svg" 17 | }, 18 | plugins: [ 19 | `gatsby-plugin-react-helmet`, 20 | "gatsby-plugin-styled-components", 21 | { 22 | resolve: `gatsby-plugin-favicon`, 23 | options: { 24 | logo: "./favicon/android-chrome-512x512.png", 25 | 26 | icons: { 27 | android: true, 28 | appleIcon: false, 29 | appleStartup: false, 30 | coast: false, 31 | favicons: true, 32 | firefox: true, 33 | yandex: false, 34 | windows: false 35 | } 36 | } 37 | }, 38 | { 39 | resolve: `gatsby-plugin-google-analytics`, 40 | options: { 41 | trackingId: "UA-149386814-1", 42 | head: true, 43 | anonymize: false 44 | } 45 | }, 46 | "gatsby-plugin-sitemap", 47 | { 48 | resolve: "gatsby-plugin-robots-txt", 49 | options: { 50 | host: "https://www.react-laag.com", 51 | sitemap: "https://www.react-laag.com/sitemap.xml", 52 | policy: [{ userAgent: "*" }] 53 | } 54 | } 55 | ] 56 | }; 57 | -------------------------------------------------------------------------------- /packages/website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-laag-website", 3 | "private": true, 4 | "description": "Official website of react-laag to demostrate its capabilities and docs", 5 | "version": "0.1.0", 6 | "author": "Erik Verweij", 7 | "dependencies": { 8 | "copy-text-to-clipboard": "^3.0.1", 9 | "framer-motion": "^4.1.17", 10 | "gatsby": "^2.32.13", 11 | "gatsby-image": "^2.11.0", 12 | "gatsby-plugin-favicon": "^3.1.6", 13 | "gatsby-plugin-react-helmet": "^3.10.0", 14 | "gatsby-plugin-google-analytics": "^2.11.0", 15 | "gatsby-plugin-robots-txt": "^1.5.0", 16 | "gatsby-plugin-sitemap": "^2.12.0", 17 | "normalize.css": "^8.0.1", 18 | "prismjs": "^1.22.0", 19 | "prop-types": "^15.7.2", 20 | "query-string": "^7.0.0", 21 | "react": "^17.0.2", 22 | "react-dom": "^17.0.2", 23 | "react-helmet": "^6.1.0", 24 | "react-laag": "*", 25 | "react-use-gesture": "^5.2.4", 26 | "styled-components": "^5.3.0", 27 | "styled-icons": "^10.34.0" 28 | }, 29 | "devDependencies": { 30 | "@types/prettier": "^2.3.0", 31 | "@types/prismjs": "^1.16.5", 32 | "@types/react": "^17.0.11", 33 | "@types/react-dom": "^17.0.7", 34 | "@types/react-helmet": "^6.1.1", 35 | "@types/styled-components": "^5.1.10", 36 | "babel-plugin-styled-components": "^1.12.0", 37 | "gatsby-plugin-styled-components": "^3.10.0", 38 | "typescript": "^4.3.2" 39 | }, 40 | "keywords": [ 41 | "react-laag" 42 | ], 43 | "license": "MIT", 44 | "scripts": { 45 | "build": "gatsby build", 46 | "develop": "gatsby develop", 47 | "start": "npm run develop", 48 | "serve": "gatsby serve", 49 | "clean": "gatsby clean" 50 | }, 51 | "browserslist": [ 52 | ">0.25%", 53 | "not dead", 54 | "ie 11", 55 | "safari 11" 56 | ] 57 | } -------------------------------------------------------------------------------- /packages/website/src/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useLayer } from "react-laag"; 4 | 5 | import { Copy } from "@styled-icons/boxicons-solid/Copy"; 6 | import copy from "copy-text-to-clipboard"; 7 | import NotifyTip from "./NotifyTip"; 8 | import { mergeRefs } from "react-laag"; 9 | 10 | const CopyButtonBase = styled.button` 11 | color: #b5799f; 12 | width: 32px; 13 | height: 32px; 14 | border: 0; 15 | box-shadow: 0; 16 | background-color: white; 17 | outline: 0; 18 | /* border-radius: 3px; */ 19 | background-image: linear-gradient( 20 | -180deg, 21 | #ffffff 0%, 22 | #ffebf9 4%, 23 | #ffeaf8 13%, 24 | #efdbe9 78%, 25 | #e2bed6 98%, 26 | #804a6e 100% 27 | ); 28 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.13); 29 | cursor: pointer; 30 | 31 | :hover { 32 | color: #88436f; 33 | filter: brightness(0.97); 34 | } 35 | :active { 36 | color: #88436f; 37 | filter: brightness(0.92); 38 | } 39 | `; 40 | 41 | type CopyButtonProps = { 42 | text: string; 43 | } & React.ComponentPropsWithoutRef<"button">; 44 | 45 | const CopyButton = React.forwardRef( 46 | function CopyButton({ text, ...rest }, ref) { 47 | const [showCopied, setShowCopied] = React.useState(false); 48 | 49 | const { triggerProps, layerProps, renderLayer } = useLayer({ 50 | isOpen: showCopied, 51 | placement: "bottom-center", 52 | triggerOffset: 8, 53 | auto: true 54 | }); 55 | 56 | async function copyToClipboard() { 57 | try { 58 | copy(text); 59 | 60 | if (!showCopied) { 61 | setShowCopied(true); 62 | 63 | setTimeout(() => { 64 | setShowCopied(false); 65 | }, 1000); 66 | } 67 | } catch (e) { 68 | setShowCopied(false); 69 | } 70 | } 71 | 72 | return ( 73 | <> 74 | 80 | 81 | 82 | {renderLayer( 83 | 84 | Copied to clipboard! 85 | 86 | )} 87 | 88 | ); 89 | } 90 | ); 91 | 92 | export default CopyButton; 93 | -------------------------------------------------------------------------------- /packages/website/src/components/Features.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { Arrow } from "react-laag"; 4 | import { colors, media } from "../theme"; 5 | import useMedia from "../useMedia"; 6 | 7 | const Base = styled.div` 8 | position: relative; 9 | background: #fbfbfb; 10 | box-shadow: 15px 16px 50px 0 rgba(110, 26, 82, 0.55); 11 | max-width: 356px; 12 | width: 100%; 13 | padding: 48px; 14 | color: ${colors.text}; 15 | margin-top: 48px; 16 | border-radius: 4px; 17 | 18 | h2 { 19 | text-align: center; 20 | margin-top: 0; 21 | margin-bottom: 24px; 22 | } 23 | 24 | @media ${media.tablet} { 25 | border-radius: 8px; 26 | } 27 | 28 | @media ${media.desktop} { 29 | margin-top: 0px; 30 | position: absolute; 31 | left: calc(100% + 56px); 32 | top: 50%; 33 | transform: translateY(-50%); 34 | } 35 | `; 36 | 37 | const ItemBase = styled.li` 38 | display: flex; 39 | 40 | > *:first-child { 41 | font-style: normal; 42 | font-size: 20px; 43 | margin-right: 16px; 44 | } 45 | > *:last-child { 46 | flex: 1; 47 | } 48 | 49 | h3 { 50 | font-family: "Noto Sans"; 51 | font-size: 16px; 52 | font-weight: 700; 53 | margin: 0; 54 | margin-bottom: 6px; 55 | color: #232022; 56 | } 57 | 58 | p { 59 | font-size: 14px; 60 | color: #484848; 61 | margin: 0; 62 | } 63 | `; 64 | 65 | type ItemProps = { 66 | icon: string; 67 | title: string; 68 | children: string; 69 | }; 70 | 71 | function Item({ icon, title, children }: ItemProps) { 72 | return ( 73 | 74 | {icon} 75 |
    76 |

    {title}

    77 |

    {children}

    78 |
    79 |
    80 | ); 81 | } 82 | 83 | const Items = styled.ul` 84 | list-style: none; 85 | margin: 0; 86 | padding: 0; 87 | 88 | > *:not(:last-child) { 89 | margin-bottom: 24px; 90 | } 91 | `; 92 | 93 | function Features() { 94 | const isBottom = useMedia(1275); 95 | 96 | return ( 97 | 98 |

    Features

    99 | 100 | 101 | Only 8kb minified & gzipped / tree-shakable / no dependencies 102 | 103 | 104 | We do the positioning, you do the rest. You maintain full control over 105 | the look and feel. 106 | 107 | 108 | Optimized for performance / no scroll lag whatsoever 109 | 110 | 111 | Comes with sensible defaults out of the box, but you can tweak things 112 | to your liking 113 | 114 | 115 | 126 | 127 | ); 128 | } 129 | 130 | export default Features; 131 | -------------------------------------------------------------------------------- /packages/website/src/components/InstallBox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import WithTooltip from "./WithTooltip"; 5 | import CopyButton from "./CopyButton"; 6 | import { colors } from "../theme"; 7 | 8 | const Base = styled.div` 9 | padding-left: 24px; 10 | color: #e8d8e3; 11 | background-color: ${colors["bg-code"]}; 12 | font-family: Consolas, Menlo, Monaco, source-code-pro, Courier New, monospace; 13 | font-size: 14px; 14 | border-radius: 3px; 15 | display: flex; 16 | align-items: center; 17 | overflow: hidden; 18 | 19 | > *:last-child { 20 | margin-left: 24px; 21 | } 22 | 23 | > :first-child::selection { 24 | background-color: pink; 25 | } 26 | `; 27 | 28 | type Props = { 29 | children: string; 30 | }; 31 | 32 | function InstallBox({ children }: Props) { 33 | return ( 34 | 35 |
    { 37 | window.getSelection()!.selectAllChildren(evt.currentTarget); 38 | }} 39 | > 40 | {children} 41 |
    42 | 43 | {props => ( 44 | 45 | )} 46 | 47 | 48 | ); 49 | } 50 | 51 | export default InstallBox; 52 | -------------------------------------------------------------------------------- /packages/website/src/components/NotifyTip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { Check } from "@styled-icons/boxicons-regular/Check"; 4 | import { colors } from "../theme"; 5 | 6 | const NotifyTipBase = styled.div<{ $show: boolean }>` 7 | background-color: ${colors.green}; 8 | color: white; 9 | padding: 6px 12px; 10 | border-radius: 3px; 11 | box-shadow: 3px 5px 16px 0px rgba(0, 0, 0, 0.15); 12 | display: flex; 13 | align-items: center; 14 | font-size: 14px; 15 | opacity: ${p => (p.$show ? 1 : 0)}; 16 | transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; 17 | pointer-events: none; 18 | transform-origin: top; 19 | transform: ${p => 20 | p.$show ? `scale(1) translateY(0px)` : `scale(0.9) translateY(10px)`}; 21 | 22 | > *:first-child { 23 | margin-right: 8px; 24 | color: white; 25 | } 26 | `; 27 | 28 | const NotifyTip = React.forwardRef< 29 | HTMLDivElement, 30 | React.ComponentPropsWithoutRef<"div"> & { show: boolean } 31 | >(function NotifyTip({ children, show, ...props }, ref) { 32 | return ( 33 | 34 | 35 |
    {children}
    36 |
    37 | ); 38 | }); 39 | 40 | export default NotifyTip; 41 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import { Checkmark as Check } from "@styled-icons/icomoon/Checkmark"; 5 | import { colors } from "../../theme"; 6 | 7 | const SIZE_DEFAULT = 16; 8 | const SIZE_SMALL = 14; 9 | 10 | const Base = styled.div<{ size: number; disabled?: boolean }>` 11 | width: ${p => p.size / 16}rem; 12 | height: ${p => p.size / 16}rem; 13 | display: inline-flex; 14 | justify-content: center; 15 | align-items: center; 16 | border-width: 1px; 17 | border-style: solid; 18 | border-radius: 3px; 19 | flex-shrink: 0; 20 | opacity: ${p => (p.disabled ? 0.5 : 1)}; 21 | transition: 0.15s ease-in-out; 22 | `; 23 | 24 | const IconWrapper = styled.div` 25 | width: 100%; 26 | height: 100%; 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | `; 31 | 32 | type Props = { 33 | value?: boolean; 34 | small?: boolean; 35 | disabled?: boolean; 36 | variant?: "default" | "partial"; 37 | } & React.ComponentPropsWithoutRef<"div">; 38 | 39 | export default React.forwardRef(function Checkbox( 40 | { value, small, variant = "default", disabled, onClick, style, ...rest }, 41 | ref 42 | ) { 43 | const [isHovered, setHovered] = React.useState(false); 44 | 45 | const selected = value; 46 | const size = small ? SIZE_SMALL : SIZE_DEFAULT; 47 | 48 | return ( 49 | setHovered(true)} 53 | onMouseLeave={() => setHovered(false)} 54 | style={{ 55 | ...style, 56 | backgroundColor: disabled 57 | ? colors["grey-200"] 58 | : selected 59 | ? colors["bg-dark"] 60 | : "#ffffff", 61 | borderColor: disabled 62 | ? colors["grey-300"] 63 | : selected 64 | ? colors["bg-dark"] 65 | : colors[`grey-${isHovered ? 700 : 500}`], 66 | color: isHovered && !selected ? colors["gradient-light"] : "#ffffff", 67 | cursor: disabled ? "default" : "pointer" 68 | }} 69 | role="checkbox" 70 | aria-checked={value} 71 | disabled={disabled} 72 | onClick={disabled ? undefined : onClick} 73 | {...rest} 74 | > 75 | 82 | 89 | 90 | 91 | ); 92 | }); 93 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/Code.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DEFAULT_OPTIONS } from "react-laag"; 3 | import Prism from "prismjs"; 4 | import "prismjs/components/prism-jsx"; 5 | import "prismjs/components/prism-typescript"; 6 | import "prismjs/components/prism-tsx"; 7 | import { format as prettier } from "prettier/standalone"; 8 | import parserBabel from "prettier/parser-babel"; 9 | import { Options, LayerSettings } from "./types"; 10 | import CopyButton from "../CopyButton"; 11 | 12 | function noNewLines(...args: (string | false)[]) { 13 | return args.filter(Boolean).join(", "); 14 | } 15 | 16 | function createCode( 17 | { 18 | overflowContainer, 19 | placement, 20 | auto, 21 | snap, 22 | preferX, 23 | preferY, 24 | possiblePlacements, 25 | triggerOffset, 26 | containerOffset, 27 | arrowOffset, 28 | closeOnDisappear, 29 | closeOnOutsideClick 30 | }: Options, 31 | layerSettings: LayerSettings 32 | ) { 33 | let code = ` 34 | import * as React from "react"; 35 | import { useLayer, Arrow } from "react-laag"; 36 | import { Button, Menu } from "./ui"; 37 | 38 | function Example() { 39 | const [isOpen, setOpen] = React.useState(false); 40 | 41 | const { renderLayer, triggerProps, layerProps, arrowProps } = useLayer({ 42 | isOpen, 43 | ${noNewLines( 44 | overflowContainer !== DEFAULT_OPTIONS.overflowContainer && 45 | `overflowContainer: ${String(!DEFAULT_OPTIONS.overflowContainer)}`, 46 | placement !== DEFAULT_OPTIONS.placement && 47 | `placement: "${placement}"`, 48 | auto !== DEFAULT_OPTIONS.auto && `auto: ${!DEFAULT_OPTIONS.auto}`, 49 | auto && 50 | possiblePlacements.length !== 51 | DEFAULT_OPTIONS.possiblePlacements.length && 52 | `possiblePlacements: [${possiblePlacements 53 | .filter(x => x !== "center") 54 | .map(placement => `"${placement}"`)}]`, 55 | auto && 56 | snap !== DEFAULT_OPTIONS.snap && 57 | `snap: ${!DEFAULT_OPTIONS.snap}`, 58 | auto && 59 | preferX !== DEFAULT_OPTIONS.preferX && 60 | `preferX: "${ 61 | ["left", "right"].filter( 62 | side => side !== DEFAULT_OPTIONS.preferX 63 | )[0] 64 | }"`, 65 | auto && 66 | preferY !== DEFAULT_OPTIONS.preferY && 67 | `preferY: "${ 68 | ["top", "bottom"].filter( 69 | side => side !== DEFAULT_OPTIONS.preferY 70 | )[0] 71 | }"`, 72 | triggerOffset !== DEFAULT_OPTIONS.triggerOffset && 73 | `triggerOffset: ${triggerOffset}`, 74 | containerOffset !== DEFAULT_OPTIONS.containerOffset && 75 | `containerOffset: ${containerOffset}`, 76 | arrowOffset !== DEFAULT_OPTIONS.arrowOffset && 77 | `arrowOffset: ${arrowOffset}`, 78 | closeOnDisappear === "partial" 79 | ? `onDisappear: () => setOpen(false)` 80 | : closeOnDisappear === "full" 81 | ? `onDisappear: (disappearType) => { if (disappearType === "full") { setOpen(false) } }` 82 | : false, 83 | closeOnOutsideClick && "onOutsideClick: () => setOpen(false)" 84 | )} 85 | }); 86 | 87 | return ( 88 | <> 89 | 95 | {isOpen && renderLayer( 96 | 99 | Layer 100 | 106 | 107 | )} 108 | 109 | ); 110 | } 111 | `; 112 | 113 | return code; 114 | } 115 | 116 | type CodeProps = { 117 | options: Options; 118 | layerSettings: LayerSettings; 119 | }; 120 | 121 | export default function Code({ layerSettings, options }: CodeProps) { 122 | const ref = React.useRef(null!); 123 | 124 | React.useEffect(() => { 125 | Prism.highlightElement(ref.current); 126 | }); 127 | 128 | const code = prettier(createCode(options, layerSettings), { 129 | tabWidth: 2, 130 | parser: "babel", 131 | printWidth: 70, 132 | trailingComma: "none", 133 | plugins: [parserBabel] 134 | }); 135 | 136 | return ( 137 | <> 138 |
    139 |
    140 |           {code}
    141 |         
    142 |
    143 | 147 | 148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/Label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import WithTooltip from "../WithTooltip"; 4 | import { InfoCircle } from "@styled-icons/fa-solid/InfoCircle"; 5 | 6 | const LabelBase = styled.label` 7 | display: flex; 8 | align-items: center; 9 | font-size: 12px; 10 | font-weight: 700; 11 | margin-bottom: 0px; 12 | 13 | :not(:first-of-type) { 14 | margin-top: 16px; 15 | } 16 | 17 | svg { 18 | margin-left: 4px; 19 | } 20 | `; 21 | 22 | type LabelProps = { 23 | children: string; 24 | info?: string; 25 | } & React.ComponentPropsWithoutRef<"label">; 26 | 27 | function Label({ children, info, ...rest }: LabelProps) { 28 | return ( 29 | 30 | {children} 31 | {info && ( 32 | 33 | 34 | 35 | )} 36 | 37 | ); 38 | } 39 | 40 | export default Label; 41 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/Radio.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { colors } from "../../theme"; 4 | 5 | const Base = styled.div<{ $disabled?: boolean }>` 6 | width: 16px; 7 | height: 16px; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | border-radius: 50%; 12 | border: 1px solid 13 | ${p => (p.$disabled ? colors["grey-300"] : colors["grey-500"])}; 14 | background-color: ${p => (p.$disabled ? colors["grey-200"] : "white")}; 15 | transition: 0.15s ease-in-out; 16 | 17 | &:hover { 18 | border: 1px solid ${colors["grey-700"]}; 19 | } 20 | `; 21 | 22 | const Circle = styled.div<{ $disabled?: boolean }>` 23 | border-radius: 50%; 24 | width: 8px; 25 | height: 8px; 26 | background-color: ${p => 27 | p.$disabled ? colors["grey-300"] : colors["bg-dark"]}; 28 | transition: 0.15s ease-in-out; 29 | `; 30 | 31 | type Props = { 32 | value?: boolean; 33 | disabled?: boolean; 34 | } & React.ComponentPropsWithoutRef<"div">; 35 | 36 | function Radio({ onClick, value, style, disabled, ...rest }: Props) { 37 | return ( 38 | 44 | 45 | 46 | ); 47 | } 48 | 49 | export default Radio; 50 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/RadioGroup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import Radio from "./Radio"; 4 | 5 | const RadioGroupBase = styled.div` 6 | display: flex; 7 | margin-top: 6px; 8 | `; 9 | 10 | const RadioGroupItem = styled.div` 11 | display: flex; 12 | align-items: center; 13 | font-size: 14px; 14 | 15 | :not(:last-child) { 16 | margin-right: 8px; 17 | } 18 | 19 | input { 20 | margin-right: 4px; 21 | } 22 | 23 | div { 24 | cursor: default; 25 | } 26 | `; 27 | 28 | type RadioGroupProps = { 29 | items: { value: any; display: string }[]; 30 | value: any; 31 | onChange: (value: string) => void; 32 | disabled?: boolean; 33 | }; 34 | 35 | function RadioGroup({ items, value, onChange, disabled }: RadioGroupProps) { 36 | return ( 37 | 38 | {items.map(item => { 39 | return ( 40 | { 43 | if (disabled) { 44 | return; 45 | } 46 | 47 | onChange(item.value); 48 | }} 49 | > 50 | 55 |
    {item.display}
    56 |
    57 | ); 58 | })} 59 |
    60 | ); 61 | } 62 | 63 | export default RadioGroup; 64 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/Range.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import Slider from "./Slider"; 4 | 5 | const RangeBase = styled.div` 6 | display: flex; 7 | align-items: center; 8 | 9 | input { 10 | flex: 1; 11 | } 12 | 13 | > *:last-child { 14 | margin-left: 8px; 15 | width: 2ch; 16 | font-size: 12px; 17 | } 18 | `; 19 | 20 | type RangeProps = { 21 | min: number; 22 | max: number; 23 | step: number; 24 | value: number; 25 | onChange: (value: number) => void; 26 | }; 27 | 28 | function Range({ min, max, step, value, onChange }: RangeProps) { 29 | return ( 30 | 31 | 39 |
    {Math.round(value * 10) / 10}
    40 |
    41 | ); 42 | } 43 | 44 | export default Range; 45 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/Slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useGesture } from "react-use-gesture"; 3 | import { mergeRefs } from "react-laag"; 4 | import { colors } from "../../theme"; 5 | 6 | import styled from "styled-components"; 7 | 8 | const BAR_HEIGHT = 5; 9 | const CIRCLE_SIZE = 16; 10 | 11 | const Base = styled.div` 12 | position: relative; 13 | height: ${CIRCLE_SIZE}px; 14 | margin-top: 2px; 15 | `; 16 | 17 | const Bar = styled.div` 18 | position: absolute; 19 | left: 0; 20 | right: 0; 21 | top: 50%; 22 | border-radius: 5px; 23 | transform: translateY(-50%); 24 | height: ${BAR_HEIGHT}px; 25 | overflow: hidden; 26 | background-color: ${colors["grey-200"]}; 27 | `; 28 | 29 | const Circle = styled.div` 30 | position: absolute; 31 | top: 0; 32 | left: 0; 33 | border-radius: 50%; 34 | width: ${CIRCLE_SIZE}px; 35 | height: ${CIRCLE_SIZE}px; 36 | background-color: ${colors["bg-dark"]}; 37 | cursor: pointer; 38 | cursor: -webkit-grab; 39 | 40 | &:active { 41 | cursor: -webkit-grabbing; 42 | } 43 | `; 44 | 45 | interface SingleProps { 46 | style?: React.CSSProperties; 47 | className?: string; 48 | min?: number; 49 | max?: number; 50 | step?: number; 51 | value: number; 52 | onChange: (value: number) => void; 53 | } 54 | 55 | function useDimensions() { 56 | const ref = React.useRef(); 57 | const [state, setState] = React.useState({ width: 0, height: 0 }); 58 | 59 | React.useLayoutEffect(() => { 60 | const { width, height } = ref.current!.getBoundingClientRect(); 61 | setState({ width, height }); 62 | }, []); 63 | 64 | return [ref, state] as [any, typeof state]; 65 | } 66 | 67 | function round(num: number, increment: number, offset: number) { 68 | return Math.ceil((num - offset) / increment) * increment + offset; 69 | } 70 | 71 | type UseSliderProps = { 72 | value: number; 73 | width: number; 74 | min: number; 75 | max: number; 76 | onChange: (value: number) => void; 77 | step: number; 78 | }; 79 | 80 | function useSlider({ value, width, onChange, min, max, step }: UseSliderProps) { 81 | const lastValue = React.useRef(value); 82 | 83 | const [isDragging, setDragging] = React.useState(false); 84 | 85 | const bind = useGesture({ 86 | onDragEnd: () => { 87 | setDragging(false); 88 | }, 89 | onDragStart: (props: any) => { 90 | lastValue.current = value; 91 | setDragging(true); 92 | props.event.preventDefault(); 93 | }, 94 | onDrag: ({ delta }) => { 95 | const movedValue = (max - min) * (delta[0] / width); 96 | 97 | let newValue = lastValue.current! + movedValue; 98 | if (newValue < min) { 99 | newValue = min; 100 | } 101 | if (newValue > max) { 102 | newValue = max; 103 | } 104 | 105 | onChange(round(newValue, step, min)); 106 | } 107 | }); 108 | 109 | const x = ((value - min) / (max - min)) * width; 110 | 111 | return [bind as any, x as number, isDragging as boolean]; 112 | } 113 | 114 | function useCircleAnimation(isDragging: boolean) { 115 | const [isOver, setOver] = React.useState(false); 116 | 117 | const style: React.CSSProperties = { 118 | backgroundColor: isDragging 119 | ? colors["gradient-light"] 120 | : isOver 121 | ? colors["gradient-light"] 122 | : colors["gradient-dark"], 123 | boxShadow: `0px 1px 2px 0.5px rgba(0, 0, 0, ${isDragging ? 0.4 : 0.2})` 124 | }; 125 | 126 | return [ 127 | style, 128 | { 129 | onMouseEnter: () => setOver(true), 130 | onMouseLeave: () => setOver(false) 131 | } 132 | ] as const; 133 | } 134 | 135 | export default React.forwardRef(function Single( 136 | { 137 | style, 138 | className, 139 | value = 0, 140 | min = 0, 141 | max = 100, 142 | onChange, 143 | step = 1 144 | }: SingleProps, 145 | ref: any 146 | ) { 147 | const [baseRef, { width }] = useDimensions(); 148 | 149 | const [bind, x, isDragging] = useSlider({ 150 | value, 151 | width, 152 | onChange, 153 | min, 154 | max, 155 | step 156 | }); 157 | 158 | const [aniStyle, isOverBind] = useCircleAnimation(isDragging); 159 | 160 | return ( 161 | 162 | 163 | 171 | 172 | ); 173 | }); 174 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { DEFAULT_OPTIONS } from "react-laag"; 4 | import Preview from "./Preview"; 5 | import { Options as OptionsType, LayerSettings } from "./types"; 6 | import Options from "./Options"; 7 | import Code from "./Code"; 8 | import { colors } from "../../theme"; 9 | 10 | const SCROLL_BOX_INNER_SIZE = 2000; 11 | const SCROLL_BOX_SIZE = 480; 12 | const BUTTON_SIZE = { 13 | width: 106, 14 | height: 46 15 | }; 16 | 17 | const Layout = styled.div` 18 | min-height: 100vh; 19 | width: 100vw; 20 | max-width: 1680px; 21 | margin: 0 auto; 22 | display: flex; 23 | position: relative; 24 | align-items: flex-start; 25 | `; 26 | 27 | const OptionsContainer = styled.div` 28 | width: 260px; 29 | min-width: 260px; 30 | min-height: 600px; 31 | height: 100vh; 32 | position: sticky; 33 | overflow: auto; 34 | top: 0px; 35 | background: #fbfbfb; 36 | box-shadow: 4px 5px 20px 0 rgba(110, 26, 82, 0.08); 37 | `; 38 | 39 | const PreviewContainer = styled.div` 40 | flex: 2; 41 | min-height: 600px; 42 | height: 200vh; 43 | display: flex; 44 | justify-content: center; 45 | background: radial-gradient(#df75b8, #c34597); 46 | background-size: 100vh; 47 | background-position: 50%; 48 | `; 49 | 50 | const ScrollBox = styled.div` 51 | background-color: #d05aa4; 52 | width: ${SCROLL_BOX_SIZE}px; 53 | height: ${SCROLL_BOX_SIZE}px; 54 | overflow: scroll; 55 | border-radius: 4px; 56 | border: 2px dashed #a94382; 57 | margin-top: calc(100vh / 2 - ${SCROLL_BOX_SIZE}px / 2); 58 | position: relative; 59 | box-shadow: inset 1px 1px 20px 6px rgba(130, 11, 86, 0.16); 60 | 61 | &::-webkit-scrollbar { 62 | -webkit-appearance: none; 63 | 64 | :vertical { 65 | width: 7px; 66 | } 67 | 68 | :horizontal { 69 | height: 7px; 70 | } 71 | } 72 | 73 | &::-webkit-scrollbar-thumb { 74 | border-radius: 5px; 75 | background-color: ${colors["bg-dark"]}; 76 | } 77 | 78 | &::-webkit-scrollbar-corner { 79 | -webkit-appearance: none; 80 | background-color: ${colors["bg-dark"]}; 81 | } 82 | `; 83 | 84 | const CodeContainer = styled.div` 85 | flex: 1; 86 | max-width: 800px; 87 | min-width: 700px; 88 | height: 100vh; 89 | min-height: 600px; 90 | background-color: ${colors["bg-code"]}; 91 | position: sticky; 92 | padding: 32px; 93 | top: 0; 94 | overflow: auto; 95 | `; 96 | 97 | function Playground() { 98 | const scrollBoxRef = React.useRef(null!); 99 | 100 | const [options, setOptions] = React.useState({ 101 | placement: DEFAULT_OPTIONS.placement, 102 | possiblePlacements: DEFAULT_OPTIONS.possiblePlacements, 103 | preferX: DEFAULT_OPTIONS.preferX, 104 | preferY: DEFAULT_OPTIONS.preferY, 105 | auto: DEFAULT_OPTIONS.auto, 106 | snap: DEFAULT_OPTIONS.snap, 107 | arrowOffset: 4, 108 | containerOffset: DEFAULT_OPTIONS.containerOffset, 109 | triggerOffset: DEFAULT_OPTIONS.triggerOffset, 110 | overflowContainer: DEFAULT_OPTIONS.overflowContainer, 111 | closeOnDisappear: false, 112 | closeOnOutsideClick: false 113 | }); 114 | 115 | const [layerSettings, setLayerSettings] = React.useState({ 116 | width: 200, 117 | height: 150, 118 | color: "light", 119 | arrowSize: 5, 120 | arrowRoundness: 0 121 | }); 122 | 123 | React.useEffect(() => { 124 | scrollBoxRef.current.scrollTop = 125 | SCROLL_BOX_INNER_SIZE / 2 - SCROLL_BOX_SIZE / 2 + BUTTON_SIZE.height / 2; 126 | scrollBoxRef.current.scrollLeft = 127 | SCROLL_BOX_INNER_SIZE / 2 - SCROLL_BOX_SIZE / 2 + BUTTON_SIZE.width / 2; 128 | }, []); 129 | 130 | return ( 131 | 132 | 133 | 134 | 135 | 136 | 137 | 147 |
    153 | 154 | 155 | 156 | 157 | 158 | 159 | ); 160 | } 161 | 162 | export default Playground; 163 | -------------------------------------------------------------------------------- /packages/website/src/components/Playground/types.ts: -------------------------------------------------------------------------------- 1 | import { UseLayerOptions } from "react-laag"; 2 | 3 | export type Options = Required< 4 | Omit< 5 | UseLayerOptions, 6 | | "isOpen" 7 | | "onOutsideClick" 8 | | "ResizeObserver" 9 | | "onDisappear" 10 | | "environment" 11 | | "onParentClose" 12 | | "layerDimensions" 13 | > 14 | > & { 15 | closeOnOutsideClick: boolean; 16 | closeOnDisappear: false | "partial" | "full"; 17 | }; 18 | 19 | export type LayerSettings = { 20 | width: number; 21 | height: number; 22 | color: "light" | "dark"; 23 | arrowSize: number; 24 | arrowRoundness: number; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/website/src/components/PrimaryButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { media } from "../theme"; 3 | 4 | const PrimaryButton = styled.a` 5 | user-select: none; 6 | text-decoration: none; 7 | background-image: linear-gradient( 8 | -180deg, 9 | #d9ffed 0%, 10 | #a1d2bb 4%, 11 | #8ecbb5 14%, 12 | #66b699 78%, 13 | #5ea289 98%, 14 | #48876f 100% 15 | ); 16 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.13); 17 | border-radius: 3px; 18 | font-size: 14px; 19 | padding: 10px 16px; 20 | color: white; 21 | font-weight: 700; 22 | letter-spacing: 0.1px; 23 | text-shadow: 0 0 2px rgba(0, 0, 0, 0.15); 24 | filter: saturate(1.3); 25 | cursor: pointer; 26 | 27 | :hover { 28 | filter: saturate(1.5) brightness(1.05); 29 | } 30 | 31 | :active { 32 | filter: saturate(1.5) brightness(1.1); 33 | } 34 | 35 | @media ${media.tablet} { 36 | padding: 12px 24px; 37 | font-size: 16px; 38 | } 39 | `; 40 | 41 | export default PrimaryButton; 42 | -------------------------------------------------------------------------------- /packages/website/src/components/SecondaryButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { colors } from "../theme"; 3 | import { media } from "../theme"; 4 | 5 | const SecondaryButton = styled.a` 6 | user-select: none; 7 | text-decoration: none; 8 | background-image: linear-gradient( 9 | -180deg, 10 | #ffffff 0%, 11 | #ffebf9 4%, 12 | #ffeaf8 13%, 13 | #efdbe9 78%, 14 | #e2bed6 98%, 15 | #804a6e 100% 16 | ); 17 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.13); 18 | border-radius: 3px; 19 | font-size: 14px; 20 | padding: 10px 16px; 21 | color: ${colors.text}; 22 | font-weight: 700; 23 | letter-spacing: 0.1px; 24 | filter: saturate(1.3); 25 | cursor: pointer; 26 | 27 | :hover { 28 | filter: saturate(1.5) brightness(1.05); 29 | } 30 | 31 | :active { 32 | filter: saturate(1.5) brightness(1.1); 33 | } 34 | 35 | @media ${media.tablet} { 36 | padding: 12px 24px; 37 | font-size: 16px; 38 | } 39 | `; 40 | 41 | export default SecondaryButton; 42 | -------------------------------------------------------------------------------- /packages/website/src/components/WithTooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | useLayer, 4 | Arrow, 5 | useHover, 6 | Placement, 7 | UseHoverProps, 8 | UseLayerProps 9 | } from "react-laag"; 10 | import styled from "styled-components"; 11 | 12 | import { colors } from "../theme"; 13 | 14 | type Position = "top" | "left" | "right" | "bottom"; 15 | 16 | type ForwardedProps = UseLayerProps["triggerProps"] & UseHoverProps; 17 | 18 | type WithTooltipProps = { 19 | text: string; 20 | children: 21 | | React.ReactElement 22 | | (( 23 | props: UseHoverProps & UseLayerProps["triggerProps"] 24 | ) => React.ReactElement); 25 | position?: Position; 26 | auto?: boolean; 27 | maxWidth?: number; 28 | }; 29 | 30 | const positionMap: Record = { 31 | top: "top-center", 32 | left: "left-center", 33 | right: "right-center", 34 | bottom: "bottom-center" 35 | }; 36 | 37 | const Tooltip = styled.div` 38 | padding: 4px 8px; 39 | line-height: 1.15; 40 | background-color: ${colors["bg-code"]}; 41 | color: white; 42 | font-size: 12.8px; 43 | pointer-events: none; 44 | border-radius: 3px; 45 | transition: opacity 0.1s ease-in-out; 46 | opacity: 1; 47 | `; 48 | 49 | export default function WithTooltip({ 50 | text, 51 | children, 52 | position = "bottom", 53 | auto = false, 54 | maxWidth 55 | }: WithTooltipProps) { 56 | const [isOpen, hoverProps] = useHover({ delayEnter: 250, delayLeave: 250 }); 57 | 58 | const { layerProps, triggerProps, renderLayer, arrowProps } = useLayer({ 59 | isOpen: isOpen, 60 | placement: positionMap[position], 61 | triggerOffset: 8, 62 | possiblePlacements: [ 63 | "top-center", 64 | "bottom-center", 65 | "left-center", 66 | "right-center" 67 | ], 68 | auto, 69 | snap: true 70 | }); 71 | 72 | const forwardedProps: ForwardedProps = { 73 | ...triggerProps, 74 | ...hoverProps 75 | }; 76 | 77 | return ( 78 | <> 79 | {isOpen && 80 | renderLayer( 81 | 85 | {text} 86 | 92 | 93 | )} 94 | {typeof children === "function" 95 | ? children(forwardedProps) 96 | : React.cloneElement(children, forwardedProps)} 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /packages/website/src/components/seo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Helmet } from "react-helmet"; 3 | import { useStaticQuery, graphql } from "gatsby"; 4 | 5 | function SEO() { 6 | const { site } = useStaticQuery( 7 | graphql` 8 | query { 9 | site { 10 | siteMetadata { 11 | title 12 | description 13 | author 14 | keywords 15 | image 16 | } 17 | } 18 | } 19 | ` 20 | ); 21 | 22 | const metaDescription = site.siteMetadata.description; 23 | 24 | const img = `${site.siteMetadata.siteUrl}${site.siteMetadata.image}`; 25 | 26 | const fullTitle = site.siteMetadata.title; 27 | 28 | return ( 29 | 81 | ); 82 | } 83 | 84 | export default SEO; 85 | -------------------------------------------------------------------------------- /packages/website/src/main.css: -------------------------------------------------------------------------------- 1 | /* noto-sans-regular - latin */ 2 | @font-face { 3 | font-family: "Noto Sans"; 4 | font-display: swap; 5 | font-style: normal; 6 | font-weight: 400; 7 | src: url("../fonts/noto-sans-v9-latin-regular.eot"); /* IE9 Compat Modes */ 8 | src: local("Noto Sans"), local("NotoSans"), 9 | url("../fonts/noto-sans-v9-latin-regular.eot?#iefix") 10 | format("embedded-opentype"), 11 | /* IE6-IE8 */ url("../fonts/noto-sans-v9-latin-regular.woff2") 12 | format("woff2"), 13 | /* Super Modern Browsers */ url("../fonts/noto-sans-v9-latin-regular.woff") 14 | format("woff"), 15 | /* Modern Browsers */ url("../fonts/noto-sans-v9-latin-regular.ttf") 16 | format("truetype"), 17 | /* Safari, Android, iOS */ 18 | url("../fonts/noto-sans-v9-latin-regular.svg#NotoSans") format("svg"); /* Legacy iOS */ 19 | } 20 | /* noto-sans-700 - latin */ 21 | @font-face { 22 | font-family: "Noto Sans"; 23 | font-display: swap; 24 | font-style: normal; 25 | font-weight: 700; 26 | src: url("../fonts/noto-sans-v9-latin-700.eot"); /* IE9 Compat Modes */ 27 | src: local("Noto Sans Bold"), local("NotoSans-Bold"), 28 | url("../fonts/noto-sans-v9-latin-700.eot?#iefix") 29 | format("embedded-opentype"), 30 | /* IE6-IE8 */ url("../fonts/noto-sans-v9-latin-700.woff2") format("woff2"), 31 | /* Super Modern Browsers */ url("../fonts/noto-sans-v9-latin-700.woff") 32 | format("woff"), 33 | /* Modern Browsers */ url("../fonts/noto-sans-v9-latin-700.ttf") 34 | format("truetype"), 35 | /* Safari, Android, iOS */ 36 | url("../fonts/noto-sans-v9-latin-700.svg#NotoSans") format("svg"); /* Legacy iOS */ 37 | } 38 | 39 | /* luckiest-guy-regular - latin */ 40 | @font-face { 41 | font-family: "Luckiest Guy"; 42 | font-display: swap; 43 | font-style: normal; 44 | font-weight: 400; 45 | src: url("../fonts/luckiest-guy-v10-latin-regular.eot"); /* IE9 Compat Modes */ 46 | src: local("Luckiest Guy Regular"), local("LuckiestGuy-Regular"), 47 | url("../fonts/luckiest-guy-v10-latin-regular.eot?#iefix") 48 | format("embedded-opentype"), 49 | /* IE6-IE8 */ url("../fonts/luckiest-guy-v10-latin-regular.woff2") 50 | format("woff2"), 51 | /* Super Modern Browsers */ 52 | url("../fonts/luckiest-guy-v10-latin-regular.woff") format("woff"), 53 | /* Modern Browsers */ url("../fonts/luckiest-guy-v10-latin-regular.ttf") 54 | format("truetype"), 55 | /* Safari, Android, iOS */ 56 | url("../fonts/luckiest-guy-v10-latin-regular.svg#LuckiestGuy") 57 | format("svg"); /* Legacy iOS */ 58 | } 59 | 60 | * { 61 | box-sizing: border-box !important; 62 | } 63 | 64 | html { 65 | font-family: "Noto Sans", sans-serif; 66 | font-size: 16px; 67 | line-height: 1.3; 68 | color: #393036; 69 | -webkit-font-smoothing: antialiased; 70 | -moz-osx-font-smoothing: grayscale; 71 | } 72 | 73 | h1, 74 | h2, 75 | h3 { 76 | font-family: "Luckiest Guy"; 77 | font-weight: 400; 78 | } 79 | 80 | body { 81 | color: white; 82 | background-color: #c34597; 83 | width: 100%; 84 | min-height: 100vh; 85 | } 86 | -------------------------------------------------------------------------------- /packages/website/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import SEO from "../components/seo"; 4 | 5 | const NotFoundPage = () => ( 6 |
    7 | 8 |

    NOT FOUND

    9 |

    You just hit a route that doesn't exist... the sadness.

    10 |
    11 | ); 12 | 13 | export default NotFoundPage; 14 | -------------------------------------------------------------------------------- /packages/website/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import SEO from "../components/seo"; 4 | import Logo from "../components/Logo"; 5 | import InstallBox from "../components/InstallBox"; 6 | import PrimaryButton from "../components/PrimaryButton"; 7 | import SecondaryButton from "../components/SecondaryButton"; 8 | import Features from "../components/Features"; 9 | import Playground from "../components/Playground"; 10 | import useMedia from "../useMedia"; 11 | 12 | import { colors, media } from "../theme"; 13 | 14 | const Landing = styled.main` 15 | width: 100vw; 16 | max-width: 1680px; 17 | margin: 0 auto; 18 | min-height: 100vh; 19 | 20 | background: radial-gradient( 21 | ${colors["gradient-light"]} 0%, 22 | ${colors["gradient-dark"]} 70% 23 | ); 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | background-size: min(200vh, 1600px) min(200vh, 1600px); 28 | background-repeat: no-repeat; 29 | background-position: center 10%; 30 | 31 | @media ${media.huge} { 32 | min-height: initial; 33 | height: calc(100vh - 50px); 34 | height: calc(max(100vh, 900px) - 50px); 35 | } 36 | `; 37 | 38 | const IntroContent = styled.div` 39 | max-width: 480px; 40 | display: flex; 41 | flex-direction: column; 42 | align-items: center; 43 | position: relative; 44 | margin-top: 50px; 45 | margin-bottom: 0px; 46 | 47 | @media ${media.tablet} { 48 | margin-top: 100px; 49 | margin-bottom: 100px; 50 | } 51 | 52 | @media ${media.desktop} { 53 | margin-top: 0px; 54 | margin-bottom: 0px; 55 | } 56 | 57 | > svg { 58 | width: 42px; 59 | 60 | @media ${media.tablet} { 61 | width: 56px; 62 | } 63 | } 64 | `; 65 | 66 | const Title = styled.h1` 67 | text-shadow: 0 2px 3px rgba(0, 0, 0, 0.15); 68 | font-size: 32px; 69 | margin-bottom: 0px; 70 | 71 | @media ${media.tablet} { 72 | font-size: 48px; 73 | } 74 | `; 75 | 76 | const Tagline = styled.div` 77 | font-size: 18px; 78 | text-shadow: 0 2px 3px rgba(0, 0, 0, 0.1); 79 | margin-left: 16px; 80 | margin-right: 16px; 81 | margin-bottom: 16px; 82 | text-align: center; 83 | 84 | @media ${media.tablet} { 85 | font-size: 22px; 86 | margin-bottom: 56px; 87 | } 88 | `; 89 | 90 | const Buttons = styled.div` 91 | margin-top: 48px; 92 | display: flex; 93 | align-items: center; 94 | > *:not(:last-child) { 95 | margin-right: 24px; 96 | } 97 | `; 98 | 99 | const PlaygroundBanner = styled.div` 100 | padding: 32px 0px; 101 | display: flex; 102 | flex-direction: column; 103 | align-items: center; 104 | background-color: ${colors["bg-dark"]}; 105 | 106 | h2 { 107 | margin-top: 0; 108 | margin-bottom: 8px; 109 | } 110 | 111 | p { 112 | margin: 0; 113 | } 114 | `; 115 | 116 | const IndexPage = () => { 117 | const hidePlayground = useMedia(1348); 118 | const hideInstallBox = useMedia(480); 119 | 120 | return ( 121 | <> 122 | 123 | 124 | 125 | 126 | react-laag 127 | Hooks for positioning tooltips & popovers 128 | {!hideInstallBox && npm install react-laag} 129 | 130 | 131 | Examples 132 | 133 | 134 | Documentation 135 | 136 | 137 | 138 | 139 | 140 | {!hidePlayground && ( 141 | <> 142 | 143 |

    Playground

    144 |

    Try it out and see how it works!

    145 |
    146 | 147 | 148 | )} 149 | 150 | ); 151 | }; 152 | 153 | export default IndexPage; 154 | -------------------------------------------------------------------------------- /packages/website/src/prism.css: -------------------------------------------------------------------------------- 1 | p + div.code-highlight { 2 | margin-top: 32px; 3 | } 4 | 5 | code[class*="language-"], 6 | pre[class*="language-"] { 7 | font-size: 13px !important; 8 | color: white !important; 9 | background: none; 10 | font-family: Consolas, Menlo, Monaco, source-code-pro, Courier New, monospace; 11 | font-feature-settings: normal; 12 | text-align: left; 13 | white-space: pre; 14 | word-spacing: normal; 15 | word-break: normal; 16 | word-wrap: normal; 17 | line-height: 1.5; 18 | margin-bottom: 0; 19 | 20 | -moz-tab-size: 4; 21 | -o-tab-size: 4; 22 | tab-size: 4; 23 | 24 | -webkit-hyphens: none; 25 | -moz-hyphens: none; 26 | -ms-hyphens: none; 27 | hyphens: none; 28 | 29 | margin-top: 0px; 30 | } 31 | 32 | /* Code blocks */ 33 | pre[class*="language-"] { 34 | overflow: auto; 35 | padding: 1.3125rem !important; 36 | } 37 | 38 | pre[class*="language-"]::-moz-selection { 39 | /* Firefox */ 40 | background: hsl(207, 4%, 16%); 41 | } 42 | 43 | pre[class*="language-"]::selection { 44 | /* Safari */ 45 | background: hsl(207, 4%, 16%); 46 | } 47 | 48 | /* Text Selection colour */ 49 | pre[class*="language-"]::-moz-selection, 50 | pre[class*="language-"] ::-moz-selection { 51 | text-shadow: none; 52 | background: hsla(0, 0%, 100%, 0.15); 53 | } 54 | 55 | pre[class*="language-"]::selection, 56 | pre[class*="language-"] ::selection { 57 | text-shadow: none; 58 | background: hsla(0, 0%, 100%, 0.15); 59 | } 60 | 61 | .token.attr-name { 62 | color: #fbe0f6; 63 | font-style: italic; 64 | } 65 | 66 | .token.script.language-javascript 67 | .token.punctuation:not(.script-punctuation):not(:nth-child(2)):not(:last-child) { 68 | color: #c3a9ba; 69 | } 70 | 71 | .token.comment { 72 | color: rgb(96 220 141 / 59%); 73 | } 74 | 75 | .token.attr-value { 76 | color: #f7d38f; 77 | } 78 | 79 | .token.attr-value .token.punctuation:not(:first-child) { 80 | color: #f7d38f; 81 | } 82 | 83 | .token.string, 84 | .token.url { 85 | color: #ffbd85; 86 | } 87 | 88 | .token.variable { 89 | color: rgb(214, 222, 235); 90 | } 91 | 92 | .token.number { 93 | color: #dc85f9; 94 | } 95 | 96 | .token.spread .token.attr-value { 97 | color: #e6cce1; 98 | } 99 | 100 | .token.constant { 101 | color: #68cabc; 102 | } 103 | 104 | .token.builtin, 105 | .token.char { 106 | color: rgb(130, 170, 255); 107 | } 108 | 109 | .token.function { 110 | color: #ffe7a6; 111 | } 112 | 113 | .token.tag .punctuation { 114 | color: #ccaec6; 115 | } 116 | 117 | .token.tag .script { 118 | color: white; 119 | } 120 | 121 | .token.punctuation { 122 | color: #b59aab; 123 | } 124 | 125 | .token.script .token.punctuation:not(.script-punctuation) { 126 | color: #fb78cc; 127 | } 128 | 129 | .token.selector, 130 | .token.doctype { 131 | color: rgb(199, 146, 234); 132 | font-style: "italic"; 133 | } 134 | 135 | .token.class-name { 136 | color: #23c7b8; 137 | } 138 | 139 | .token.operator { 140 | color: #e4b4d1; 141 | } 142 | 143 | .token.tag { 144 | color: #2c9de8; 145 | } 146 | 147 | .token.keyword { 148 | color: #ff89d4; 149 | } 150 | 151 | .token.boolean { 152 | color: #3299ca; 153 | } 154 | 155 | .token.property { 156 | color: rgb(128, 203, 196); 157 | } 158 | 159 | .token.namespace { 160 | color: rgb(178, 204, 214); 161 | } 162 | 163 | pre[data-line] { 164 | padding: 1em 0 1em 3em; 165 | position: relative; 166 | } 167 | 168 | .code-highlight-code-line { 169 | background-color: hsla(207, 95%, 15%, 1); 170 | display: block; 171 | padding-right: 1em; 172 | padding-left: 1.25em; 173 | border-left: 0.25em solid #ffa7c4; 174 | } 175 | 176 | .code-highlight { 177 | -webkit-overflow-scrolling: touch; 178 | overflow: auto; 179 | } 180 | 181 | .code-highlight pre[class*="language-"] { 182 | float: left; 183 | min-width: 100%; 184 | } 185 | -------------------------------------------------------------------------------- /packages/website/src/theme.ts: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | "gradient-dark": "#c34597", 3 | "gradient-light": "#e580c0", 4 | "bg-dark": "#aa3580", 5 | "bg-code": "#211620", 6 | "bg-install": "#5f2a4c", 7 | green: "#819c1b", 8 | text: "#393036", 9 | border: "#eaeaea", 10 | "grey-100": "#f8f8f8", 11 | "grey-200": "#eeeeee", 12 | "grey-300": "#bfbfbf", 13 | "grey-400": "#9b9b9b", 14 | "grey-500": "#787878", 15 | "grey-600": "#555555", 16 | "grey-700": "#333333", 17 | "grey-800": "#212121", 18 | "grey-900": "#0f0f0f" 19 | }; 20 | 21 | export const media = { 22 | mobile: "(max-width: calc(48em - 1px)", 23 | tablet: "(min-width: 48em)", 24 | desktop: "(min-width: 1275px)", 25 | huge: "(min-width: 1348px)" 26 | }; 27 | -------------------------------------------------------------------------------- /packages/website/src/useMedia.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export default function useBreakpoint(maxPixels: number) { 4 | const [match, setMatch] = React.useState( 5 | typeof window !== "undefined" 6 | ? window.matchMedia(`(max-width: ${maxPixels}px)`).matches 7 | : false 8 | ); 9 | 10 | React.useEffect(() => { 11 | const matcher = window.matchMedia(`(max-width: ${maxPixels}px)`); 12 | 13 | function onMatch(evt: MediaQueryListEvent) { 14 | setMatch(evt.matches); 15 | } 16 | 17 | matcher.addListener(onMatch); 18 | setMatch(window.matchMedia(`(max-width: ${maxPixels}px)`).matches); 19 | 20 | return () => { 21 | matcher.removeListener(onMatch); 22 | }; 23 | }, [maxPixels]); 24 | 25 | return match; 26 | } 27 | -------------------------------------------------------------------------------- /packages/website/src/util.ts: -------------------------------------------------------------------------------- 1 | // export function mergeRefs(...refs: any[]) { 2 | // return (element: HTMLElement | null) => { 3 | // for (const ref of refs) { 4 | // if (!ref) { 5 | // continue; 6 | // } 7 | 8 | // if (typeof ref === "function") { 9 | // ref(element); 10 | // } else { 11 | // ref.current = element!; 12 | // } 13 | // } 14 | // }; 15 | // } 16 | -------------------------------------------------------------------------------- /packages/website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "target": "es5", 6 | "module": "esnext", 7 | "rootDir": "./src", 8 | "baseUrl": "./" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "esnext", 5 | "lib": ["dom", "esnext", "ES2019.Object"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "strictPropertyInitialization": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "moduleResolution": "node", 21 | "baseUrl": "./", 22 | "jsx": "react", 23 | "esModuleInterop": true, 24 | "downlevelIteration": true, 25 | "skipDefaultLibCheck": true, 26 | "skipLibCheck": true 27 | } 28 | } 29 | --------------------------------------------------------------------------------