├── .circleci
└── config.yml
├── .eslintrc.js
├── .github
├── codeql
│ └── codeql-config.yml
└── workflows
│ └── codeql.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .vscode
├── extensions.json
└── settings.json
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── demo
├── src
│ ├── AutoAlignment.tsx
│ ├── Demo.tsx
│ ├── PositionAlignOverview.tsx
│ ├── hooks
│ │ ├── index.ts
│ │ └── useWatcher.ts
│ ├── index.html
│ ├── index.tsx
│ └── regressions
│ │ ├── ButtonOverlay.tsx
│ │ ├── FitOnPage.tsx
│ │ ├── Regression.tsx
│ │ ├── Regressions.tsx
│ │ ├── SameWidth.tsx
│ │ ├── ScrollPosition.tsx
│ │ ├── StickInSvg.tsx
│ │ ├── StickNodeWidth.tsx
│ │ ├── StickOnHover.tsx
│ │ ├── StyledWithDataAttributes.tsx
│ │ ├── TransportToFixedContainer.tsx
│ │ └── index.ts
├── tsconfig.json
└── vite.config.ts
├── package-lock.json
├── package.json
├── renovate.json
├── src
├── Stick.tsx
├── StickContext.ts
├── StickInline.tsx
├── StickNode.tsx
├── StickPortal.tsx
├── defaultPosition.ts
├── hooks
│ ├── index.ts
│ ├── useAutoFlip.ts
│ └── useWatcher.ts
├── index.ts
├── tsconfig.json
├── types.ts
└── utils
│ ├── fit.ts
│ ├── getDefaultAlign.ts
│ ├── getModifiers.ts
│ ├── index.ts
│ ├── scroll.ts
│ └── uniqueId.ts
├── tests
├── cypress.config.ts
├── node
│ └── ssr.spec.tsx
├── src
│ ├── autoPositioning.test.tsx
│ ├── customComponent.test.tsx
│ ├── nodeWidth.test.tsx
│ ├── onClickOutside.test.tsx
│ ├── positioning.test.tsx
│ ├── scroll.test.tsx
│ ├── updates.test.tsx
│ └── utils.tsx
├── support
│ ├── commands.ts
│ ├── component-index.html
│ └── component.ts
├── tsconfig.json
└── vitest.config.ts
└── tsconfig.base.json
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | orbs:
4 | fortify: signavio/fortify@2.0.0
5 | blackduck: signavio/blackduck@2.1.0
6 | codecov: codecov/codecov@3.3.0
7 |
8 | executors:
9 | fortify:
10 | machine:
11 | image: &ubuntu "ubuntu-2204:current"
12 | resource_class: medium
13 |
14 | commands:
15 | install-codecov-requirements:
16 | steps:
17 | - run:
18 | name: "Install codecov requirements"
19 | command: |
20 | apt-get -y update && apt-get install -y git curl gpg
21 |
22 | install-cypress-requirements:
23 | steps:
24 | - run:
25 | name: "Install cypress requirements"
26 | command: |
27 | apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb
28 |
29 | fortify_scan:
30 | parameters:
31 | build_id:
32 | type: string
33 | path:
34 | type: string
35 | translate_opts:
36 | type: string
37 | default: ""
38 | steps:
39 | - fortify/setup
40 | - run:
41 | name: Fortify translate << parameters.build_id >>
42 | command: sourceanalyzer -b << parameters.build_id >> -verbose << parameters.translate_opts >> << parameters.path >>
43 | - run:
44 | name: Fortify scan << parameters.build_id >>
45 | command: sourceanalyzer -b << parameters.build_id >> -verbose -scan -f << parameters.build_id >>.fpr
46 | - run:
47 | name: "Fortify: upload"
48 | command: |
49 | fortifyclient \
50 | -url "$FORTIFY_SSC" \
51 | -authtoken "$SSC_API_TOKEN" \
52 | uploadFPR \
53 | -file << parameters.build_id >>.fpr \
54 | -project signavio-react-stick \
55 | -version production
56 |
57 | references:
58 | defaults: &defaults
59 | working_directory: ~/repo
60 | docker:
61 | # use SAP Approved Build Container for gradle / jdk
62 | - image: node:20-slim
63 |
64 | restore_cache: &restore_cache
65 | restore_cache:
66 | keys:
67 | - deps-v5-{{ .Branch }}-{{ checksum "package-lock.json" }}
68 | - deps-v5-{{ .Branch }}
69 | - deps-v5
70 |
71 | jobs:
72 | install:
73 | <<: *defaults
74 |
75 | steps:
76 | - checkout
77 |
78 | - *restore_cache
79 |
80 | - run: npm ci --force
81 |
82 | - save_cache:
83 | key: deps-v5-{{ .Branch }}-{{ checksum "package-lock.json" }}
84 | paths:
85 | - node_modules
86 | - ~/.cache/Cypress
87 |
88 | lint:
89 | <<: *defaults
90 |
91 | steps:
92 | - checkout
93 |
94 | - *restore_cache
95 |
96 | - run:
97 | name: Lint
98 | command: npm run lint
99 |
100 | test:
101 | <<: *defaults
102 | steps:
103 | - checkout
104 | - install-codecov-requirements
105 | - install-cypress-requirements
106 |
107 | - *restore_cache
108 |
109 | - run:
110 | name: Test
111 | command: npm run test
112 |
113 | test-ssr:
114 | <<: *defaults
115 | steps:
116 | - checkout
117 | - install-codecov-requirements
118 |
119 | - *restore_cache
120 |
121 | - run:
122 | name: Test SSR
123 | command: npm run test:ssr -- --coverage
124 |
125 | release:
126 | <<: *defaults
127 | steps:
128 | - checkout
129 | - install-codecov-requirements
130 | - *restore_cache
131 | - run:
132 | name: Fix host authenticity for github.com
133 | command: mkdir -p ~/.ssh/ && ssh-keyscan github.com >> ~/.ssh/known_hosts
134 | - run:
135 | name: Build
136 | command: npm run build
137 | - run:
138 | name: Release
139 | command: npm run semantic-release
140 |
141 | fortify_scan:
142 | executor: fortify
143 | steps:
144 | - checkout
145 | - fortify_scan:
146 | build_id: signavio-react-stick
147 | path: src
148 |
149 | lint_commit_message:
150 | <<: *defaults
151 | steps:
152 | - checkout
153 | - install-codecov-requirements
154 | - *restore_cache
155 | - run:
156 | name: Define environment variable with lastest commit's message
157 | command: |
158 | echo 'export COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s")' >> $BASH_ENV
159 | source $BASH_ENV
160 | - run:
161 | name: Lint commit message
162 | command: echo "$COMMIT_MESSAGE" | npx commitlint
163 |
164 | workflows:
165 | version: 2
166 | qa-publish-release:
167 | jobs:
168 | - install
169 | - lint:
170 | requires:
171 | - install
172 | - test:
173 | requires:
174 | - install
175 | filters:
176 | branches:
177 | ignore: master
178 | post-steps:
179 | - codecov/upload:
180 | file: tests/coverage/clover.xml
181 | flags: e2e
182 | - test-ssr:
183 | requires:
184 | - install
185 | filters:
186 | branches:
187 | ignore: master
188 | post-steps:
189 | - codecov/upload:
190 | file: coverage/clover.xml
191 | flags: ssr
192 | - lint_commit_message:
193 | requires:
194 | - install
195 | - release:
196 | context: NPM_AUTOMATION
197 | requires:
198 | - install
199 | - lint_commit_message
200 | filters:
201 | branches:
202 | only: master
203 |
204 | blackduck-nightly-scan:
205 | triggers:
206 | - schedule:
207 | cron: "0 0 * * *" # UTC
208 | filters:
209 | branches:
210 | only: master
211 | jobs:
212 | - install:
213 | context: ECR
214 | - blackduck/blackduck-scan:
215 | context:
216 | - ECR
217 | - BlackDuck
218 | blackduck-project-group: SAP_Signavio_Process_Governance
219 | blackduck-project-name: react-stick
220 | blackduck-project-path: /home/circleci/project
221 | npm-types-excluded: DEV
222 | requires:
223 | - install
224 |
225 | # Run fortify scan twice a month schedueled
226 | schedueled_fortify_scan:
227 | triggers:
228 | - schedule:
229 | # every 1st and 15th of the month
230 | cron: "0 0 1,15 * *"
231 | filters:
232 | branches:
233 | only:
234 | - master
235 | jobs:
236 | - fortify_scan:
237 | context: fortify
238 |
239 | # Run fortify scan on demand
240 | on_demand_fortify_scan:
241 | jobs:
242 | - approve-fortify-scan:
243 | type: approval
244 | filters:
245 | branches:
246 | only:
247 | - master
248 | - fortify_scan:
249 | requires:
250 | - approve-fortify-scan
251 | context:
252 | - fortify
253 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['react-app', 'prettier'],
3 | plugins: ['prettier'],
4 | rules: {
5 | 'prettier/prettier': [
6 | 'error',
7 | {
8 | semi: false,
9 | singleQuote: true,
10 | trailingComma: 'es5',
11 | },
12 | ],
13 | },
14 | }
15 |
--------------------------------------------------------------------------------
/.github/codeql/codeql-config.yml:
--------------------------------------------------------------------------------
1 | paths-ignore:
2 | - '**/*.min.js'
3 | - build/
4 | - dist/
5 | - packages/**/dist
6 | - '**/*.readonly.js'
7 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ "master", "codeQlAct"]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ "master" ,"codeQlAct"]
20 | schedule:
21 | - cron: '45 7 * * 0'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
37 | # Use only 'java' to analyze code written in Java, Kotlin or both
38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
40 |
41 | steps:
42 | - name: Checkout repository
43 | uses: actions/checkout@v4
44 |
45 | # Initializes the CodeQL tools for scanning.
46 | - name: Initialize CodeQL
47 | uses: github/codeql-action/init@v3
48 | with:
49 | queries: security-extended
50 | config-file: ./.github/codeql/codeql-config.yml
51 | languages: ${{ matrix.language }}
52 |
53 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
54 | # If this step fails, then you should remove it and run the build manually (see below)
55 | - name: Autobuild
56 | uses: github/codeql-action/autobuild@v3
57 |
58 | # ℹ️ Command-line programs to run using the OS shell.
59 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
60 |
61 | # If the Autobuild fails above, remove it and uncomment the following three lines.
62 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
63 |
64 | # - run: |
65 | # echo "Run, Build Application using script"
66 | # ./location_of_script_within_repo/buildscript.sh
67 |
68 | - name: Perform CodeQL Analysis
69 | uses: github/codeql-action/analyze@v3
70 | with:
71 | category: "/language:${{matrix.language}}"
72 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /demo/dist
2 | /es
3 | /lib
4 | /node_modules
5 | npm-debug.log*
6 | /build
7 | .DS_Store
8 | /tests/.nyc_output
9 | cypress
10 | pnpm-lock.yaml
11 | yarn.lock
12 | /.vscode
13 | coverage
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no -- commitlint --edit "${1}"
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
4 |
5 | // List of extensions which should be recommended for users of this workspace.
6 | "recommendations": [
7 | "dbaeumer.vscode-eslint",
8 | "drknoxy.eslint-disable-snippets",
9 | "esbenp.prettier-vscode",
10 | ],
11 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace.
12 | "unwantedRecommendations": []
13 | }
14 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "javascript.validate.enable": false,
3 | "editor.tabSize": 2,
4 | "search.exclude": {
5 | ".git": true,
6 | "**/node_modules": true,
7 | "**/lib": true,
8 | "**/lib-es6": true,
9 | "target": true
10 | },
11 | "editor.formatOnSave": true,
12 | "[typescript]": {
13 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
14 | },
15 | "[javascript]": {
16 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
17 | },
18 | "[typescriptreact]": {
19 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
20 | },
21 | "[css]": {
22 | "editor.defaultFormatter": "esbenp.prettier-vscode"
23 | },
24 | "[jsonc]": {
25 | "editor.defaultFormatter": "esbenp.prettier-vscode"
26 | },
27 | "[html]": {
28 | "editor.defaultFormatter": "esbenp.prettier-vscode"
29 | },
30 | "[json]": {
31 | "editor.defaultFormatter": "esbenp.prettier-vscode"
32 | },
33 | "[less]": {
34 | "editor.defaultFormatter": "esbenp.prettier-vscode"
35 | },
36 | "[yaml]": {
37 | "editor.defaultFormatter": "esbenp.prettier-vscode"
38 | },
39 | "editor.codeActionsOnSave": {
40 | "source.fixAll.eslint": true
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Prerequisites
2 |
3 | [Node.js](http://nodejs.org/) >= v6 must be installed.
4 |
5 | ## Installation
6 |
7 | - Running `npm install` in the component's root directory will install everything you need for development.
8 |
9 | ## Demo Development Server
10 |
11 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading.
12 |
13 | ## Running Tests
14 |
15 | - `npm test` will run the tests in command line.
16 |
17 | - `npm run test:open` will open the Cypress UI to run the cases interactively
18 |
19 | ## Building
20 |
21 | - `npm run build` will build the component for publishing to npm and also bundle the demo app.
22 |
23 | - `npm run clean` will delete built resources.
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Signavio GmbH
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-stick
2 |
3 | [![CircleCI][build-badge]][build]
4 | [![codecov][codecov-badge]][codecov]
5 | [![npm package][npm-badge]][npm]
6 | [](https://github.com/semantic-release/semantic-release)
7 |
8 | _Stick_ is a component that allows to attach an absolutely positioned node to a statically
9 | positioned anchor element. Per default, the node will be rendered in a portal as a direct
10 | child of the `body` element.
11 |
12 | ```bash
13 | npm install --save react-stick
14 | ```
15 |
16 | ```javascript
17 | import Stick from 'react-stick'
18 |
19 | The stick node
} position="bottom center" align="top center">
20 | The anchor node
21 |
22 | ```
23 |
24 | ## Props
25 |
26 | | prop name | type | description |
27 | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
28 | | `children` | node | The content of the anchor element |
29 | | `node` | node | The node to stick to the anchor element |
30 | | `position` | one of: `"bottom left"`, `"bottom center"`, `"bottom right"`, `"middle left"`, `"middle center"`, `"middle right"`, `"top left"`, `"top center"`, `"top right"` (default value: `"bottom left"`) | The reference point on the anchor element at which to position the stick node |
31 | | `align` | one of: `"bottom left"`, `"bottom center"`, `"bottom right"`, `"middle left"`, `"middle center"`, `"middle right"`, `"top left"`, `"top center"`, `"top right"` (default value depends on the `position`) | The alignment of the stick node. You can also think of this as the reference point on the stick node that is placed on the `position` reference point of the anchor node. For example `position="top left" align="bottom right"` means "put the bottom right point of the stick not onto the top left point of the anchor node". |
32 | | `sameWidth` | boolean | If set to `true`, the container of the stick node will have the same width as the anchor node. This enforces a maximum width on the content of the stick node. |
33 | | `autoFlipVertically` | boolean | If a node has been attached to the bottom but there isn't enough space on the screen it will automatically be positioned to the top. |
34 | | `autoFlipHorizontally` | boolean | If a node has been attached to the left but there isn't enough space on the screen it will automatically be positioned to the right. |
35 | | `onClickOutside` | function: (event: Event) => void | A handler that is called on every click on any element outside of the anchor element and the stick node. |
36 | | `inline` | boolean | If set to `true`, the stick node will not be rendered detached but inside the same container as the anchor node. |
37 | | `updateOnAnimationFrame` | boolean | If set to `true`, will update the stick node position on every animation frame. Per default, it will only update on idle callback. |
38 | | `component` | string | Pass any string-type React component that shall be rendered as the wrapper element around the children. Per default, `"div"` is used. |
39 |
40 | [build-badge]: https://dl.circleci.com/status-badge/img/gh/signavio/react-stick/tree/master.svg?style=svg
41 | [build]: https://circleci.com/gh/signavio/react-stick/tree/master
42 | [npm-badge]: https://img.shields.io/npm/v/react-stick.svg
43 | [npm]: https://www.npmjs.org/package/react-stick
44 | [codecov-badge]: https://img.shields.io/codecov/c/github/signavio/react-stick.svg
45 | [codecov]: https://codecov.io/gh/signavio/react-stick
46 |
--------------------------------------------------------------------------------
/demo/src/AutoAlignment.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Stick from '../../es'
4 |
5 | function AutoAlignment() {
6 | return (
7 |
8 |
Auto-Alignment
9 |
10 |
Vertical flipping
11 |
12 |
18 | This is the content of the node
19 |
20 | }
21 | >
22 |
29 | The node of this stick should move to the top if it can't fit to the
30 | bottom
31 |
32 |
33 |
34 |
35 | Horizontal flipping
36 |
37 |
40 |
41 |
49 | This is the content of the node
50 |
51 | }
52 | >
53 |
60 | The node of this stick should move to the right if it can't fit to
61 | the left
62 |
63 |
64 |
65 |
66 |
67 | )
68 | }
69 |
70 | export default AutoAlignment
71 |
--------------------------------------------------------------------------------
/demo/src/Demo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import AutoAlignment from './AutoAlignment'
4 | import PositionAlignOverview from './PositionAlignOverview'
5 | import Regressions from './regressions'
6 |
7 | export default function Demo() {
8 | return (
9 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/demo/src/PositionAlignOverview.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useRef, useState } from 'react'
2 |
3 | import Stick from '../../es'
4 | import { useWatcher } from './hooks'
5 | import { WatcherOptions } from './hooks/useWatcher'
6 |
7 | function formPairs(
8 | listA: readonly A[],
9 | listB: readonly B[]
10 | ): `${A} ${B}`[][] {
11 | return listA.map((a) => listB.map((b) => `${a} ${b}`)) as `${A} ${B}`[][]
12 | }
13 |
14 | const verticals = ['top', 'middle', 'bottom'] as const
15 | const horizontals = ['left', 'center', 'right'] as const
16 |
17 | const positionGroups = formPairs(verticals, horizontals)
18 | const alignmentGroups = formPairs(verticals, horizontals)
19 |
20 | const Anchor = () => (
21 |
28 | )
29 |
30 | const Node = () => (
31 |
38 | )
39 |
40 | function FramesPerSecond({ updateOnAnimationFrame }: WatcherOptions) {
41 | const [fps, setFps] = useState(0)
42 | const lastUpdated = useRef(Date.now())
43 | const framesSinceLastUpdate = useRef(0)
44 |
45 | const measure = useCallback(() => {
46 | framesSinceLastUpdate.current += 1
47 |
48 | const duration = Date.now() - lastUpdated.current
49 | if (duration >= 1000) {
50 | setFps(framesSinceLastUpdate.current)
51 |
52 | framesSinceLastUpdate.current = 0
53 | lastUpdated.current = Date.now()
54 | }
55 | }, [])
56 |
57 | useWatcher(measure, { updateOnAnimationFrame })
58 |
59 | return FPS: {fps}
60 | }
61 |
62 | function PositionAlignOverview() {
63 | const [updateOnAnimationFrame, setUpdateOnAnimationFrame] = useState(false)
64 | const [inline, setInline] = useState(false)
65 | const [showNode, setShowNode] = useState(true)
66 |
67 | return (
68 |
69 |
70 | The table shows all combinations of possible values for the{' '}
71 | position
and align
props. The{' '}
72 | node
elements are colors in red while the anchor elements (
73 | children
) are colored in blue.
74 |
75 |
76 | setInline(!inline)}
80 | />
81 | inline
82 |
83 |
84 | setUpdateOnAnimationFrame(!updateOnAnimationFrame)}
88 | />
89 | updateOnAnimationFrame
90 |
91 |
92 | setShowNode(!showNode)}
96 | />
97 | showNode
98 |
99 |
100 |
104 |
105 |
106 |
112 |
118 | {positionGroups.map((positions, i: number) => (
119 |
120 | {positions.map((position) => (
121 |
130 | position="{position}"
131 |
132 |
133 |
}
138 | >
139 |
140 |
141 |
142 |
143 |
149 |
155 | {alignmentGroups.map((alignments, j) => (
156 |
157 | {alignments.map((alignment) => (
158 |
167 | align="{alignment}"
168 |
169 |
170 |
}
178 | >
179 |
180 |
181 |
182 |
183 | ))}
184 |
185 | ))}
186 |
187 |
188 |
189 | ))}
190 |
191 | ))}
192 |
193 |
194 |
195 | )
196 | }
197 |
198 | export default PositionAlignOverview
199 |
--------------------------------------------------------------------------------
/demo/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useWatcher } from './useWatcher'
2 |
--------------------------------------------------------------------------------
/demo/src/hooks/useWatcher.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | type WatcherFuncT = () => void
4 |
5 | export type WatcherOptions = {
6 | updateOnAnimationFrame: boolean
7 | }
8 |
9 | function useWatcher(
10 | watcher: WatcherFuncT,
11 | { updateOnAnimationFrame }: WatcherOptions
12 | ): void {
13 | useEffect(() => {
14 | let animationFrameId: number
15 | let idleCallbackId: number
16 |
17 | // do not track in node
18 | if (typeof window.requestAnimationFrame !== 'undefined') {
19 | const callback = () => {
20 | watcher()
21 |
22 | if (updateOnAnimationFrame) {
23 | animationFrameId = requestAnimationFrame(callback)
24 | } else {
25 | idleCallbackId = requestIdleCallback(callback)
26 | }
27 | }
28 |
29 | callback()
30 | }
31 |
32 | return () => {
33 | if (animationFrameId) {
34 | cancelAnimationFrame(animationFrameId)
35 | }
36 |
37 | if (idleCallbackId) {
38 | cancelIdleCallback(idleCallbackId)
39 | }
40 | }
41 | }, [updateOnAnimationFrame, watcher])
42 | }
43 |
44 | export default useWatcher
45 |
--------------------------------------------------------------------------------
/demo/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | react-stick
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/demo/src/index.tsx:
--------------------------------------------------------------------------------
1 | import 'core-js/stable'
2 | import 'regenerator-runtime/runtime'
3 |
4 | import React from 'react'
5 | import { render } from 'react-dom'
6 | import { StylesAsDataAttributes } from 'substyle-glamor'
7 |
8 | import Demo from './Demo'
9 |
10 | render(
11 |
12 |
13 | ,
14 | document.querySelector('#demo')
15 | )
16 |
--------------------------------------------------------------------------------
/demo/src/regressions/ButtonOverlay.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Stick from '../../../es'
4 | import Regression from './Regression'
5 |
6 | export default function ButtonOverlay() {
7 | return (
8 |
14 |
15 | You should be able to click me
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/demo/src/regressions/FitOnPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Stick from '../../../es'
4 | import Regression from './Regression'
5 |
6 | const Anchor = ({
7 | width,
8 | children,
9 | }: {
10 | width?: number
11 | children?: React.ReactNode
12 | }) => (
13 |
21 | {children}
22 |
23 | )
24 |
25 | const node = (
26 |
33 | {Array(10).fill('Lorem ipsum dolor sit amet.').join(' ')}
34 |
35 | )
36 |
37 | export default function FitOnPage() {
38 | return (
39 |
46 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/demo/src/regressions/Regression.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | type PropsT = {
4 | firefox?: boolean
5 | chrome?: boolean
6 | ie?: boolean
7 | edge?: boolean
8 | safari?: boolean
9 | opera?: boolean
10 | allBrowsers?: boolean
11 | fixed?: boolean
12 | open?: boolean
13 | title: string
14 | description: string
15 | version?: string
16 | children: React.ReactNode
17 | }
18 |
19 | function Regression({
20 | firefox,
21 | chrome,
22 | ie,
23 | edge,
24 | safari,
25 | opera,
26 | allBrowsers,
27 | fixed,
28 | open,
29 | version,
30 | title,
31 | description,
32 | children,
33 | }: PropsT) {
34 | return (
35 |
36 |
{title}
37 |
{description}
38 |
39 |
40 | Browsers:
41 | {firefox && Firefox }
42 | {chrome && Chrome }
43 | {ie && IE }
44 | {edge && Edge }
45 | {safari && Safari }
46 | {opera && Opera }
47 | {allBrowsers && ALL }
48 |
49 | {version && (
50 |
51 | Version: {version}
52 |
53 | )}
54 |
55 |
56 | Status:
57 | {fixed && 'Fixed'}
58 | {open && 'Open'}
59 |
60 |
61 |
62 |
{children}
63 |
64 | )
65 | }
66 |
67 | export default Regression
68 |
--------------------------------------------------------------------------------
/demo/src/regressions/Regressions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import ButtonOverlay from './ButtonOverlay'
4 | import FitOnPage from './FitOnPage'
5 | import SameWidth from './SameWidth'
6 | import ScrollPosition from './ScrollPosition'
7 | import StickInSvg from './StickInSvg'
8 | import StickNodeWidth from './StickNodeWidth'
9 | import StickOnHover from './StickOnHover'
10 | import StyledWithDataAttributes from './StyledWithDataAttributes'
11 | import TransportToFixedContainer from './TransportToFixedContainer'
12 |
13 | export default function Regressions() {
14 | return (
15 |
16 |
Regressions
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/demo/src/regressions/SameWidth.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react'
2 |
3 | import Stick from '../../../es'
4 | import Regression from './Regression'
5 |
6 | const Anchor = ({ width, children }: PropsWithChildren<{ width?: number }>) => (
7 |
15 | {children}
16 |
17 | )
18 |
19 | const Node = ({ children }: PropsWithChildren<{}>) => (
20 |
27 | {children}
28 |
29 | )
30 |
31 | export default function SameWidth() {
32 | return (
33 |
39 |
48 |
}>
49 |
The stick node below should have the same width
50 |
51 |
}>
52 |
53 | The inline stick node below should have the same width
54 |
55 |
56 |
This text should break to respect the anchor's width
61 | }
62 | >
63 |
64 |
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/demo/src/regressions/ScrollPosition.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react'
2 |
3 | import Stick from '../../../es'
4 | import Regression from './Regression'
5 |
6 | function ScrollPosition() {
7 | return (
8 |
15 |
16 |
26 |
This node should always stick with its anchor}
28 | >
29 |
30 | Scroll to the right. The node should move with this element.
31 |
32 |
33 |
34 |
35 |
36 | )
37 | }
38 |
39 | const Anchor = ({ children }: PropsWithChildren<{}>) => (
40 |
47 | {children}
48 |
49 | )
50 |
51 | const Node = ({ children }: PropsWithChildren<{}>) => (
52 |
59 | {children}
60 |
61 | )
62 |
63 | export default ScrollPosition
64 |
--------------------------------------------------------------------------------
/demo/src/regressions/StickInSvg.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Stick from '../../../es'
4 | import Regression from './Regression'
5 |
6 | export default function SameWidth() {
7 | return (
8 |
15 |
16 |
27 | }
28 | >
29 |
36 |
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/demo/src/regressions/StickNodeWidth.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react'
2 |
3 | import Stick from '../../../es'
4 | import Regression from './Regression'
5 |
6 | const Anchor = ({ width }: PropsWithChildren<{ width?: number }>) => (
7 |
14 | )
15 |
16 | const Node = ({ children }: PropsWithChildren<{}>) => (
17 |
23 | {children}
24 |
25 | )
26 |
27 | const Examples = ({ inline }: { inline?: boolean }) => (
28 |
37 |
This text should stay on one line}
41 | >
42 |
43 |
44 |
45 |
This text should stay on one line}
49 | >
50 |
51 |
52 |
53 |
54 |
59 | This text must line-break as it would reach off-screen otherwise.
60 | After we've increased page width, this text needed to be extended a
61 | bit.
62 |
63 | }
64 | >
65 |
66 |
67 |
68 |
69 | )
70 |
71 | export default function StickNodeWidth() {
72 | return (
73 |
80 |
81 | with `inline` prop:
82 |
83 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/demo/src/regressions/StickOnHover.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren, useState } from 'react'
2 |
3 | import Stick from '../../../es'
4 | import Regression from './Regression'
5 |
6 | function StickOnHover() {
7 | return (
8 |
15 |
16 | {Array(100)
17 | .fill(null)
18 | .map((_, i) => (
19 |
22 | ))}
23 |
24 |
25 | )
26 | }
27 |
28 | function Popover() {
29 | const [hover, setHover] = useState(false)
30 |
31 | return (
32 | }
35 | onMouseEnter={() => setHover(true)}
36 | onMouseLeave={() => setHover(false)}
37 | >
38 |
39 |
40 | )
41 | }
42 |
43 | const Anchor = () => (
44 |
51 | )
52 |
53 | const Node = ({ children }: PropsWithChildren<{}>) => (
54 |
61 | {children}
62 |
63 | )
64 |
65 | export default StickOnHover
66 |
--------------------------------------------------------------------------------
/demo/src/regressions/StyledWithDataAttributes.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { StylesAsDataAttributes } from 'substyle-glamor'
3 |
4 | import Stick from '../../../es'
5 | import Regression from './Regression'
6 |
7 | export default function StyledWithDataAttributes() {
8 | return (
9 |
16 |
17 |
18 |
28 | Node
29 |
30 | }
31 | >
32 |
33 | Anchor
34 |
35 |
36 |
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/demo/src/regressions/TransportToFixedContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import Stick from '../../../es'
4 | import Regression from './Regression'
5 |
6 | export default function TransportToFixedContainer() {
7 | const [isOpen, setIsOpen] = useState(false)
8 | const [container, setContainer] = useState()
9 |
10 | return (
11 |
18 | setIsOpen(!isOpen)}>
19 | {isOpen ? 'close fixed container' : 'open fixed container'}
20 | {' '}
21 | (The fixed container will open at the top left. The sticked node should be
22 | attached correctly regardless of the page scroll position)
23 | {isOpen && (
24 | setContainer(node || undefined)}
35 | >
36 |
37 | There should be a line of text below me
38 |
39 |
40 | )}
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/demo/src/regressions/index.ts:
--------------------------------------------------------------------------------
1 | import Regressions from './Regressions'
2 |
3 | export default Regressions
4 |
--------------------------------------------------------------------------------
/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "skipLibCheck": true
6 | },
7 | "include": ["**/*.ts", "**/*.tsx"]
8 | }
9 |
--------------------------------------------------------------------------------
/demo/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | root: 'demo/src',
7 | plugins: [react({ jsxRuntime: 'classic' })],
8 | build: { outDir: '../../build' },
9 | })
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-stick",
3 | "version": "0.0.0-development",
4 | "description": "React component to stick a portaled node to an anchor node",
5 | "main": "lib/index.js",
6 | "module": "es/index.js",
7 | "files": [
8 | "lib/*",
9 | "es/*"
10 | ],
11 | "repository": "git@github.com:signavio/react-stick.git",
12 | "author": "Jan-Felix Schwarz ",
13 | "license": "MIT",
14 | "scripts": {
15 | "commit": "commit",
16 | "prebuild": "npm run clean",
17 | "build": "tsc -p ./src/tsconfig.json --outdir ./es && tsc -p ./src/tsconfig.json --module commonJS --outdir ./lib ",
18 | "prepublishOnly": "npm run build",
19 | "build-demo": "vite --config demo/vite.config.ts build",
20 | "clean": "rimraf tests/coverage tests/.nyc_output es lib build",
21 | "lint": "eslint --max-warnings=0 --ext .ts --ext .tsx src tests demo/src",
22 | "lint:fix": "npm run lint -- --fix",
23 | "prenow-build": "npm run build",
24 | "now-build": "npm run build-demo",
25 | "start": "vite --config demo/vite.config.ts",
26 | "semantic-release": "semantic-release",
27 | "semantic-release-preview": "semantic-release-github-pr",
28 | "test": "cypress run --component --config-file ./tests/cypress.config.ts",
29 | "test:ssr": "vitest --config=tests/vitest.config.ts",
30 | "test:open": "cypress open --component --config-file ./tests/cypress.config.ts --browser electron",
31 | "prepare": "husky install"
32 | },
33 | "dependencies": {
34 | "@types/invariant": "^2.2.35",
35 | "invariant": "^2.2.4",
36 | "requestidlecallback": "^0.3.0",
37 | "substyle": "^9.4.1"
38 | },
39 | "devDependencies": {
40 | "@commitlint/cli": "19.3.0",
41 | "@commitlint/config-conventional": "19.2.2",
42 | "@commitlint/prompt-cli": "19.3.1",
43 | "@cypress/code-coverage": "3.12.39",
44 | "@cypress/react": "8.0.1",
45 | "@cypress/vite-dev-server": "5.0.7",
46 | "@testing-library/cypress": "8.0.7",
47 | "@types/react": "16.14.60",
48 | "@types/react-dom": "16.9.24",
49 | "@vitejs/plugin-react": "2.2.0",
50 | "@vitest/coverage-v8": "^1.6.0",
51 | "condition-circle": "2.0.2",
52 | "core-js": "3.37.1",
53 | "cypress": "11.2.0",
54 | "eslint": "8.57.0",
55 | "eslint-config-prettier": "9.1.0",
56 | "eslint-config-react-app": "7.0.1",
57 | "eslint-plugin-prettier": "4.2.1",
58 | "glamor": "2.20.40",
59 | "husky": "9.0.11",
60 | "lint-staged": "15.2.2",
61 | "prettier": "2.8.8",
62 | "react": "16.14.0",
63 | "react-dom": "16.14.0",
64 | "rimraf": "5.0.7",
65 | "semantic-release": "23.0.8",
66 | "substyle-glamor": "4.1.2",
67 | "typescript": "5.4.5",
68 | "vite": "3.2.10",
69 | "vite-plugin-istanbul": "5.0.0",
70 | "vitest": "^1.6.0"
71 | },
72 | "peerDependencies": {
73 | "react": ">=16.8.0",
74 | "react-dom": ">=16.8.0"
75 | },
76 | "browserslist": [
77 | "chrome >= 50",
78 | "firefox >= 52",
79 | "safari >= 10",
80 | "ie >= 11"
81 | ],
82 | "release": {
83 | "branch": "master",
84 | "branches": [
85 | "master"
86 | ],
87 | "verifyConditions": "condition-circle"
88 | },
89 | "engines": {
90 | "node": ">=16.11",
91 | "npm": ">=8.x"
92 | },
93 | "lint-staged": {
94 | "*.{js,ts,tsx}": [
95 | "eslint --max-warnings 0 --fix"
96 | ],
97 | "*.{json,md,yaml,yml}": "prettier --write"
98 | },
99 | "commitlint": {
100 | "extends": [
101 | "@commitlint/config-conventional"
102 | ]
103 | },
104 | "overrides": {
105 | "ramda": "0.29.1",
106 | "npm": "10.5.0",
107 | "semver": "7.6.2",
108 | "find-versions": {
109 | "semver-regex": "3.1.4"
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["config:js-lib"],
3 | "encrypted": {
4 | "npmToken": "K6b0qzdDGHnF7OhXFRDHBoFNlWWSBdNuFdjhpPQ2TjEGpBNhrQD3MHMg7PV3PnztLRS7Ho2UzcTqO31eVZiZegBOQNMhpFv4+Y0SUD/ZD2noeD4Pqy1Mztf2wevFCtZpkYrgNIgBp8Dbix8EyQTv7F3nAF+xGhlIgX2rhx/K0rVfx/70dBmZoFPoQLHoEHYtQB3k6ooKZC4q3lHlCJtGbZzJ8vAcRkEPHF4uTA0VB771yj1P/aESrtSBhHOUs6hJM3yJnLdfN75yj959GG0KQMNw6t2iWFOmpIXok7olPrDe2mDHOoLsTgfquVIhpZHev3sdymIIGA9TSGb5wb1zSA=="
5 | },
6 | "npmrc": "@signavio:registry=https://npm.pkg.github.com/\n//npm.pkg.github.com/:_authToken=${NPM_TOKEN}",
7 | "docker": {
8 | "enabled": false
9 | },
10 | "packageRules": [
11 | {
12 | "depTypeList": ["devDependencies"],
13 | "automerge": true
14 | }
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/src/Stick.tsx:
--------------------------------------------------------------------------------
1 | import 'requestidlecallback'
2 |
3 | import invariant from 'invariant'
4 | import React, {
5 | LegacyRef,
6 | MutableRefObject,
7 | useCallback,
8 | useContext,
9 | useEffect,
10 | useRef,
11 | useState,
12 | } from 'react'
13 | import useStyles from 'substyle'
14 |
15 | import { StickContext } from './StickContext'
16 | import StickInline from './StickInline'
17 | import StickNode from './StickNode'
18 | import StickPortal from './StickPortal'
19 | import DEFAULT_POSITION from './defaultPosition'
20 | import { type AlignT, type PositionT, type StickPropsT } from './types'
21 | import { useAutoFlip, useWatcher } from './hooks'
22 | import { getDefaultAlign, getModifiers, scrollX, uniqueId } from './utils'
23 |
24 | const defaultStyles = {
25 | node: {
26 | position: 'absolute',
27 | zIndex: 99,
28 | textAlign: 'left',
29 | },
30 | }
31 |
32 | function Stick({
33 | inline = false,
34 | node,
35 | sameWidth = false,
36 | children,
37 | updateOnAnimationFrame = false,
38 | position,
39 | align,
40 | component,
41 | transportTo,
42 | autoFlipHorizontally = false,
43 | autoFlipVertically = false,
44 | onClickOutside,
45 | style,
46 | className,
47 | classNames,
48 | ...rest
49 | }: StickPropsT) {
50 | const [width, setWidth] = useState(0)
51 | const [containerNestingKeyExtension] = useState(() => uniqueId())
52 | const nestingKey = [useContext(StickContext), containerNestingKeyExtension]
53 | .filter((key) => !!key)
54 | .join('_')
55 |
56 | const anchorRef = useRef()
57 | const nodeRef = useRef(null)
58 | const containerRef = useRef()
59 |
60 | const [resolvedPosition, resolvedAlign, checkAlignment] = useAutoFlip(
61 | autoFlipHorizontally,
62 | autoFlipVertically,
63 | position || DEFAULT_POSITION,
64 | align || getDefaultAlign(position || DEFAULT_POSITION)
65 | )
66 |
67 | const styles = useStyles(
68 | defaultStyles,
69 | { style, className, classNames },
70 | getModifiers({
71 | position: resolvedPosition,
72 | align: resolvedAlign,
73 | sameWidth,
74 | })
75 | )
76 |
77 | useEffect(() => {
78 | const handleScroll = () => {
79 | if (!nodeRef.current || !anchorRef.current) {
80 | return
81 | }
82 |
83 | checkAlignment(nodeRef.current, anchorRef.current)
84 | }
85 |
86 | handleScroll() // Check alignment on first render
87 | window.addEventListener('scroll', handleScroll)
88 |
89 | return () => {
90 | window.removeEventListener('scroll', handleScroll)
91 | }
92 | }, [checkAlignment])
93 |
94 | useEffect(() => {
95 | const handleClickOutside = (ev: MouseEvent) => {
96 | if (!onClickOutside) {
97 | return
98 | }
99 |
100 | const { target } = ev
101 | if (
102 | target instanceof window.Element &&
103 | isOutside(anchorRef, containerRef, target)
104 | ) {
105 | onClickOutside(ev)
106 | }
107 | }
108 |
109 | document.addEventListener('click', handleClickOutside, true)
110 |
111 | return () => {
112 | document.removeEventListener('click', handleClickOutside, true)
113 | }
114 | }, [onClickOutside])
115 |
116 | const measure = useCallback(() => {
117 | if (!anchorRef.current) {
118 | return
119 | }
120 |
121 | const boundingRect = anchorRef.current.getBoundingClientRect()
122 |
123 | const newWidth = sameWidth
124 | ? boundingRect.width
125 | : calculateWidth(
126 | anchorRef.current,
127 | resolvedPosition,
128 | resolvedAlign,
129 | boundingRect
130 | )
131 |
132 | if (newWidth !== width) {
133 | setWidth(newWidth)
134 | }
135 | }, [resolvedAlign, resolvedPosition, sameWidth, width])
136 |
137 | useWatcher(measure, { updateOnAnimationFrame, enabled: !!node })
138 |
139 | const handleReposition = useCallback(() => {
140 | if (nodeRef.current && anchorRef.current) {
141 | checkAlignment(nodeRef.current, anchorRef.current)
142 | }
143 | }, [checkAlignment])
144 |
145 | if (inline) {
146 | return (
147 |
148 |
162 | {node}
163 |
164 | )
165 | }
166 | nestingKey={nestingKey}
167 | containerRef={(node) => {
168 | anchorRef.current = node || undefined
169 | containerRef.current = node || undefined
170 | }}
171 | component={component}
172 | >
173 | {children}
174 |
175 |
176 | )
177 | }
178 |
179 | return (
180 |
181 | {
187 | invariant(
188 | !node || node instanceof Element,
189 | 'Only HTML elements can be stick anchors.'
190 | )
191 |
192 | anchorRef.current = node || undefined
193 | }}
194 | position={resolvedPosition}
195 | node={
196 | node && (
197 |
204 | {node}
205 |
206 | )
207 | }
208 | style={styles}
209 | nestingKey={nestingKey}
210 | containerRef={containerRef as LegacyRef}
211 | onReposition={handleReposition}
212 | >
213 | {children}
214 |
215 |
216 | )
217 | }
218 |
219 | function isOutside(
220 | anchorRef: MutableRefObject,
221 | containerRef: MutableRefObject,
222 | target: Element
223 | ) {
224 | if (anchorRef.current && anchorRef.current.contains(target)) {
225 | return false
226 | }
227 |
228 | const nestingKey =
229 | containerRef.current &&
230 | containerRef.current.getAttribute('data-sticknestingkey')
231 |
232 | if (nestingKey) {
233 | // Find all stick nodes nested inside our own stick node and check if the click
234 | // happened on any of these (our own stick node will also be part of the query result)
235 | const nestedStickNodes = document.querySelectorAll(
236 | `[data-stickNestingKey^='${nestingKey}']`
237 | )
238 |
239 | return (
240 | !nestedStickNodes ||
241 | !Array.from(nestedStickNodes).some((stickNode) =>
242 | stickNode.contains(target)
243 | )
244 | )
245 | }
246 |
247 | return true
248 | }
249 |
250 | function calculateWidth(
251 | anchorRef: HTMLElement | undefined,
252 | position: PositionT,
253 | align: AlignT,
254 | { left, width, right }: DOMRect
255 | ): number {
256 | if (!anchorRef) {
257 | return 0
258 | }
259 |
260 | invariant(document.documentElement, 'Could not find document root node.')
261 |
262 | const scrollWidth = document.documentElement.scrollWidth
263 |
264 | const [, horizontalPosition] = position.split(' ')
265 |
266 | invariant(
267 | horizontalPosition === 'left' ||
268 | horizontalPosition === 'center' ||
269 | horizontalPosition === 'right',
270 | `Expected horizontal position to be "left", "center", or "right" but got "${horizontalPosition}".`
271 | )
272 |
273 | const positionAdjustments = {
274 | left,
275 | center: left + width / 2,
276 | right,
277 | }
278 |
279 | const absLeft = scrollX(anchorRef) + positionAdjustments[horizontalPosition]
280 |
281 | if (align.indexOf('left') !== -1) {
282 | return scrollWidth - absLeft
283 | }
284 |
285 | if (align.indexOf('right') !== -1) {
286 | return absLeft
287 | }
288 |
289 | if (align.indexOf('center') !== -1) {
290 | return Math.min(absLeft, scrollWidth - absLeft) * 2
291 | }
292 |
293 | return 0
294 | }
295 |
296 | export default Stick
297 |
--------------------------------------------------------------------------------
/src/StickContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react'
2 |
3 | export const StickContext = createContext(null)
4 |
--------------------------------------------------------------------------------
/src/StickInline.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import useStyles from 'substyle'
3 |
4 | import { type StickInlinePropsT } from './types'
5 | import { getModifiers } from './utils'
6 |
7 | function StickInline({
8 | node,
9 | children,
10 | component,
11 | containerRef,
12 | nestingKey,
13 | align,
14 | position,
15 | style,
16 | ...rest
17 | }: StickInlinePropsT) {
18 | const styles = useStyles(
19 | defaultStyle,
20 | { style },
21 | getModifiers({ align, position })
22 | )
23 | const Component: any = component || 'div'
24 | return (
25 |
31 | {children}
32 | {node && {node}
}
33 |
34 | )
35 | }
36 |
37 | const defaultStyle = {
38 | position: 'relative',
39 |
40 | node: {
41 | position: 'absolute',
42 | zIndex: 99,
43 | textAlign: 'left',
44 | },
45 |
46 | '&position-top': {
47 | node: {
48 | top: 0,
49 | },
50 | },
51 | '&position-middle': {
52 | node: {
53 | top: '50%',
54 | },
55 | },
56 | '&position-bottom': {
57 | node: {
58 | top: '100%',
59 | },
60 | },
61 |
62 | '&position-left': {
63 | node: {
64 | left: 0,
65 | },
66 | },
67 | '&position-center': {
68 | node: {
69 | left: '50%',
70 | },
71 | },
72 | '&position-right': {
73 | node: {
74 | left: '100%',
75 | },
76 | },
77 | }
78 |
79 | export default StickInline
80 |
--------------------------------------------------------------------------------
/src/StickNode.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from 'react'
2 | import useStyles, { inline } from 'substyle'
3 |
4 | import { type AlignT, type PositionT } from './types'
5 | import { getModifiers } from './utils'
6 |
7 | import type { ReactNode } from 'react'
8 |
9 | type PropsT = {
10 | width: number
11 | position: PositionT
12 | align: AlignT
13 | sameWidth: boolean
14 | children: ReactNode
15 | }
16 |
17 | const StickNode = forwardRef(function (
18 | { children, width, align, position, sameWidth }: PropsT,
19 | ref
20 | ) {
21 | const styles = useStyles(
22 | defaultStyle,
23 | {},
24 | getModifiers({ align, position, sameWidth })
25 | )
26 |
27 | return (
28 |
29 |
30 | {children}
31 |
32 |
33 | )
34 | })
35 |
36 | const defaultStyle = {
37 | position: 'absolute',
38 | right: 0,
39 | bottom: 0,
40 |
41 | content: {
42 | // absolute position is needed as the stick node would otherwise
43 | // cover up the base node and, for instance, make it impossible to
44 | // click buttons
45 | position: 'absolute',
46 | display: 'inline-block',
47 |
48 | left: 'inherit',
49 | right: 'inherit',
50 | top: 'inherit',
51 | bottom: 'inherit',
52 | },
53 |
54 | '&sameWidth': {
55 | content: {
56 | display: 'block',
57 | width: '100%',
58 | },
59 | },
60 |
61 | '&align-left': {
62 | right: 'auto',
63 | left: 0,
64 | },
65 | '&align-top': {
66 | bottom: 'auto',
67 | top: 0,
68 | },
69 |
70 | '&align-middle': {
71 | content: {
72 | transform: 'translate(0, 50%)',
73 | },
74 | },
75 | '&align-center': {
76 | content: {
77 | transform: 'translate(50%, 0)',
78 | },
79 | '&align-middle': {
80 | content: {
81 | transform: 'translate(50%, 50%)',
82 | },
83 | },
84 | },
85 | }
86 |
87 | export default StickNode
88 |
--------------------------------------------------------------------------------
/src/StickPortal.tsx:
--------------------------------------------------------------------------------
1 | import 'requestidlecallback'
2 |
3 | import invariant from 'invariant'
4 | import React, {
5 | createContext,
6 | forwardRef,
7 | LegacyRef,
8 | useCallback,
9 | useContext,
10 | useEffect,
11 | useRef,
12 | useState,
13 | } from 'react'
14 | import { createPortal } from 'react-dom'
15 | import { inline } from 'substyle'
16 |
17 | import type { PositionT, StickPortalPropsT } from './types'
18 | import { useWatcher } from './hooks'
19 | import { scrollX, scrollY } from './utils'
20 |
21 | const StickPortal = forwardRef<
22 | HTMLElement | undefined | null,
23 | StickPortalPropsT
24 | >(function StickPortal(
25 | {
26 | children,
27 | component,
28 | style,
29 | transportTo,
30 | nestingKey,
31 | node,
32 | position,
33 | containerRef,
34 | updateOnAnimationFrame,
35 | onReposition,
36 | ...rest
37 | }: StickPortalPropsT,
38 | ref
39 | ) {
40 | const nodeRef = useRef()
41 | const [top, setTop] = useState(null)
42 | const [left, setLeft] = useState(null)
43 | const [visible, setVisible] = useState(!!node)
44 |
45 | const [host, hostParent] = useHost(transportTo)
46 |
47 | useEffect(() => {
48 | if (nodeRef.current) {
49 | onReposition(nodeRef.current)
50 | }
51 | }, [onReposition, top, left])
52 |
53 | useEffect(() => {
54 | setVisible(!!node)
55 | }, [node])
56 |
57 | useEffect(() => {
58 | if (hostParent == null || host == null) {
59 | return
60 | }
61 |
62 | if (visible) {
63 | hostParent.appendChild(host)
64 |
65 | return () => {
66 | hostParent.removeChild(host)
67 | }
68 | }
69 | }, [host, hostParent, visible])
70 |
71 | const measure = useCallback(() => {
72 | const node = nodeRef.current
73 |
74 | if (!node || !visible || host == null) {
75 | return
76 | }
77 |
78 | const newTop = calculateTop(node, position, host)
79 | const newLeft = calculateLeft(node, position, host)
80 |
81 | if (newTop !== top) {
82 | setTop(newTop)
83 | }
84 |
85 | if (newLeft !== left) {
86 | setLeft(newLeft)
87 | }
88 | }, [host, left, position, top, visible])
89 |
90 | useWatcher(measure, { updateOnAnimationFrame, enabled: visible })
91 |
92 | const Component: any = component || 'div'
93 |
94 | return (
95 | {
99 | if (typeof ref === 'function') {
100 | ref(node)
101 | } else if (ref) {
102 | ref.current = node
103 | }
104 |
105 | nodeRef.current = node
106 | }}
107 | >
108 | {children}
109 |
110 | {top != null && left != null && host != null && (
111 |
114 | {createPortal(
115 | }
117 | data-sticknestingkey={nestingKey}
118 | {...inline(style('node'), {
119 | position: 'absolute',
120 | top,
121 | left,
122 | })}
123 | >
124 | {node}
125 |
,
126 | host
127 | )}
128 |
129 | )}
130 |
131 | )
132 | })
133 |
134 | export const PortalContext = createContext(null)
135 |
136 | export default StickPortal
137 |
138 | function useHost(transportTo?: HTMLElement | null) {
139 | const [host] = useState(() => {
140 | if (typeof document === 'undefined') {
141 | return null
142 | }
143 |
144 | return document.createElement('div')
145 | })
146 |
147 | const portalHost = useContext(PortalContext)
148 |
149 | if (host == null) {
150 | return [null, null]
151 | }
152 |
153 | const hostParent = transportTo || portalHost || document.body
154 |
155 | invariant(hostParent, 'Could not determine a parent for the host node.')
156 |
157 | return [host, hostParent]
158 | }
159 |
160 | function calculateTop(
161 | node: HTMLElement,
162 | position: PositionT,
163 | host: HTMLElement
164 | ) {
165 | const { top, height, bottom } = node.getBoundingClientRect()
166 | const fixedHost = getFixedParent(host)
167 |
168 | let result = 0
169 | if (position.indexOf('top') !== -1) {
170 | result = top
171 | }
172 | if (position.indexOf('middle') !== -1) {
173 | result = top + height / 2
174 | }
175 | if (position.indexOf('bottom') !== -1) {
176 | result = bottom
177 | }
178 |
179 | if (fixedHost) {
180 | const { top: hostTop } = fixedHost.getBoundingClientRect()
181 |
182 | return result - hostTop
183 | }
184 |
185 | return result + scrollY()
186 | }
187 |
188 | function calculateLeft(
189 | node: HTMLElement,
190 | position: PositionT,
191 | host: HTMLElement
192 | ) {
193 | const { left, width, right } = node.getBoundingClientRect()
194 |
195 | const fixedHost = getFixedParent(host)
196 | const scrollHost = getScrollParent(node)
197 |
198 | let result = 0
199 | if (position.indexOf('left') !== -1) {
200 | result = left
201 | }
202 | if (position.indexOf('center') !== -1) {
203 | result = left + width / 2
204 | }
205 | if (position.indexOf('right') !== -1) {
206 | result = right
207 | }
208 |
209 | if (fixedHost) {
210 | const { left: hostLeft } = fixedHost.getBoundingClientRect()
211 |
212 | return result - hostLeft
213 | }
214 |
215 | if (scrollHost) {
216 | return result + scrollX(node) - scrollHost.scrollLeft
217 | }
218 |
219 | return result + scrollX(node)
220 | }
221 |
222 | function getScrollParent(element: Element): Element | null {
223 | if (!element) {
224 | return null
225 | }
226 |
227 | if (element.nodeName === 'BODY' || element.nodeName === 'HTML') {
228 | return null
229 | }
230 |
231 | const style = getComputedStyle(element)
232 |
233 | if (style.overflowX === 'auto' || style.overflowX === 'scroll') {
234 | return element
235 | }
236 |
237 | return element.parentNode instanceof Element
238 | ? getScrollParent(element.parentNode)
239 | : null
240 | }
241 |
242 | function getFixedParent(element: Element): Element | undefined | null {
243 | if (element.nodeName === 'BODY' || element.nodeName === 'HTML') {
244 | return null
245 | }
246 |
247 | if (getComputedStyle(element).position === 'fixed') {
248 | return element
249 | }
250 |
251 | return element.parentNode instanceof Element
252 | ? getFixedParent(element.parentNode)
253 | : null
254 | }
255 |
--------------------------------------------------------------------------------
/src/defaultPosition.ts:
--------------------------------------------------------------------------------
1 | import { type PositionT } from './types'
2 |
3 | const DEFAULT_POSITION = 'bottom left'
4 |
5 | export default DEFAULT_POSITION
6 |
7 | export const positions: Array = [
8 | 'bottom left',
9 | 'bottom center',
10 | 'bottom right',
11 | 'middle left',
12 | 'middle center',
13 | 'middle right',
14 | 'top left',
15 | 'top center',
16 | 'top right',
17 | ]
18 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useAutoFlip } from './useAutoFlip'
2 | export { default as useWatcher } from './useWatcher'
3 |
--------------------------------------------------------------------------------
/src/hooks/useAutoFlip.ts:
--------------------------------------------------------------------------------
1 | import invariant from 'invariant'
2 | import { useCallback, useState, useEffect } from 'react'
3 |
4 | import { positions } from '../defaultPosition'
5 | import {
6 | type AlignT,
7 | type HorizontalTargetT,
8 | type PositionT,
9 | type VerticalTargetT,
10 | } from '../types'
11 | import {
12 | fitsOnBottom,
13 | fitsOnLeft,
14 | fitsOnRight,
15 | fitsOnTop,
16 | getDefaultAlign,
17 | isPositionedToBottom,
18 | isPositionedToLeft,
19 | isPositionedToRight,
20 | isPositionedToTop,
21 | } from '../utils'
22 |
23 | type CheckFuncT = (node: HTMLElement, anchor: HTMLElement) => void
24 |
25 | const useAutoFlip = (
26 | enableAutoHorizontalFlip: boolean,
27 | enableAutoVerticalFlip: boolean,
28 | initialPosition: PositionT,
29 | initialAlign: AlignT
30 | ): [PositionT, AlignT, CheckFuncT] => {
31 | const [currentPosition, setCurrentPosition] = useState(initialPosition)
32 | const [currentAlign, setCurrentAlign] = useState(
33 | initialAlign || getDefaultAlign(initialPosition)
34 | )
35 |
36 | useEffect(() => {
37 | setCurrentPosition(initialPosition)
38 | setCurrentAlign(initialAlign || getDefaultAlign(initialPosition))
39 | }, [initialAlign, initialPosition])
40 |
41 | const checkAlignment = useCallback(
42 | (nodeRef, anchorRef) => {
43 | const [horizontalPosition, horizontalAlign] = autoFlipHorizontally(
44 | nodeRef,
45 | anchorRef,
46 | {
47 | enabled: enableAutoHorizontalFlip,
48 | initialPosition,
49 | initialAlign,
50 | currentPosition,
51 | currentAlign,
52 | }
53 | )
54 |
55 | const [verticalPosition, verticalAlign] = autoFlipVertically(
56 | nodeRef,
57 | anchorRef,
58 | {
59 | enabled: enableAutoVerticalFlip,
60 | initialPosition,
61 | initialAlign,
62 | currentPosition: horizontalPosition,
63 | currentAlign: horizontalAlign,
64 | }
65 | )
66 |
67 | if (verticalPosition !== currentPosition) {
68 | setCurrentPosition(verticalPosition)
69 | }
70 |
71 | if (verticalAlign !== currentAlign) {
72 | setCurrentAlign(verticalAlign)
73 | }
74 | },
75 | [
76 | currentAlign,
77 | currentPosition,
78 | enableAutoHorizontalFlip,
79 | enableAutoVerticalFlip,
80 | initialAlign,
81 | initialPosition,
82 | ]
83 | )
84 |
85 | return [currentPosition, currentAlign, checkAlignment]
86 | }
87 |
88 | export default useAutoFlip
89 |
90 | type OptionsT = {
91 | enabled: boolean
92 | initialPosition: PositionT
93 | currentPosition: PositionT
94 | initialAlign: AlignT
95 | currentAlign: AlignT
96 | }
97 |
98 | const autoFlipVertically = (
99 | nodeRef: HTMLElement,
100 | anchorRef: HTMLElement,
101 | {
102 | enabled,
103 | initialPosition,
104 | currentPosition,
105 | initialAlign,
106 | currentAlign,
107 | }: OptionsT
108 | ) => {
109 | if (!enabled) {
110 | return [currentPosition, currentAlign]
111 | }
112 |
113 | const positionedToBottom = isPositionedToBottom(currentPosition)
114 | const positionedToTop = isPositionedToTop(currentPosition)
115 |
116 | if (isPositionedToBottom(initialPosition)) {
117 | if (fitsOnBottom(nodeRef, anchorRef)) {
118 | if (!positionedToBottom) {
119 | return [switchToBottom(currentPosition), switchToTop(currentAlign)]
120 | }
121 | } else if (fitsOnTop(nodeRef, anchorRef) && !positionedToTop) {
122 | return [switchToTop(currentPosition), switchToBottom(currentAlign)]
123 | }
124 | }
125 |
126 | if (isPositionedToTop(initialPosition)) {
127 | if (fitsOnTop(nodeRef, anchorRef)) {
128 | if (!positionedToTop) {
129 | return [switchToTop(currentPosition), switchToBottom(currentAlign)]
130 | }
131 | } else if (fitsOnBottom(nodeRef, anchorRef) && !positionedToBottom) {
132 | return [switchToBottom(currentPosition), switchToTop(currentAlign)]
133 | }
134 | }
135 |
136 | return [currentPosition, currentAlign]
137 | }
138 |
139 | const autoFlipHorizontally = (
140 | nodeRef: HTMLElement,
141 | anchorRef: HTMLElement,
142 | {
143 | enabled,
144 | initialPosition,
145 | currentPosition,
146 | initialAlign,
147 | currentAlign,
148 | }: OptionsT
149 | ) => {
150 | if (!enabled) {
151 | return [currentPosition, currentAlign]
152 | }
153 |
154 | const positionedToLeft = isPositionedToLeft(currentPosition)
155 | const positionedToRight = isPositionedToRight(currentPosition)
156 |
157 | if (isPositionedToRight(initialPosition)) {
158 | if (fitsOnRight(nodeRef, anchorRef)) {
159 | if (!positionedToRight) {
160 | return [switchToRight(currentPosition), switchToLeft(currentAlign)]
161 | }
162 | } else if (fitsOnLeft(nodeRef, anchorRef) && !positionedToLeft) {
163 | return [switchToLeft(currentPosition), switchToRight(currentAlign)]
164 | }
165 | }
166 |
167 | if (isPositionedToLeft(initialPosition)) {
168 | if (fitsOnLeft(nodeRef, anchorRef)) {
169 | if (!positionedToLeft) {
170 | return [switchToLeft(currentPosition), switchToRight(currentAlign)]
171 | }
172 | } else if (fitsOnRight(nodeRef, anchorRef) && !positionedToRight) {
173 | return [switchToRight(currentPosition), switchToLeft(currentAlign)]
174 | }
175 | }
176 |
177 | return [currentPosition, currentAlign]
178 | }
179 |
180 | const switchVerticalPosition = (
181 | position: PositionT,
182 | target: VerticalTargetT
183 | ) => {
184 | const newPosition: PositionT | undefined | null = positions.find(
185 | (standardPosition: PositionT) =>
186 | standardPosition === `${target} ${position.split(' ')[1]}`
187 | )
188 |
189 | invariant(
190 | newPosition,
191 | `Could not determine new position. Old position "${position}", new vertical target "${target}"`
192 | )
193 |
194 | return newPosition
195 | }
196 | const switchHorizontalPosition = (
197 | position: PositionT,
198 | target: HorizontalTargetT
199 | ) => {
200 | const newPosition: PositionT | undefined | null = positions.find(
201 | (standardPosition: PositionT) =>
202 | standardPosition === `${position.split(' ')[0]} ${target}`
203 | )
204 |
205 | invariant(
206 | newPosition,
207 | `Could not determine new position. Old position "${position}", new horizontal target "${target}"`
208 | )
209 |
210 | return newPosition
211 | }
212 |
213 | const switchToBottom = (position: PositionT) =>
214 | switchVerticalPosition(position, 'bottom')
215 | const switchToTop = (position: PositionT) =>
216 | switchVerticalPosition(position, 'top')
217 |
218 | const switchToLeft = (position: PositionT) =>
219 | switchHorizontalPosition(position, 'left')
220 | const switchToRight = (position: PositionT) =>
221 | switchHorizontalPosition(position, 'right')
222 |
--------------------------------------------------------------------------------
/src/hooks/useWatcher.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | type WatcherFuncT = () => void
4 |
5 | type OptionsT = {
6 | updateOnAnimationFrame: boolean
7 | enabled: boolean
8 | }
9 |
10 | function useWatcher(
11 | watcher: WatcherFuncT,
12 | { updateOnAnimationFrame, enabled }: OptionsT
13 | ): void {
14 | useEffect(() => {
15 | let animationFrameId: number
16 | let idleCallbackId: number
17 |
18 | // do not track in node
19 | if (enabled && typeof window.requestAnimationFrame !== 'undefined') {
20 | const callback = () => {
21 | watcher()
22 |
23 | if (updateOnAnimationFrame) {
24 | animationFrameId = requestAnimationFrame(callback)
25 | } else {
26 | idleCallbackId = requestIdleCallback(callback)
27 | }
28 | }
29 |
30 | callback()
31 | }
32 |
33 | return () => {
34 | if (animationFrameId) {
35 | cancelAnimationFrame(animationFrameId)
36 | }
37 |
38 | if (idleCallbackId) {
39 | cancelIdleCallback(idleCallbackId)
40 | }
41 | }
42 | }, [updateOnAnimationFrame, watcher, enabled])
43 | }
44 |
45 | export default useWatcher
46 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Stick'
2 | export { PortalContext } from './StickPortal'
3 |
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | // "allowJs": true,
5 | "rootDir": ".",
6 | "sourceMap": true,
7 | "declaration": true,
8 | "declarationMap": true,
9 | },
10 | "include": [
11 | "**/*.ts",
12 | "**/*.tsx",
13 | ],
14 | }
15 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { LegacyRef, ReactNode } from 'react'
2 | import type { StylingProps, Substyle } from 'substyle'
3 |
4 | export type VerticalTargetT = 'bottom' | 'middle' | 'top'
5 | export type HorizontalTargetT = 'left' | 'center' | 'right'
6 |
7 | export type PositionT =
8 | | 'bottom left'
9 | | 'bottom center'
10 | | 'bottom right'
11 | | 'middle left'
12 | | 'middle center'
13 | | 'middle right'
14 | | 'top left'
15 | | 'top center'
16 | | 'top right'
17 |
18 | export type AlignT = PositionT
19 |
20 | export type StickPropsT = {
21 | position?: PositionT
22 | align?: AlignT
23 | inline?: boolean
24 | sameWidth?: boolean
25 | autoFlipVertically?: boolean
26 | autoFlipHorizontally?: boolean
27 | updateOnAnimationFrame?: boolean
28 | component?: string
29 | transportTo?: HTMLElement
30 | node?: ReactNode | null
31 | children: ReactNode
32 | onClickOutside?: (ev: MouseEvent) => void
33 | } & StylingProps &
34 | React.HTMLAttributes
35 |
36 | type SpecificStickBasePropsT = {
37 | children: ReactNode
38 | node?: ReactNode | null
39 | component?: string | null
40 | style: Substyle
41 | nestingKey: string
42 | containerRef?: LegacyRef
43 | }
44 |
45 | export type StickInlinePropsT = {
46 | position: PositionT
47 | align: AlignT
48 | } & SpecificStickBasePropsT
49 |
50 | export type StickPortalPropsT = {
51 | transportTo?: HTMLElement | null
52 | position: PositionT
53 | updateOnAnimationFrame: boolean
54 | onReposition: (nodeRef: HTMLElement) => void
55 | } & SpecificStickBasePropsT
56 |
--------------------------------------------------------------------------------
/src/utils/fit.ts:
--------------------------------------------------------------------------------
1 | import { type PositionT } from '../types'
2 |
3 | export const isPositionedToTop = (position: PositionT): boolean => {
4 | const [positionMarker] = position.split(' ')
5 |
6 | return positionMarker === 'top'
7 | }
8 |
9 | export const isPositionedToBottom = (position: PositionT): boolean => {
10 | const [positionMarker] = position.split(' ')
11 |
12 | return positionMarker === 'bottom'
13 | }
14 |
15 | export const isPositionedToRight = (position: PositionT): boolean => {
16 | const positionMarker = position.split(' ')[1]
17 |
18 | return positionMarker === 'right'
19 | }
20 |
21 | export const isPositionedToLeft = (position: PositionT): boolean => {
22 | const positionMarker = position.split(' ')[1]
23 |
24 | return positionMarker === 'left'
25 | }
26 |
27 | export const fitsOnRight = (
28 | nodeRef: HTMLElement,
29 | anchorRef: HTMLElement
30 | ): boolean => {
31 | const { width: nodeWidth } = nodeRef.getBoundingClientRect()
32 | const { right: anchorRight } = anchorRef.getBoundingClientRect()
33 |
34 | return anchorRight + nodeWidth <= window.innerWidth
35 | }
36 |
37 | export const fitsOnLeft = (
38 | nodeRef: HTMLElement,
39 | anchorRef: HTMLElement
40 | ): boolean => {
41 | const { width: nodeWidth } = nodeRef.getBoundingClientRect()
42 | const { left: anchorLeft } = anchorRef.getBoundingClientRect()
43 |
44 | return anchorLeft - nodeWidth >= 0
45 | }
46 |
47 | export const fitsOnTop = (
48 | nodeRef: HTMLElement,
49 | anchorRef: HTMLElement
50 | ): boolean => {
51 | const { height: nodeHeight } = nodeRef.getBoundingClientRect()
52 | const { top: anchorTop } = anchorRef.getBoundingClientRect()
53 |
54 | return anchorTop - nodeHeight >= 0
55 | }
56 |
57 | export const fitsOnBottom = (
58 | nodeRef: HTMLElement,
59 | anchorRef: HTMLElement
60 | ): boolean => {
61 | const { height: nodeHeight } = nodeRef.getBoundingClientRect()
62 | const { bottom: anchorBottom } = anchorRef.getBoundingClientRect()
63 |
64 | return anchorBottom + nodeHeight <= window.innerHeight
65 | }
66 |
--------------------------------------------------------------------------------
/src/utils/getDefaultAlign.ts:
--------------------------------------------------------------------------------
1 | import { type PositionT, type AlignT } from '../types'
2 |
3 | type DefaultAlignT = {
4 | [position in PositionT]: AlignT
5 | }
6 |
7 | const defaultAligns: DefaultAlignT = {
8 | 'top left': 'bottom left',
9 | 'top center': 'bottom center',
10 | 'top right': 'bottom right',
11 | 'middle left': 'middle right',
12 | 'middle center': 'middle center',
13 | 'middle right': 'middle left',
14 | 'bottom left': 'top left',
15 | 'bottom center': 'top center',
16 | 'bottom right': 'top right',
17 | }
18 |
19 | const getDefaultAlign = (position: PositionT): AlignT => defaultAligns[position]
20 |
21 | export default getDefaultAlign
22 |
--------------------------------------------------------------------------------
/src/utils/getModifiers.ts:
--------------------------------------------------------------------------------
1 | import DEFAULT_POSITION from '../defaultPosition'
2 | import { type AlignT, type PositionT } from '../types'
3 | import getDefaultAlign from './getDefaultAlign'
4 |
5 | type PropsT = {
6 | align: AlignT
7 | position: PositionT
8 | sameWidth?: boolean
9 | }
10 |
11 | const getModifiers = ({ align, position, sameWidth }: PropsT) => {
12 | const finalPosition = position || DEFAULT_POSITION
13 | const [verticalPosition, horizontalPosition] = finalPosition.split(' ')
14 | const [verticalAlign, horizontalAlign] = (
15 | align || getDefaultAlign(finalPosition)
16 | ).split(' ')
17 | return {
18 | [`&position-${horizontalPosition}`]: true,
19 | [`&position-${verticalPosition}`]: true,
20 | [`&align-${horizontalAlign}`]: true,
21 | [`&align-${verticalAlign}`]: true,
22 | '&sameWidth': !!sameWidth,
23 | }
24 | }
25 |
26 | export default getModifiers
27 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './scroll'
2 | export * from './fit'
3 |
4 | export { default as getDefaultAlign } from './getDefaultAlign'
5 | export { default as getModifiers } from './getModifiers'
6 | export { default as uniqueId } from './uniqueId'
7 |
--------------------------------------------------------------------------------
/src/utils/scroll.ts:
--------------------------------------------------------------------------------
1 | export function scrollX(node?: Node | null): number {
2 | if (!node) {
3 | return 0
4 | }
5 |
6 | if (!(node instanceof HTMLElement)) {
7 | return 0
8 | }
9 |
10 | return node.scrollLeft + scrollX(node.parentNode)
11 | }
12 |
13 | export function scrollY(): number {
14 | if (typeof window !== 'undefined') {
15 | return typeof window.scrollY === 'number'
16 | ? window.scrollY
17 | : window.pageYOffset
18 | }
19 | return 0
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/uniqueId.ts:
--------------------------------------------------------------------------------
1 | let counter = 1
2 |
3 | function uniqueId(): number {
4 | return counter++
5 | }
6 |
7 | export default uniqueId
8 |
--------------------------------------------------------------------------------
/tests/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'cypress'
2 |
3 | import istanbul from 'vite-plugin-istanbul'
4 |
5 | export default defineConfig({
6 | component: {
7 | devServer: {
8 | framework: 'react',
9 | bundler: 'vite',
10 | viteConfig: {
11 | logLevel: 'warn',
12 | plugins: [istanbul({ cwd: '../src', cypress: true })],
13 | },
14 | },
15 | specPattern: 'tests/src/**/*.test.{ts,tsx}',
16 | supportFile: 'tests/support/component.ts',
17 | indexHtmlFile: 'tests/support/component-index.html',
18 | setupNodeEvents(on, config) {
19 | require('@cypress/code-coverage/task')(on, config)
20 | return config
21 | },
22 | },
23 | viewportWidth: 1024,
24 | viewportHeight: 1024,
25 | downloadsFolder: 'cypress/downloads',
26 | videosFolder: 'cypress/videos',
27 | fixturesFolder: 'cypress/fixtures',
28 | screenshotsFolder: 'cypress/screenshots',
29 | })
30 |
--------------------------------------------------------------------------------
/tests/node/ssr.spec.tsx:
--------------------------------------------------------------------------------
1 | import { renderToStaticMarkup } from 'react-dom/server'
2 | import Stick from '../../src'
3 | import { describe, expect, it } from 'vitest'
4 |
5 | describe('SSR', () => {
6 | it('does not throw when rendering in a node environment', () => {
7 | expect(() =>
8 | renderToStaticMarkup(
9 | Node}>
10 | Anchor
11 |
12 | )
13 | ).not.toThrow()
14 | })
15 |
16 | it('renders only the anchor and not the node on the server', () => {
17 | expect(
18 | renderToStaticMarkup(Node}>Anchor )
19 | ).toMatchInlineSnapshot(`"Anchor
"`)
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/tests/src/autoPositioning.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Stick from '../../src'
4 | import { batchFindByTestId, render } from './utils'
5 |
6 | const windowHeight = window.innerHeight
7 | const windowWidth = window.innerWidth
8 |
9 | describe('autoPositioning', () => {
10 | const node =
11 | const anchor = (
12 |
13 | )
14 |
15 | describe('vertical', () => {
16 | it('should move the node from bottom to top if there is not enough space at the bottom.', () => {
17 | render(
18 |
24 | {anchor}
25 |
26 | )
27 |
28 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
29 | const { top: nodeTop, height: nodeHeight } =
30 | node[0].getBoundingClientRect()
31 | const { top: anchorTop } = anchor[0].getBoundingClientRect()
32 |
33 | expect(anchorTop).equal(nodeTop + nodeHeight)
34 | })
35 | })
36 |
37 | it('should move the node back to its original intended position if space clears up (initial position: "bottom center").', () => {
38 | render(
39 |
45 | {anchor}
46 |
47 | ).then(({ rerender }) =>
48 | rerender(
49 |
55 | {anchor}
56 |
57 | )
58 | )
59 |
60 | cy.window().then((window) => window.dispatchEvent(new Event('scroll')))
61 |
62 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
63 | const { top: nodeTop } = node[0].getBoundingClientRect()
64 | const { top: anchorTop, height: anchorHeight } =
65 | anchor[0].getBoundingClientRect()
66 |
67 | console.log(anchorTop)
68 |
69 | expect(nodeTop).equal(anchorTop + anchorHeight)
70 | })
71 | })
72 |
73 | it('should move the node back to its original intended position if space clears up (initial position: "top center").', () => {
74 | render(
75 |
76 | {anchor}
77 |
78 | ).then(({ rerender }) =>
79 | rerender(
80 |
86 | {anchor}
87 |
88 | )
89 | )
90 |
91 | cy.window().then((window) => window.dispatchEvent(new Event('scroll')))
92 |
93 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
94 | const { top: nodeTop, height: nodeHeight } =
95 | node[0].getBoundingClientRect()
96 | const { top: anchorTop } = anchor[0].getBoundingClientRect()
97 |
98 | expect(anchorTop).equal(nodeTop + nodeHeight)
99 | })
100 | })
101 |
102 | it('should not move the node from top to bottom if there is not enough space a the top.', () => {
103 | render(
104 |
110 | {anchor}
111 |
112 | )
113 |
114 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
115 | const { top: nodeTop, height: nodeHeight } =
116 | node[0].getBoundingClientRect()
117 | const { top: anchorTop } = anchor[0].getBoundingClientRect()
118 |
119 | expect(anchorTop).equal(nodeTop + nodeHeight)
120 | })
121 | })
122 |
123 | it('should not move the node from top to bottom if there is neither enough space at the top nor the botom.', () => {
124 | render(
125 |
131 | {anchor}
132 |
133 | )
134 |
135 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
136 | const { top: nodeTop, height: nodeHeight } =
137 | node[0].getBoundingClientRect()
138 | const { top: anchorTop } = anchor[0].getBoundingClientRect()
139 |
140 | expect(anchorTop).equal(nodeTop + nodeHeight)
141 | })
142 | })
143 |
144 | it('should not move the node from bottom to top if there is neither enough space at the bottom nor the top.', () => {
145 | render(
146 |
147 |
151 |
152 | )
153 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
154 | const { top: nodeTop } = node[0].getBoundingClientRect()
155 | const { top: anchorTop, height: anchorHeight } =
156 | anchor[0].getBoundingClientRect()
157 |
158 | expect(nodeTop).equal(anchorTop + anchorHeight)
159 | })
160 | })
161 | })
162 |
163 | describe('horizontal', () => {
164 | it('should move the node from left to right if there is not enough space at the left.', () => {
165 | render(
166 |
167 | {anchor}
168 |
169 | )
170 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
171 | const { left: nodeLeft } = node[0].getBoundingClientRect()
172 | const { left: anchorLeft, width: anchorWidth } =
173 | anchor[0].getBoundingClientRect()
174 |
175 | expect(nodeLeft).equal(anchorLeft + anchorWidth)
176 | })
177 | })
178 |
179 | it('should move the node back to its original intended position if space clears up (initial position: "middle right").', () => {
180 | render(
181 |
187 | {anchor}
188 |
189 | ).then(({ rerender }) =>
190 | rerender(
191 |
197 | {anchor}
198 |
199 | )
200 | )
201 |
202 | cy.window().then((window) => window.dispatchEvent(new Event('scroll')))
203 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
204 | const { left: nodeLeft } = node[0].getBoundingClientRect()
205 | const { left: anchorLeft, width: anchorWidth } =
206 | anchor[0].getBoundingClientRect()
207 |
208 | expect(nodeLeft).equal(anchorLeft + anchorWidth)
209 | })
210 | })
211 |
212 | it('should move the node back to its original intended position if space clears up (initial position: "middle left").', () => {
213 | render(
214 |
215 | {anchor}
216 |
217 | ).then(({ rerender }) =>
218 | rerender(
219 |
227 | {anchor}
228 |
229 | )
230 | )
231 |
232 | cy.window().then((window) => window.dispatchEvent(new Event('scroll')))
233 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
234 | const { left: nodeLeft, width: nodeWidth } =
235 | node[0].getBoundingClientRect()
236 | const { left: anchorLeft, width: anchorWidth } =
237 | anchor[0].getBoundingClientRect()
238 |
239 | console.log(nodeWidth, nodeLeft, anchorWidth, anchorLeft)
240 |
241 | expect(anchorLeft).equal(nodeLeft + nodeWidth)
242 | })
243 | })
244 |
245 | it('should move the node from right to left if there is not enough space a the right.', () => {
246 | render(
247 |
253 | {anchor}
254 |
255 | )
256 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
257 | const { left: nodeLeft, width: nodeWidth } =
258 | node[0].getBoundingClientRect()
259 | const { left: anchorLeft } = anchor[0].getBoundingClientRect()
260 |
261 | expect(anchorLeft).equal(nodeLeft + nodeWidth)
262 | })
263 | })
264 |
265 | it('should not move the node from left to right if there is neither enough space at the left nor the right.', () => {
266 | render(
267 |
273 | {anchor}
274 |
275 | )
276 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
277 | const { left: nodeLeft, width: nodeWidth } =
278 | node[0].getBoundingClientRect()
279 | const { left: anchorLeft } = anchor[0].getBoundingClientRect()
280 |
281 | expect(anchorLeft).equal(nodeLeft + nodeWidth)
282 | })
283 | })
284 |
285 | it('should not move the node from right to left if there is neither enough space at the right nor the left.', () => {
286 | render(
287 |
288 |
292 |
293 | )
294 | batchFindByTestId(['node', 'anchor']).then(([node, anchor]) => {
295 | const { left: nodeLeft } = node[0].getBoundingClientRect()
296 | const { left: anchorLeft, width: anchorWidth } =
297 | anchor[0].getBoundingClientRect()
298 |
299 | expect(nodeLeft).equal(anchorLeft + anchorWidth)
300 | })
301 | })
302 | })
303 | })
304 |
--------------------------------------------------------------------------------
/tests/src/customComponent.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Stick from '../../src/'
4 | import { wrapRender } from './utils'
5 |
6 | describe('customize wrapper component', () => {
7 | const node =
8 |
9 | const SvgWrapper = ({ children }: { children: React.ReactNode }) => (
10 |
11 | {children}
12 |
13 | )
14 | // wrap render in an svg element
15 | const render = (stick: React.ReactElement) => wrapRender(stick, SvgWrapper)
16 |
17 | it('should be possible to render in SVG by passing `"g"` as `component`', () => {
18 | render(
19 |
20 |
27 |
28 | )
29 |
30 | cy.findByTestId('node').then((node) => {
31 | const { left, top } = node[0].getBoundingClientRect()
32 | expect(left).equal(208)
33 | expect(top).equal(58)
34 | })
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/tests/src/nodeWidth.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Stick from '../../src'
4 | import { wrapRender } from './utils'
5 |
6 | describe('stick node width', () => {
7 | const longText = new Array(50).fill('Lorem ipsum dolor sit amet.').join(' ')
8 | const anchor =
9 | const node = (
10 |
11 | {longText}
12 |
13 | )
14 |
15 | const PositionWrapper = ({ children }: { children: React.ReactNode }) => (
16 |
17 |
18 | {children}
19 |
20 |
21 | )
22 |
23 | const render = (stick: React.ReactElement) =>
24 | wrapRender(stick, PositionWrapper)
25 |
26 | const inlineOptions = [false, true]
27 | inlineOptions.forEach((inline) => {
28 | describe(`inline={${inline.toString()}}`, () => {
29 | it('should make sure that a left aligned node stretches to the right screen border', () => {
30 | render(
31 |
37 | {anchor}
38 |
39 | )
40 |
41 | cy.findByTestId('node').then((node) => {
42 | const { right } = node[0].getBoundingClientRect()
43 | expect(right).equal(document.documentElement?.scrollWidth)
44 | })
45 | })
46 |
47 | it('should make sure that a right aligned node stretches to the left screen border', () => {
48 | render(
49 |
55 | {anchor}
56 |
57 | )
58 | cy.findByTestId('node').then((node) => {
59 | const { left } = node[0].getBoundingClientRect()
60 |
61 | expect(left).equal(0)
62 | })
63 | })
64 |
65 | describe('sameWidth={true}', () => {
66 | it('should make sure that the stick node has the same width as the anchor', () => {
67 | render(
68 |
69 | {anchor}
70 |
71 | )
72 | cy.findByTestId('node').then((node) => {
73 | const { width } = node[0].getBoundingClientRect()
74 | expect(width).equal(100)
75 | })
76 | })
77 | })
78 | })
79 | })
80 | })
81 |
--------------------------------------------------------------------------------
/tests/src/onClickOutside.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { mount } from '@cypress/react'
3 |
4 | import Stick from '../../src/'
5 |
6 | describe('`onClickOutside` event', () => {
7 | const anchor =
8 | const node =
9 |
10 | it('should call `onClickOutside` on click on any element outside of the stick node an anchor element', () => {
11 | const spy = cy.stub().as('spy')
12 | mount(
13 |
14 |
15 | {anchor}
16 |
17 |
18 | )
19 |
20 | cy.findByTestId('container').click({ force: true })
21 | cy.get('@spy')
22 | .should('have.been.called')
23 | .then(() => spy.reset())
24 |
25 | cy.get('body').click({ force: true })
26 |
27 | cy.get('@spy').should('have.been.called')
28 | })
29 |
30 | it('should call `onClickOutside` on click on SVGElement outside of the stick node an anchor element', () => {
31 | const spy = cy.stub().as('spy')
32 | mount(
33 |
34 |
35 | {anchor}
36 |
37 |
44 |
45 |
46 |
47 | )
48 |
49 | cy.findByTestId('svg-element').click({ force: true })
50 | cy.get('@spy')
51 | .should('have.been.called')
52 | .then(() => spy.reset())
53 |
54 | cy.get('body').click({ force: true })
55 |
56 | cy.get('@spy').should('have.been.called')
57 | })
58 |
59 | it('should not call `onClickOutside` on click on the anchor element or stick node', () => {
60 | const spy = cy.stub().as('spy')
61 |
62 | mount(
63 |
64 | {anchor}
65 |
66 | )
67 | cy.findByTestId('anchor').click({ force: true })
68 | cy.get('@spy')
69 | .should('not.have.been.called')
70 | .then(() => spy.reset())
71 |
72 | cy.findByTestId('node').click({ force: true })
73 | cy.get('@spy').should('not.have.been.called')
74 | })
75 |
76 | const inlineOptions = [false, true]
77 | inlineOptions.forEach((outerInline) => {
78 | inlineOptions.forEach((innerInline) => {
79 | describe(` in node of `, () => {
82 | it('should not call `onClickOutside` on click on the nested stick node', () => {
83 | const spy = cy.stub().as('spy')
84 | mount(
85 | }
92 | >
93 | foo
94 |
95 | }
96 | >
97 |
98 |
99 | )
100 |
101 | cy.findByTestId('nested-node').click({ force: true })
102 | cy.get('@spy').should('not.have.been.called')
103 | })
104 | })
105 | })
106 | })
107 | })
108 |
--------------------------------------------------------------------------------
/tests/src/positioning.test.tsx:
--------------------------------------------------------------------------------
1 | import invariant from 'invariant'
2 | import React from 'react'
3 |
4 | import Stick from '../../src/'
5 | import {
6 | type AlignT,
7 | type HorizontalTargetT,
8 | type PositionT,
9 | type VerticalTargetT,
10 | } from '../../src/types'
11 | import { render } from './utils'
12 |
13 | const getPosition = (
14 | vertical: VerticalTargetT,
15 | horizontal: HorizontalTargetT
16 | ): PositionT => {
17 | const position = [vertical, horizontal].join(' ')
18 |
19 | invariant(
20 | position === 'bottom left' ||
21 | position === 'bottom center' ||
22 | position === 'bottom right' ||
23 | position === 'middle left' ||
24 | position === 'middle center' ||
25 | position === 'middle right' ||
26 | position === 'top left' ||
27 | position === 'top center' ||
28 | position === 'top right',
29 | `Invalid position: "${position}"`
30 | )
31 |
32 | return position
33 | }
34 |
35 | const getAlign = (
36 | vertical: VerticalTargetT,
37 | horizontal: HorizontalTargetT
38 | ): AlignT => {
39 | const align = [vertical, horizontal].join(' ')
40 |
41 | invariant(
42 | align === 'bottom left' ||
43 | align === 'bottom center' ||
44 | align === 'bottom right' ||
45 | align === 'middle left' ||
46 | align === 'middle center' ||
47 | align === 'middle right' ||
48 | align === 'top left' ||
49 | align === 'top center' ||
50 | align === 'top right',
51 | `Invalid align: "${align}"`
52 | )
53 |
54 | return align
55 | }
56 |
57 | describe('positioning', () => {
58 | const verticals: ReadonlyArray = ['top', 'middle', 'bottom']
59 | const horizontals: ReadonlyArray = [
60 | 'left',
61 | 'center',
62 | 'right',
63 | ]
64 |
65 | const BODY_PADDING = 8
66 |
67 | const NODE_WIDTH = 10
68 | const NODE_HEIGHT = 20
69 | const node = (
70 |
74 | )
75 |
76 | const ANCHOR_WIDTH = 30
77 | const ANCHOR_HEIGHT = 40
78 | const anchor = (
79 |
83 | )
84 |
85 | const widthFactor = (position: HorizontalTargetT) => {
86 | switch (position) {
87 | case 'left':
88 | return 0
89 | case 'center':
90 | return 0.5
91 | case 'right':
92 | return 1
93 | default:
94 | throw new Error(`Invalid position "${position}"`)
95 | }
96 | }
97 |
98 | const heightFactor = (position: VerticalTargetT) => {
99 | switch (position) {
100 | case 'top':
101 | return 0
102 | case 'middle':
103 | return 0.5
104 | case 'bottom':
105 | return 1
106 | default:
107 | throw new Error(`Invalid position "${position}"`)
108 | }
109 | }
110 |
111 | const calcLeft = (position: HorizontalTargetT, align: HorizontalTargetT) =>
112 | BODY_PADDING +
113 | widthFactor(position) * ANCHOR_WIDTH -
114 | widthFactor(align) * NODE_WIDTH
115 |
116 | const calcTop = (position: VerticalTargetT, align: VerticalTargetT) =>
117 | BODY_PADDING +
118 | heightFactor(position) * ANCHOR_HEIGHT -
119 | heightFactor(align) * NODE_HEIGHT
120 |
121 | verticals.forEach((verticalPosition) => {
122 | horizontals.forEach((horizontalPosition) => {
123 | const position = getPosition(verticalPosition, horizontalPosition)
124 |
125 | describe(`position="${position}"`, () => {
126 | verticals.forEach((verticalAlign) => {
127 | horizontals.forEach((horizontalAlign) => {
128 | const align = getAlign(verticalAlign, horizontalAlign)
129 |
130 | const expectedLeft = calcLeft(horizontalPosition, horizontalAlign)
131 | const expectedTop = calcTop(verticalPosition, verticalAlign)
132 |
133 | describe(`align="${align}"`, () => {
134 | it(`should place at left: ${expectedLeft} top: ${expectedTop}`, () => {
135 | render(
136 |
137 | {anchor}
138 |
139 | )
140 |
141 | cy.findByTestId('node').then((node) => {
142 | const { left, top } = node[0].getBoundingClientRect()
143 | expect(left).equal(expectedLeft)
144 | expect(top).equal(expectedTop)
145 | })
146 | })
147 |
148 | it(`should place at left: ${expectedLeft} top: ${expectedTop} with \`inline\` prop`, () => {
149 | render(
150 |
151 | {anchor}
152 |
153 | )
154 |
155 | cy.findByTestId('node').then((node) => {
156 | const { left, top } = node[0].getBoundingClientRect()
157 | expect(left).equal(expectedLeft)
158 | expect(top).equal(expectedTop)
159 | })
160 | })
161 | })
162 | })
163 | })
164 |
165 | it('should use the correct default `align`', () => {
166 | const defaultAligns = {
167 | 'top left': 'bottom left',
168 | 'top center': 'bottom center',
169 | 'top right': 'bottom right',
170 | 'middle left': 'middle right',
171 | 'middle center': 'middle center',
172 | 'middle right': 'middle left',
173 | 'bottom left': 'top left',
174 | 'bottom center': 'top center',
175 | 'bottom right': 'top right',
176 | } as const
177 |
178 | const stick = (
179 |
180 | {anchor}
181 |
182 | )
183 | const otherStick = (
184 |
189 | {anchor}
190 |
191 | )
192 |
193 | render(stick).then(({ rerender }) =>
194 | cy.findByTestId('node').then((node) => {
195 | const { left, top } = node[0].getBoundingClientRect()
196 | cy.wrap({ left, top }).as('stickPosition')
197 | return rerender(otherStick)
198 | })
199 | )
200 |
201 | cy.findByTestId('node').then((node) => {
202 | const { left: otherLeft, top: otherTop } =
203 | node[0].getBoundingClientRect()
204 | cy.get<{ left: number; top: number }>('@stickPosition').then(
205 | ({ left, top }) => {
206 | expect(left).to.be.equal(otherLeft)
207 | expect(top).to.be.equal(otherTop)
208 | }
209 | )
210 | })
211 | })
212 | })
213 | })
214 | })
215 | })
216 |
--------------------------------------------------------------------------------
/tests/src/scroll.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Stick from '../../src'
4 | import { wrapRender } from './utils'
5 |
6 | describe('positioning in scrolling window', () => {
7 | let fixedElement: HTMLDivElement
8 |
9 | const longText = new Array(50).fill('Lorem ipsum dolor sit amet.').join(' ')
10 | const anchor =
11 | const node = {longText}
12 |
13 | const PositionWrapper = (
14 | { children }: { children: React.ReactNode } // sets documentElement's scroll width to 1008
15 | ) => (
16 |
17 |
18 | {children}
19 |
20 |
21 | )
22 |
23 | const render = (stick: React.ReactElement) =>
24 | wrapRender(stick, PositionWrapper)
25 |
26 | beforeEach(() => {
27 | fixedElement = document.createElement('div')
28 | fixedElement.style.position = 'fixed'
29 | fixedElement.style.top = '0'
30 |
31 | cy.document().then((document) => document.body.appendChild(fixedElement))
32 |
33 | cy.window().then((window) => window.scrollTo(0, 0))
34 | })
35 |
36 | afterEach(() => {
37 | cy.document().then((document) => document.body.removeChild(fixedElement))
38 | })
39 |
40 | it.only('should keep the correct the node position when scrolling', () => {
41 | render(
42 |
43 | {anchor}
44 |
45 | )
46 |
47 | cy.findByTestId('node').then((node) => {
48 | const { top } = node[0].getBoundingClientRect()
49 | expect(top).equal(5000)
50 | })
51 |
52 | cy.window().then((window) => {
53 | window.scrollTo(0, 3000)
54 | })
55 | cy.findByTestId('node').then((node) => {
56 | // getBoundingClientRect takes the scrolling amount into account
57 | const { top: topAfterScroll } = node[0].getBoundingClientRect()
58 | expect(topAfterScroll).equal(2000) // 5000 absolute position - 3000 scroll top
59 | })
60 | })
61 |
62 | it('should keep the correct the node position when transported to a fixed container', () => {
63 | render(
64 |
70 | {anchor}
71 |
72 | )
73 |
74 | cy.findByTestId('node').then((node) => {
75 | const { top } = node[0].getBoundingClientRect()
76 | expect(top).equal(5000)
77 | })
78 |
79 | cy.window().then((window) => {
80 | window.scrollTo(0, 3000)
81 | })
82 |
83 | cy.findByTestId('node').then((node) => {
84 | // in the fixed transport target, the scrolling of the viewport should have no effect
85 | const { top: topAfterScroll } = node[0].getBoundingClientRect()
86 | expect(topAfterScroll).equal(5000)
87 | })
88 | })
89 | })
90 |
--------------------------------------------------------------------------------
/tests/src/updates.test.tsx:
--------------------------------------------------------------------------------
1 | import invariant from 'invariant'
2 | import React from 'react'
3 |
4 | import Stick from '../../src/'
5 | import { wrapRender } from './utils'
6 |
7 | describe('updates', () => {
8 | const anchor =
9 | const node =
10 |
11 | const PositionWrapper = ({ children }: { children: React.ReactNode }) => (
12 | {children}
13 | )
14 |
15 | // wrap render to invoke callback only after the node has actually been mounted
16 | const render = (stick: React.ReactElement) =>
17 | wrapRender(stick, PositionWrapper)
18 |
19 | it('should work if the node is only provided after the initial mount', () => {
20 | render(
21 |
22 | {anchor}
23 |
24 | ).then(({ rerender }) =>
25 | rerender(
26 |
27 | {anchor}
28 |
29 | )
30 | )
31 |
32 | cy.findByTestId('node').then((node) => {
33 | const { left, top } = node[0].getBoundingClientRect()
34 |
35 | expect(left).equal(18)
36 | expect(top).equal(8)
37 | })
38 | })
39 |
40 | it('should unmount node container if no node is passed anymore', () => {
41 | render({anchor} ).then(({ rerender }) => {
42 | cy.document()
43 | .then((document) => {
44 | const body = document.body
45 |
46 | invariant(body, 'No body element present.')
47 |
48 | return body.childElementCount
49 | })
50 | .as('bodyChildrenCountWithStick')
51 |
52 | rerender({anchor} )
53 |
54 | cy.findByTestId('node').should('not.exist')
55 |
56 | cy.get('@bodyChildrenCountWithStick').then(
57 | (bodyChildrenCountWithStick) => {
58 | cy.document().then((document) => {
59 | expect(document.body?.childElementCount).to.be.equal(
60 | bodyChildrenCountWithStick - 1
61 | )
62 | })
63 | }
64 | )
65 | })
66 | })
67 |
68 | it('should correctly apply `sameWidth` if set after initial mount', () => {
69 | render({anchor} ).then(({ rerender }) =>
70 | rerender(
71 |
72 | {anchor}
73 |
74 | )
75 | )
76 |
77 | cy.findByTestId('node').then((node) => {
78 | // we have to wait for first measure to be applied
79 | const { width } = node[0].getBoundingClientRect()
80 |
81 | expect(width).equal(10)
82 | })
83 | })
84 |
85 | it('should correctly handle clearing of `sameWidth` after initial mount', () => {
86 | render(
87 |
88 | {anchor}
89 |
90 | ).then(({ rerender }) => rerender({anchor} ))
91 |
92 | cy.findByTestId('node').then((node) => {
93 | const { width } = node[0].getBoundingClientRect()
94 | expect(width).equal(0) // empty content means zero width
95 | })
96 | })
97 |
98 | it('should handle switching to `updateOnAnimationFrame` correctly', () => {
99 | render(
100 |
101 | {anchor}
102 |
103 | ).then(({ rerender }) =>
104 | rerender(
105 |
106 | {anchor}
107 |
108 | )
109 | )
110 |
111 | cy.findByTestId('node').then((node) => {
112 | const { left, top } = node[0].getBoundingClientRect()
113 |
114 | expect(left).equal(18)
115 | expect(top).equal(8)
116 | })
117 | })
118 |
119 | it('should handle switching back from `updateOnAnimationFrame` correctly', () => {
120 | render(
121 |
122 | {anchor}
123 |
124 | ).then(({ rerender }) =>
125 | rerender(
126 |
127 | {anchor}
128 |
129 | )
130 | )
131 |
132 | cy.findByTestId('node').then((node) => {
133 | const { left, top } = node[0].getBoundingClientRect()
134 |
135 | expect(left).equal(18)
136 | expect(top).equal(8)
137 | })
138 | })
139 | })
140 |
--------------------------------------------------------------------------------
/tests/src/utils.tsx:
--------------------------------------------------------------------------------
1 | import { mount } from '@cypress/react'
2 | import React from 'react'
3 |
4 | const InlineWrapper = ({ children }: { children: React.ReactNode }) => (
5 | {children}
6 | )
7 |
8 | export function wrapRender(
9 | component: React.ReactNode,
10 | Wrapper: React.ComponentType<{ children: React.ReactNode }> = InlineWrapper,
11 | render: (component: React.ReactNode) => ReturnType = mount
12 | ): ReturnType {
13 | return render({component} )
14 | .then(({ component, rerender, unmount }) => ({
15 | component,
16 | rerender: (component: React.ReactNode) =>
17 | wrapRender(component, Wrapper, rerender),
18 | unmount,
19 | }))
20 | .wait(100)
21 | }
22 |
23 | export function render(component: React.ReactNode): ReturnType {
24 | return wrapRender(component)
25 | }
26 |
27 | export function batchFindByTestId(selectors: string[]) {
28 | return cy
29 | .wrap[]>, JQuery[]>(
30 | Promise.all(
31 | selectors.map(
32 | (selector) =>
33 | new Promise((resolve) =>
34 | cy.findByTestId(selector).then(resolve)
35 | )
36 | )
37 | )
38 | )
39 | .wait(100)
40 | }
41 |
--------------------------------------------------------------------------------
/tests/support/commands.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 |
12 | import '@testing-library/cypress/add-commands'
13 |
--------------------------------------------------------------------------------
/tests/support/component-index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Components App
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/tests/support/component.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/component.ts is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | import './commands'
17 | import '@cypress/code-coverage/support'
18 |
--------------------------------------------------------------------------------
/tests/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "types": ["cypress", "@testing-library/cypress"]
5 | },
6 | "include": ["**/*.ts", "**/*.tsx"]
7 | }
8 |
--------------------------------------------------------------------------------
/tests/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 |
3 | export default defineConfig({
4 | test: {
5 | environment: 'node',
6 | include: ['tests/node/**/*.spec.tsx'],
7 | },
8 | })
9 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "esModuleInterop": true,
6 | "allowSyntheticDefaultImports": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "module": "ESNext",
10 | "moduleResolution": "Node",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "react-jsx"
14 | },
15 | }
16 |
--------------------------------------------------------------------------------