├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── test-wpt.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .stylelintignore ├── .stylelintrc.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── eslint.config.js ├── favicon.svg ├── index.html ├── netlify.toml ├── package-lock.json ├── package.json ├── playwright.config.ts ├── position-area.html ├── public ├── anchor-absolute.css ├── anchor-custom-props.css ├── anchor-duplicate-custom-props.css ├── anchor-inside-outside.css ├── anchor-manual.css ├── anchor-math.css ├── anchor-media-query.css ├── anchor-name-custom-prop.css ├── anchor-name-list.css ├── anchor-popover.css ├── anchor-positioning.css ├── anchor-pseudo-element.css ├── anchor-scope.css ├── anchor-scroll.css ├── anchor-size-extended.css ├── anchor-size.css ├── anchor-update.css ├── anchor.css ├── demo.css ├── import-has-import.css ├── import-is-imported-string.css ├── import-is-imported-url.css ├── mail.svg ├── position-anchor.css ├── position-area-page.css ├── position-area.css ├── position-try-tactics-combined.css ├── position-try-tactics.css └── position-try.css ├── src ├── @types │ ├── css-tree.d.ts │ └── global.d.ts ├── cascade.ts ├── dom.ts ├── fallback.ts ├── fetch.ts ├── index-fn.ts ├── index-wpt.ts ├── index.ts ├── parse.ts ├── polyfill.ts ├── position-area.ts ├── syntax.ts ├── transform.ts ├── utils.ts └── validate.ts ├── tests ├── e2e │ ├── polyfill.test.ts │ ├── position-area.test.ts │ ├── utils.ts │ └── validate.test.ts ├── helpers.ts ├── report.liquid ├── report.ts ├── runner.html ├── tsconfig.json ├── unit │ ├── __snapshots__ │ │ └── position-area.test.ts.snap │ ├── cascade.test.ts │ ├── fallback.test.ts │ ├── fetch.test.ts │ ├── parse.test.ts │ ├── polyfill.test.ts │ ├── position-area.test.ts │ ├── setup.ts │ ├── transform.test.ts │ └── utils.test.ts └── wpt.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | Include relevant parts of the [spec](https://drafts.csswg.org/css-anchor-position-1/), and a clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Browsers tested on** 27 | Include OS, Browser, Browser Version, and device (if mobile). 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to how anchor positioning works in general?** 10 | If the request is for changes to the [spec](https://drafts.csswg.org/css-anchor-position-1/), consider opening a request in the [CSS Working Group](https://github.com/w3c/csswg-drafts/issues?q=is:issue+is:open+%5Bcss-anchor-position%5D) repo. 11 | 12 | **Is your feature request related to how this polyfill works? Please describe.** 13 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 14 | 15 | **Describe the solution you'd like** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: '/' 6 | schedule: 7 | interval: weekly 8 | time: '04:00' 9 | timezone: America/New_York 10 | 11 | - package-ecosystem: npm 12 | directory: '/' 13 | versioning-strategy: increase 14 | schedule: 15 | interval: monthly 16 | time: '04:00' 17 | timezone: America/New_York 18 | groups: 19 | prod: 20 | dependency-type: production 21 | dev: 22 | dependency-type: development 23 | ignore: 24 | - dependency-name: '@playwright/test' 25 | -------------------------------------------------------------------------------- /.github/workflows/test-wpt.yml: -------------------------------------------------------------------------------- 1 | name: Run WPT 2 | 3 | on: 4 | workflow_dispatch: # Allow running on-demand 5 | # push: 6 | # pull_request: 7 | # types: [opened, reopened] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} 15 | BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 16 | WPT_MANIFEST: ${{ github.workspace }}/wpt/MANIFEST.json 17 | WPT_REPO: web-platform-tests/wpt 18 | WPT_REF: master 19 | SOURCE_REPO: ${{ github.repository }} 20 | SOURCE_COMMIT: ${{ github.sha }} 21 | SOURCE_BRANCH: ${{ github.ref_name }} 22 | RESULTS_BRANCH: ${{ github.ref_name }}--wpt-results 23 | 24 | jobs: 25 | test: 26 | name: Run WPT 27 | runs-on: ubuntu-latest 28 | if: github.event_name == 'push' || github.event.action == 'reopened' 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Check if WPT results branch exists 32 | id: results-branch-exists 33 | run: echo "EXISTS=$(git ls-remote --heads origin $RESULTS_BRANCH | wc -l)" >> $GITHUB_OUTPUT 34 | - name: Create WPT results branch 35 | if: steps.results-branch-exists.outputs.EXISTS == '0' 36 | run: | 37 | git checkout --orphan $RESULTS_BRANCH 38 | git rm -rf . 39 | mkdir test-results 40 | touch test-results/history.html 41 | git add . 42 | git config user.name github-actions 43 | git config user.email github-actions@github.com 44 | git commit -m "Initial WPT history commit" 45 | git push origin $RESULTS_BRANCH 46 | git switch ${{ github.ref_name }} 47 | - name: Clone WPT results branch 48 | uses: actions/checkout@v4 49 | with: 50 | ref: ${{ env.RESULTS_BRANCH }} 51 | path: wpt-results 52 | - uses: actions/setup-node@v4 53 | with: 54 | node-version-file: .nvmrc 55 | cache: npm 56 | - uses: actions/setup-python@v5 57 | with: 58 | python-version: '3.x' 59 | - name: Clone WPT repo 60 | uses: actions/checkout@v4 61 | with: 62 | repository: ${{ env.WPT_REPO }} 63 | ref: ${{ env.WPT_REF }} 64 | path: wpt 65 | - name: Get WPT commit SHA 66 | run: echo "WPT_COMMIT=$(cd wpt; git rev-parse HEAD)" >> $GITHUB_ENV 67 | 68 | - name: Build polyfill 69 | run: | 70 | npm install 71 | npm run build:wpt 72 | 73 | - name: Setup WPT 74 | run: | 75 | cd wpt 76 | pip install virtualenv 77 | ./wpt make-hosts-file | sudo tee -a /etc/hosts 78 | - name: Run Tests 79 | run: | 80 | cd tests 81 | python3 -m http.server 9606 & 82 | cd ../wpt 83 | ./wpt manifest 84 | ./wpt serve --inject-script=${{ github.workspace }}/dist/css-anchor-positioning-wpt.umd.cjs & 85 | cd .. 86 | cp -r wpt-results/test-results . 87 | npm run test:wpt 88 | 89 | - name: Push to WPT results branch 90 | run: | 91 | rsync -a test-results/ wpt-results/test-results/ 92 | cd wpt-results 93 | git add test-results 94 | git config user.name github-actions 95 | git config user.email github-actions@github.com 96 | git commit -m "WPT results from https://github.com/${{ github.repository }}/commit/${{ github.sha }}" 97 | git push origin $RESULTS_BRANCH 98 | 99 | link-to-report: 100 | name: Link to report 101 | runs-on: ubuntu-latest 102 | if: github.event.action == 'opened' 103 | steps: 104 | - name: Link to WPT report (only on first run) 105 | continue-on-error: true 106 | env: 107 | GH_TOKEN: ${{ github.token }} 108 | run: > 109 | gh --repo ${{ github.repository }} 110 | pr comment ${{ github.event.pull_request.number }} 111 | --body "WPT report for this branch: https://${{ github.head_ref }}-wpt-results--anchor-position-wpt.netlify.app" 112 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | test: 13 | name: Test 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version-file: .nvmrc 20 | cache: npm 21 | - name: Install dependencies 22 | run: | 23 | npm install 24 | npx playwright install --with-deps 25 | - run: npm run test:ci 26 | 27 | lint: 28 | name: Lint 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version-file: .nvmrc 35 | cache: npm 36 | - name: Lint 37 | run: | 38 | npm install 39 | npm run lint:ci 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Test coverage/reports 22 | .nyc_output 23 | .coverage 24 | coverage 25 | *.lcov 26 | playwright-report 27 | test-results 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn files 70 | .yarn-integrity 71 | .pnp.* 72 | .yarn/* 73 | !.yarn/patches 74 | !.yarn/plugins 75 | !.yarn/releases 76 | !.yarn/sdks 77 | !.yarn/versions 78 | 79 | # dotenv environment variables file 80 | .env 81 | .env.test 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | 86 | # Next.js build output 87 | .next 88 | 89 | # Nuxt.js build / generate output 90 | .nuxt 91 | dist 92 | 93 | # Gatsby files 94 | .cache/ 95 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 96 | # https://nextjs.org/blog/next-9-1#public-directory-support 97 | # public 98 | 99 | # vuepress build output 100 | .vuepress/dist 101 | 102 | # Serverless directories 103 | .serverless/ 104 | 105 | # FuseBox cache 106 | .fusebox/ 107 | 108 | # DynamoDB Local files 109 | .dynamodb/ 110 | 111 | # TernJS port file 112 | .tern-port 113 | 114 | *.DS_Store 115 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | !.* 2 | .git/ 3 | .pnp.* 4 | .vscode/ 5 | .yarn/ 6 | .yarnrc.yml 7 | coverage/ 8 | dist/ 9 | node_modules/ 10 | playwright-report/ 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | !.* 2 | .git/ 3 | .pnp.* 4 | .vscode/ 5 | .yarn/ 6 | .yarnrc.yml 7 | coverage/ 8 | dist/ 9 | node_modules/ 10 | playwright-report/ 11 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "selector-type-no-unknown": null, 5 | "at-rule-descriptor-value-no-unknown": null, 6 | "at-rule-no-unknown": [true, { "ignoreAtRules": ["position-try"] }], 7 | "declaration-block-no-redundant-longhand-properties": null, 8 | "declaration-property-value-no-unknown": [ 9 | true, 10 | { 11 | "ignoreProperties": { 12 | "/.+/": ["/^anchor\\(/", "/^anchor-size\\(/"] 13 | } 14 | } 15 | ], 16 | "function-no-unknown": [ 17 | true, 18 | { "ignoreFunctions": ["anchor", "anchor-size"] } 19 | ], 20 | "property-no-unknown": [ 21 | true, 22 | { 23 | "ignoreProperties": [ 24 | "anchor-name", 25 | "position-anchor", 26 | "position-try-fallbacks", 27 | "position-try" 28 | ] 29 | } 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [80] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CSS Anchor Positioning Polyfill Changelog 2 | 3 | See the [releases page](https://github.com/oddbird/css-anchor-positioning/releases). 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As a company, we want to embrace the very differences that have made 4 | our collaboration successful, and work together to provide the best 5 | environment for learning, growing, working, and sharing ideas. It is 6 | imperative that OddBird continue to be a welcoming, challenging, fun, 7 | and fair place to work. 8 | 9 | OddBird is dedicated to providing a harassment-free environment for 10 | everyone – regardless of gender, gender identity and expression, sexual 11 | orientation, disability, physical appearance, body size, age, race, or 12 | religion. We do not tolerate harassment in any form. If you are being 13 | harassed by an OddBird contributor, notice that someone else is being 14 | harassed, or have any other concerns, please contact the owners: 15 | 16 | - All: 17 | - Carl Meyer: 18 | - Jonny Gerig Meyer: 19 | - Miriam Suzanne: 20 | 21 | ## Contributors strive to: 22 | 23 | - **Be welcoming, kind, and helpful** 24 | - **Be collaborative, open, and transparent** 25 | - **Take responsibility for our words and actions** 26 | - **Look out for each other** 27 | 28 | ## Scope 29 | 30 | This document and related procedures apply to behavior occurring inside 31 | or outside the scope of OddBird activities, online or in-person, in 32 | public, at work, in one-on-one communications, and anywhere such 33 | behavior has the potential to adversely affect the safety and well-being 34 | of OddBird contributors. Any OddBird contributor who violates this code 35 | of conduct may be sanctioned, removed from the team, or expelled from 36 | OddBird community spaces and activities at the discretion of the owners. 37 | 38 | If you are being harassed by an OddBird contributor outside our work 39 | environment, we still want to know about it. We will take all good-faith 40 | reports of harassment by OddBird contributors, especially the owners, 41 | seriously. This includes harassment outside our spaces, and harassment 42 | that took place at any point in time. We reserve the right to exclude 43 | people from OddBird spaces and activities based on their past behavior, 44 | including behavior outside OddBird spaces, and behavior towards people 45 | who are not OddBird contributors. 46 | 47 | OddBird contributors include owners, contractors, clients, open source 48 | contributors, and anyone participating in OddBird spaces or activities. 49 | 50 | ## Harassment includes: 51 | 52 | - Derogatory, unwelcome, or discriminatory comments related to gender, 53 | gender identity and expression, sexual orientation, disability, 54 | mental illness, neuro(a)typicality, physical appearance, body size, 55 | age, race, or religion. 56 | - Repeated unwelcome comments regarding a person’s lifestyle choices 57 | and practices, including but not limited to topics like food, 58 | health, parenting, relationships, geographic locations, drugs, and 59 | employment. 60 | - Deliberate misgendering or use of ‘dead’ or rejected names. 61 | - Gratuitous or off-topic sexual images or behavior in spaces where 62 | they are not appropriate. 63 | - Physical contact and simulated physical contact (eg, textual 64 | descriptions like “`*hug*`” or “`*backrub*`”) without consent or 65 | after a request to stop. 66 | - Threats of violence. 67 | - Incitement of violence towards any individual, including encouraging 68 | a person to commit suicide or to engage in self-harm. 69 | - Deliberate intimidation. 70 | - Stalking or following. 71 | - Harassing photography or recording, including logging online 72 | activity for harassment purposes. 73 | - Sustained disruption of discussion. 74 | - Unwelcome sexual attention. 75 | - Continued one-on-one communication after requests to cease. 76 | - Deliberate “outing” of any aspect of a person’s identity without 77 | their consent – except as necessary to protect vulnerable people 78 | from intentional abuse. 79 | - Publication of non-harassing private communication. 80 | 81 | ## Exclusions 82 | 83 | OddBird prioritizes marginalized people’s safety over privileged 84 | people’s comfort. The owners will not act on complaints regarding: 85 | 86 | - ‘Reverse’ -isms, including ‘reverse racism,’ ‘reverse sexism,’ and 87 | ‘cisphobia’ 88 | - Reasonable communication of boundaries, such as “leave me alone,” 89 | “go away,” or “I’m not discussing this with you.” 90 | - Communicating in a ‘tone’ you don’t find congenial 91 | - Criticizing racist, sexist, cissexist, or otherwise oppressive 92 | behavior or assumptions 93 | 94 | ## Reporting 95 | 96 | If you are being harassed by an OddBird contributor, notice that someone 97 | else is being harassed, or have any other concerns, please contact the 98 | owners: 99 | 100 | - All: 101 | - Carl Meyer: 102 | - Jonny Gerig Meyer: 103 | - Miriam Suzanne: 104 | 105 | If the person who is harassing you is one of the owners, that owner will 106 | recuse themselves from handling your incident. We will respond as 107 | promptly as we can. 108 | 109 | In order to protect this policy from abuse, we reserve the right to 110 | reject any report we believe to have been made in bad faith. Reports 111 | intended to silence legitimate criticism may be deleted without 112 | response. 113 | 114 | We will respect confidentiality requests for the purpose of protecting 115 | victims of abuse. At our discretion, we may publicly name a person about 116 | whom we’ve received harassment complaints, or privately warn third 117 | parties about them, if we believe that doing so will increase the safety 118 | of OddBird contributors or the general public. We will not name 119 | harassment victims without their affirmative consent. 120 | 121 | ## Consequences 122 | 123 | OddBird contributors asked to stop any harassing behavior are expected 124 | to comply immediately. If a participant engages in harassing behavior, 125 | the owners may take any action they deem appropriate, up to and 126 | including expulsion from all OddBird spaces and activities, as well as 127 | identification of the participant as a harasser to other OddBird contributors 128 | or the general public. 129 | 130 | The OddBird owners will be happy to help participants contact any 131 | relevant security or law enforcement officials, provide escorts, or 132 | otherwise assist any OddBird contributors experiencing harassment to 133 | feel safe for the duration of their interaction with our company. 134 | 135 | ## Attribution 136 | 137 | This anti-harassment policy is based on the example policy from the 138 | [Geek Feminism wiki], created by the Geek Feminism community, as well as 139 | the [Sass Community Guidelines], [Slack Developer Community Code of 140 | Conduct], and [FreeBSD Code of Conduct]. 141 | 142 | [Geek Feminism wiki]: https://geekfeminism.fandom.com/wiki/Community_anti-harassment 143 | [Sass Community Guidelines]: https://sass-lang.com/community-guidelines/ 144 | [Slack Developer Community Code of Conduct]: https://api.slack.com/community/code-of-conduct 145 | [FreeBSD Code of Conduct]: https://www.freebsd.org/internal/code-of-conduct/ 146 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the CSS Anchor Positioning Polyfill 2 | 3 | Ideas, issues, and pull-requests are welcome! 4 | 5 | - [**Github Issues**](https://github.com/oddbird/css-anchor-positioning/issues/) 6 | are the best place to request a feature, file a bug, or just ask a question. 7 | Also a great place to discuss possible features before you submit a PR. 8 | - **Pull Requests** are a big help, if you're willing to jump in and make things 9 | happen. For a bugfix, or documentation, just jump right in. For major changes 10 | or new features, it's best to discuss in an issue first. 11 | 12 | ## Conduct 13 | 14 | Please follow the [OddBird Code of Conduct](https://www.oddbird.net/conduct/). 15 | 16 | ## How it works 17 | 18 | At a high level, the CSS Anchor Positioning Polyfill parses all relevant CSS on 19 | the page, and finds any uses of CSS Anchor Positioning syntax (e.g. `anchor()`, 20 | `anchor-name`, `anchor-size()`, `position-try`, `@position-try`, etc.). It 21 | replaces each use of `anchor()` with a unique CSS custom property. It then 22 | determines pixel values for each CSS custom property based on the individual 23 | `anchor()` usage (that is, the anchor element, target element, anchor side, and 24 | inset property it's applied to), and sets those values on the root element of 25 | the document. This allows the CSS cascade to determine which styles actually 26 | apply to which elements on the page. 27 | 28 | ## Development 29 | 30 | - Clone the repository. 31 | - Install dependencies: `npm install`. 32 | - Start dev server: `npm run serve`. Visit `localhost:3000`. 33 | 34 | ## Code style 35 | 36 | JS code is formatted with prettier, and CSS is formatted with stylelint. 37 | 38 | - Lint: `npm run lint:ci` 39 | - Format & lint: `npm run lint` 40 | 41 | We recommend setting up your IDE to automatically format code for you. 42 | 43 | ## Testing 44 | 45 | Unit tests and end-to-end tests are available in the `tests/` folder. 46 | 47 | - Run all tests: `npm run test` 48 | - Run unit tests: `npm run test:unit` 49 | - Run end-to-end tests: 50 | - Configure Playwright (this step is only required once or when the version of 51 | `@playwright/test` changes in package.json): 52 | `npx playwright install --with-deps` 53 | - Run tests (Chromium only): `npm run test:e2e` 54 | - Run tests (Chromium, Firefox & Webkit): `npm run test:e2e:ci` 55 | 56 | ## Previewing Pull Requests 57 | 58 | Active pull requests on the polyfill can be tested using the built version 59 | hosted within the preview environment. 60 | 61 | > **IMPORTANT** 62 | > 63 | > These previews are ephemeral, and will stop working after the pull request is 64 | > merged. Do not use this for any purpose other than testing the pull request. 65 | 66 | All polyfills are located at the root of the preview environment. For instance, the CommonJS functional version for PR 123 would be available at 67 | https://deploy-preview-123--anchor-polyfill.netlify.app/css-anchor-positioning-fn.umd.cjs. 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022–2024 OddBird LLC 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSS Anchor Positioning Polyfill 2 | 3 | [![Build Status](https://github.com/oddbird/css-anchor-positioning/actions/workflows/test.yml/badge.svg)](https://github.com/oddbird/css-anchor-positioning/actions/workflows/test.yml) [![npm version](https://badge.fury.io/js/@oddbird%2Fcss-anchor-positioning.svg)](https://www.npmjs.com/package/@oddbird/css-anchor-positioning) [![Netlify Status](https://api.netlify.com/api/v1/badges/61a20096-7925-4775-99a9-b40a010197c0/deploy-status)](https://app.netlify.com/sites/anchor-polyfill/deploys) 4 | 5 | 6 | 7 | - [Demo](https://anchor-positioning.oddbird.net/) 8 | - [Draft Spec](https://drafts.csswg.org/css-anchor-position/) 9 | 10 | The CSS anchor positioning 11 | [specification](https://drafts.csswg.org/css-anchor-position/) defines anchor 12 | positioning, "where a positioned element can size and position itself relative 13 | to one or more 'anchor elements' elsewhere on the page." This CSS Anchor 14 | Positioning Polyfill supports and is based on this specification. 15 | 16 | ## Browser Support 17 | 18 | - Firefox 54+ (includes Android) 19 | - Chrome 51 - 124 (includes Android) 20 | - Edge 79 - 124 21 | - Safari 10+ (includes iOS) 22 | 23 | Anchor positioning was added to Chrome, Chrome Android, and Edge in Chromium 24 | 125, so the polyfill will not be applied to versions after 124. Some aspects of 25 | anchor positioning were shipped later in Chromium, meaning that they are not 26 | polyfilled and are not present in those versions. 27 | 28 | - `position-try-fallbacks` was added in 128 after being renamed from 29 | `position-try-order`. Use both `-fallbacks` and `-order` or the `position-try` 30 | shorthand to make sure all versions are covered. 31 | - `position-area` was added in 129. 32 | - `anchor-scope` was added in 131. 33 | 34 | ## Getting Started 35 | 36 | To use the polyfill, add this script tag to your document ``: 37 | 38 | ```js 39 | 44 | ``` 45 | 46 | If you want to manually apply the polyfill, you can instead import the 47 | `polyfill` function directly from the 48 | `@oddbird/css-anchor-positioning/dist/css-anchor-positioning-fn.js` file. 49 | 50 | For build tools such as Vite, Webpack, and Parcel, that will look like this: 51 | 52 | ```js 53 | import polyfill from '@oddbird/css-anchor-positioning/fn'; 54 | ``` 55 | 56 | The `polyfill` function returns a promise that resolves when the polyfill has 57 | been applied. 58 | 59 | You can view a more complete demo 60 | [here](https://anchor-positioning.oddbird.net/). 61 | 62 | ## Configuration 63 | 64 | The polyfill supports a small number of options. When using the default version 65 | of the polyfill that executes automatically, options can be set by setting the 66 | value of `window.ANCHOR_POSITIONING_POLYFILL_OPTIONS`. 67 | 68 | ```js 69 | 79 | ``` 80 | 81 | When manually applying the polyfill, options can be set by passing an object as 82 | an argument. 83 | 84 | ```js 85 | 96 | ``` 97 | 98 | ### elements 99 | 100 | type: `HTMLElements[]`, default: `undefined` 101 | 102 | If set, the polyfill will only be applied to the specified elements instead of 103 | to all styles. Any specified `` and ` 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | # If the $WPT_ONLY env var is set, only build branches that end with `--wpt-results` 3 | ignore = "if [[ ! $WPT_ONLY || $BRANCH == *--wpt-results ]]; then exit 1; else exit 0; fi" 4 | [[headers]] 5 | for = "/css-anchor-positioning*" # Only allow for polyfill files 6 | [headers.values] 7 | access-control-allow-origin = "*" 8 | content-type = "text/javascript" 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oddbird/css-anchor-positioning", 3 | "version": "0.6.0", 4 | "description": "Polyfill for the proposed CSS anchor positioning spec", 5 | "license": "BSD-3-Clause", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "author": "OddBird (oddbird.net)", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/oddbird/css-anchor-positioning.git" 13 | }, 14 | "bugs": "https://github.com/oddbird/css-anchor-positioning/issues", 15 | "homepage": "https://anchor-positioning.oddbird.net", 16 | "keywords": [ 17 | "css", 18 | "polyfill", 19 | "anchor-positioning" 20 | ], 21 | "type": "module", 22 | "main": "./dist/css-anchor-positioning.umd.cjs", 23 | "module": "./dist/css-anchor-positioning.js", 24 | "unpkg": "./dist/css-anchor-positioning.js", 25 | "types": "./dist/index.d.ts", 26 | "exports": { 27 | ".": { 28 | "types": "./dist/index.d.ts", 29 | "import": "./dist/css-anchor-positioning.js", 30 | "require": "./dist/css-anchor-positioning.umd.cjs" 31 | }, 32 | "./fn": { 33 | "types": "./dist/index-fn.d.ts", 34 | "import": "./dist/css-anchor-positioning-fn.js", 35 | "require": "./dist/css-anchor-positioning-fn.umd.cjs" 36 | } 37 | }, 38 | "typesVersions": { 39 | "*": { 40 | "fn": [ 41 | "./dist/index-fn.d.ts" 42 | ] 43 | } 44 | }, 45 | "files": [ 46 | "README.md", 47 | "src/**/*.ts", 48 | "dist/**/*.{ts,js,cjs,map}", 49 | "package.json" 50 | ], 51 | "scripts": { 52 | "build": "run-s build:demo build:dist build:fn", 53 | "build:dist": "vite build", 54 | "build:fn": "cross-env BUILD_FN=1 vite build", 55 | "build:wpt": "cross-env BUILD_WPT=1 vite build", 56 | "build:demo": "cross-env BUILD_DEMO=1 vite build", 57 | "preview": "vite preview", 58 | "serve": "vite dev", 59 | "tsc": "tsc --noEmit", 60 | "tsc:tests": "tsc --project tests/tsconfig.json", 61 | "types": "tsc --emitDeclarationOnly", 62 | "prettier:check": "prettier --check .", 63 | "prettier:fix": "prettier --write .", 64 | "eslint:check": "eslint .", 65 | "eslint:fix": "npm run eslint:check -- --fix", 66 | "format:css": "npm run lint:css -- --fix", 67 | "format:js": "run-s prettier:fix eslint:fix tsc tsc:tests", 68 | "lint": "run-s format:css format:js", 69 | "lint:css": "stylelint \"**/*.css\"", 70 | "lint:js": "run-s prettier:check eslint:check tsc tsc:tests", 71 | "lint:ci": "run-p lint:css lint:js", 72 | "prepack": "run-s build types", 73 | "test:unit": "vitest", 74 | "test:watch": "npm run test:unit -- --watch", 75 | "test:e2e": "playwright test tests/e2e/", 76 | "test:e2e:ci": "npm run test:e2e -- --browser=all", 77 | "test": "run-p test:unit test:e2e", 78 | "test:ci": "run-p test:unit test:e2e:ci", 79 | "test:wpt": "node --loader ts-node/esm ./tests/wpt.ts" 80 | }, 81 | "dependencies": { 82 | "@floating-ui/dom": "^1.7.1", 83 | "@types/css-tree": "^2.3.10", 84 | "css-tree": "^3.1.0", 85 | "nanoid": "^5.1.5" 86 | }, 87 | "devDependencies": { 88 | "@eslint/js": "^9.28.0", 89 | "@playwright/test": "1.43.1", 90 | "@rollup/plugin-replace": "^6.0.2", 91 | "@types/async": "^3.2.24", 92 | "@types/node": "*", 93 | "@types/selenium-webdriver": "^4.1.28", 94 | "@vitest/coverage-istanbul": "^3.2.0", 95 | "@vitest/eslint-plugin": "^1.2.1", 96 | "async": "^3.2.6", 97 | "browserslist": "^4.25.0", 98 | "browserstack-local": "^1.5.6", 99 | "cross-env": "^7.0.3", 100 | "eslint": "^9.28.0", 101 | "eslint-config-prettier": "^10.1.5", 102 | "eslint-import-resolver-typescript": "^4.4.2", 103 | "eslint-plugin-import": "^2.31.0", 104 | "eslint-plugin-simple-import-sort": "^12.1.1", 105 | "fetch-mock": "^12.5.2", 106 | "jsdom": "^26.1.0", 107 | "liquidjs": "^10.21.1", 108 | "npm-run-all": "^4.1.5", 109 | "prettier": "^3.5.3", 110 | "rollup-plugin-bundle-stats": "^4.20.2", 111 | "selenium-webdriver": "^4.33.0", 112 | "stylelint": "^16.20.0", 113 | "stylelint-config-standard": "^38.0.0", 114 | "ts-node": "^10.9.2", 115 | "typescript": "^5.8.3", 116 | "typescript-eslint": "^8.33.1", 117 | "vite": "^6.3.5", 118 | "vitest": "^3.2.0" 119 | }, 120 | "sideEffects": [ 121 | "./src/index.ts", 122 | "./dist/css-anchor-positioning.js", 123 | "./dist/css-anchor-positioning.umd.cjs" 124 | ] 125 | } 126 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | timeout: process.env.CI ? undefined : 10 * 1000, // Max execution time of any single test 3 | expect: { 4 | timeout: 10 * 1000, // Max execution time of single expect() calls 5 | }, 6 | reporter: 'dot', 7 | fullyParallel: true, 8 | webServer: { 9 | command: 'npm run serve -- --port 4000 -l warn', 10 | url: 'http://localhost:4000/', 11 | timeout: 10 * 1000, 12 | reuseExistingServer: !process.env.CI, 13 | }, 14 | use: { 15 | baseURL: 'http://localhost:4000/', 16 | browserName: 'chromium', 17 | headless: true, 18 | forbidOnly: process.env.CI, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /position-area.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CSS Anchor Positioning Polyfill 8 | 12 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | 65 | 66 | 67 | 68 | 73 | 74 | 75 |
76 |

CSS Anchor Positioning Polyfill

77 | 98 | 101 |
102 |
103 |

Placing elements with position-area

104 |

105 | The position-area property places an element in relation to 106 | its anchor, using 1 or 2 keywords to define the position on each axis. 107 |

108 |

109 | In browsers that support position-area, this creates a new 110 | containing block for the positioned element based on the position of the 111 | anchor. The polyfill achieves this by wrapping the positioned element in 112 | a <POLYFILL-POSITION-AREA> element. Be aware that 113 | this may impact selectors that target the positioned element using 114 | direct child or sibling selectors. 115 |

116 | 119 |

120 | This approach also causes some differences with content that is shifted 121 | to stay within its containing block in supporting browsers. 122 |

123 |

124 | Note: We strive to keep the polyfill up-to-date with 125 | ongoing changes to the spec, and we welcome 126 | code contributions 132 | and financial support to make that happen. 133 |

134 |
135 | 136 |
137 |

138 | 139 | bottom center ✅ 140 |

141 |
142 |
Anchor
143 |
Target
144 |
145 |
146 | 147 |
148 |

149 | 150 | span-left top ✅ 151 |

152 |
153 |
Anchor
154 |
Target with longer content
155 |
156 |
157 | 158 |
159 |

160 | 161 | center left ✅ 162 |

163 |
164 |
Anchor
165 |
Target
166 |
167 |
168 | 169 |
170 |

171 | 172 | inline-start block-end ✅ 173 |

174 |
175 |
Anchor
176 |
Target
177 |
178 |
179 | 180 |
181 |

182 | 183 | start end ✅ 184 |

185 |
186 |
Anchor
187 |
Target
188 |
189 |
190 | 191 |
192 |

193 | 194 | end ✅ 195 |

196 |
197 |
Anchor
198 |
Target
199 |
200 |
201 | 202 |
203 |

204 | 205 | no space around anchor, end start ✅ 206 |

207 |
208 |
Anchor
209 |
Target
210 |
211 |
212 | 213 |
214 |

215 | 216 | no block space around anchor, span-all top ✅ 217 |

218 |
219 |
Anchor
220 |
221 | Target with longer content that might line wrap 222 |
223 |
224 |
225 | 226 |
227 |

228 | 229 | inline styles ✅ 230 |

231 |
232 |
Anchor
233 |
Target
234 |
235 |
236 | 237 |
238 |

239 | 240 | One declaration, different containing blocks ✅ 241 |

242 |
247 |
Anchor
248 |
Target
249 |
250 |
255 |
Anchor
256 |
Target
257 |
258 |

259 | Anchor inset values need to be different for each element matching a 260 | declaration, as their values depend on their containing blocks. Should 261 | be `right bottom`. 262 |

263 |
264 | 265 |
266 |

267 | 268 | cascade should be respected ✅ 269 |

270 |
271 |
Anchor
272 |
Target
273 |
274 |

275 | Should be right top. Also has 276 | right bottom applied, with less specificity. When the 277 | cascaded value changes, the positioned element will only update to 278 | reflect the changes the next time the polyfill recalculates the 279 | positions, for instance, on scroll. 280 | 281 |

282 |
283 | 284 |
285 |

286 | 287 | Logical properties and writing mode support 288 |

289 |
294 |
Anchor
295 |
vertical-rl rtl
296 |
297 |
301 |
Anchor
302 |
vertical-rl ltr
303 |
304 |
308 |
Anchor
309 |
vertical-lr rtl
310 |
311 |
315 |
Anchor
316 |
vertical-lr ltr
317 |
318 |
322 |
Anchor
323 |
sideways-rl rtl
324 |
325 |
329 |
Anchor
330 |
sideways-rl ltr
331 |
332 |
336 |
Anchor
337 |
sideways-lr rtl
338 |
339 |
343 |
Anchor
344 |
sideways-lr ltr
345 |
346 |
347 |
348 |

349 | 350 | Logical properties and writing mode support for self 351 |

352 |
353 |
Anchor
354 |
358 | vertical-rl rtl 359 |
360 |
361 |
362 |
Anchor
363 |
367 | vertical-rl ltr 368 |
369 |
370 |
371 |
Anchor
372 |
376 | vertical-lr rtl 377 |
378 |
379 |
380 |
Anchor
381 |
385 | vertical-lr ltr 386 |
387 |
388 |
389 |
Anchor
390 |
394 | sideways-rl rtl 395 |
396 |
397 |
398 |
Anchor
399 |
403 | sideways-rl ltr 404 |
405 |
406 |
407 |
Anchor
408 |
412 | sideways-lr rtl 413 |
414 |
415 |
416 |
Anchor
417 |
421 | sideways-lr ltr 422 |
423 |
424 |
425 | 426 |
427 |

428 | 429 | no block space around anchor, span-all left ❓ 430 |

431 |
432 |
Anchor
433 |
437 | Target with longer content that might line wrap 438 |
439 |
440 |

441 | If the target overflows the containing block, and the target is shifted 442 | to the start of the containing block in browsers that support CSS anchor 443 | positioning, you can approximate the same behavior by adding an inline 444 | style to add safe overflow alignment for the impacted axis. 445 | There is not a similar solution for content that supporting browsers 446 | shift to the end of the containing block. 447 |

448 | style="align-self: var(--position-area-align-self) safe" 449 |
450 | 451 |
452 |

453 | 454 | Shifting content to stay inside containing block ❌ 455 |

456 |
457 |
458 |
start
459 |
Target
460 |
461 |
462 |
center
463 |
Target
464 |
465 |
466 |
end
467 |
Target
468 |
469 |
470 |

Targets should get shifted to stay within the containing block.

471 |
472 | 473 |
474 |

475 | 476 | span-all positioned correctly on non-centered anchors ❌ 477 |

478 |
479 |
480 |
start
481 |
Target
482 |
483 |
484 |

Should be positioned on anchor-center, not center.

485 |
486 | 487 | 510 | 527 | 528 | 529 | -------------------------------------------------------------------------------- /public/anchor-absolute.css: -------------------------------------------------------------------------------- 1 | #my-anchor-absolute-one, 2 | #my-anchor-absolute-two { 3 | position: absolute; 4 | anchor-name: --my-anchor-absolute; 5 | } 6 | 7 | #my-anchor-absolute-two { 8 | top: 100px; 9 | left: 200px; 10 | } 11 | 12 | #my-target-absolute-one, 13 | #my-target-absolute-two { 14 | position: absolute; 15 | top: anchor(--my-anchor-absolute bottom); 16 | left: anchor(--my-anchor-absolute right); 17 | } 18 | -------------------------------------------------------------------------------- /public/anchor-custom-props.css: -------------------------------------------------------------------------------- 1 | #my-anchor-props { 2 | anchor-name: --my-anchor-props; 3 | } 4 | 5 | #my-target-props { 6 | --half: anchor(--my-anchor-props 50%); 7 | --quarter: calc(var(--half) / 2); 8 | --tenth: calc(var(--quarter) / 2.5); 9 | 10 | position: absolute; 11 | left: var(--half); 12 | bottom: var(--tenth); 13 | } 14 | -------------------------------------------------------------------------------- /public/anchor-duplicate-custom-props.css: -------------------------------------------------------------------------------- 1 | #anchor-duplicate-custom-props { 2 | anchor-name: --anchor-duplicate-custom-props; 3 | } 4 | 5 | #target-duplicate-custom-props { 6 | --center: anchor(--anchor-duplicate-custom-props 50%); 7 | 8 | position: absolute; 9 | top: var(--center); 10 | left: var(--center); 11 | } 12 | 13 | #other { 14 | --center: anchor(--anchor-duplicate-custom-props 100%); 15 | } 16 | -------------------------------------------------------------------------------- /public/anchor-inside-outside.css: -------------------------------------------------------------------------------- 1 | #inside-outside .anchor { 2 | anchor-name: --inside-outside; 3 | } 4 | 5 | #inside-outside .target { 6 | position: absolute; 7 | position-anchor: --inside-outside; 8 | left: anchor(outside); 9 | bottom: anchor(inside); 10 | padding: 1.5em; 11 | } 12 | -------------------------------------------------------------------------------- /public/anchor-manual.css: -------------------------------------------------------------------------------- 1 | #my-target-manual-link-el { 2 | position: absolute; 3 | bottom: anchor(--my-anchor-manual top); 4 | left: anchor(--my-anchor-manual right); 5 | } 6 | -------------------------------------------------------------------------------- /public/anchor-math.css: -------------------------------------------------------------------------------- 1 | #my-anchor-math { 2 | anchor-name: --my-anchor-math; 3 | } 4 | 5 | #my-target-math { 6 | --full-math: anchor(--my-anchor-math 100%); 7 | --full-minus-math: calc(anchor(--my-anchor-math 100%) - 10px); 8 | 9 | position: absolute; 10 | top: calc(var(--full-math) + 10px); 11 | left: var(--full-minus-math); 12 | } 13 | -------------------------------------------------------------------------------- /public/anchor-media-query.css: -------------------------------------------------------------------------------- 1 | @media print { 2 | #my-print-anchor-media-query { 3 | anchor-name: --my-anchor-media-query; 4 | } 5 | } 6 | 7 | #my-anchor-media-query { 8 | anchor-name: --my-anchor-media-query; 9 | } 10 | 11 | #my-target-media-query { 12 | position: absolute; 13 | top: anchor(--my-anchor-media-query top); 14 | right: anchor(--my-anchor-media-query right); 15 | } 16 | -------------------------------------------------------------------------------- /public/anchor-name-custom-prop.css: -------------------------------------------------------------------------------- 1 | #my-anchor-name-prop { 2 | anchor-name: --my-anchor-name-prop; 3 | } 4 | 5 | #my-target-name-prop { 6 | --anchor-var: --my-anchor-name-prop; 7 | 8 | position: absolute; 9 | right: anchor(var(--anchor-var) left); 10 | bottom: anchor(var(--anchor-var) top); 11 | } 12 | -------------------------------------------------------------------------------- /public/anchor-name-list.css: -------------------------------------------------------------------------------- 1 | #my-anchor-name-list { 2 | anchor-name: --my-anchor-name-a, --my-anchor-name-b; 3 | } 4 | 5 | #my-target-name-list-a { 6 | position: absolute; 7 | right: anchor(--my-anchor-name-a left); 8 | bottom: anchor(--my-anchor-name-a top); 9 | } 10 | 11 | #my-target-name-list-b { 12 | position: absolute; 13 | left: anchor(--my-anchor-name-b right); 14 | top: anchor(--my-anchor-name-b bottom); 15 | } 16 | -------------------------------------------------------------------------------- /public/anchor-popover.css: -------------------------------------------------------------------------------- 1 | #my-anchor-popover { 2 | position: absolute; 3 | left: 200px; 4 | anchor-name: --my-anchor-popover; 5 | } 6 | 7 | #my-target-popover { 8 | position: absolute; 9 | margin: 0; 10 | left: anchor(--my-anchor-popover right); 11 | top: anchor(--my-anchor-popover bottom); 12 | } 13 | -------------------------------------------------------------------------------- /public/anchor-positioning.css: -------------------------------------------------------------------------------- 1 | #my-anchor-positioning { 2 | anchor-name: --my-anchor-positioning; 3 | } 4 | 5 | #my-target-positioning { 6 | position: absolute; 7 | right: anchor(--my-anchor-positioning right, 50px); 8 | top: anchor(--my-anchor-positioning bottom); 9 | } 10 | -------------------------------------------------------------------------------- /public/anchor-pseudo-element.css: -------------------------------------------------------------------------------- 1 | #my-anchor-pseudo-element::before { 2 | content: '::before'; 3 | margin-right: 1ch; 4 | background-color: var(--pseudo-element); 5 | anchor-name: --my-anchor-pseudo-element; 6 | } 7 | 8 | #my-target-pseudo-element { 9 | position: absolute; 10 | position-anchor: --my-anchor-pseudo-element; 11 | top: anchor(bottom); 12 | left: anchor(right); 13 | } 14 | -------------------------------------------------------------------------------- /public/anchor-scope.css: -------------------------------------------------------------------------------- 1 | #anchor-scope li { 2 | display: flex; 3 | justify-content: space-between; 4 | anchor-scope: --anchor-scope; 5 | } 6 | 7 | #anchor-scope .arrow { 8 | anchor-name: --anchor-scope; 9 | block-size: 1em; 10 | inline-size: 1em; 11 | } 12 | 13 | #anchor-scope .target { 14 | position: absolute; 15 | position-anchor: --anchor-scope; 16 | left: anchor(right); 17 | top: anchor(center); 18 | } 19 | -------------------------------------------------------------------------------- /public/anchor-scroll.css: -------------------------------------------------------------------------------- 1 | .scroll-container { 2 | height: 400px; 3 | overflow: scroll; 4 | border: thin solid; 5 | position: relative; 6 | } 7 | 8 | .placefiller-above-anchor { 9 | height: 150px; 10 | } 11 | 12 | .placefiller-before-anchor { 13 | display: inline-block; 14 | width: 150px; 15 | } 16 | 17 | .placefiller-after-anchor { 18 | height: 1000px; 19 | width: 1000px; 20 | } 21 | 22 | #scroll-anchor { 23 | anchor-name: --scroll-anchor; 24 | } 25 | 26 | #inner-anchored { 27 | position: absolute; 28 | bottom: anchor(--scroll-anchor top); 29 | left: anchor(--scroll-anchor end); 30 | } 31 | 32 | #outer-anchored { 33 | position: absolute; 34 | top: anchor(--scroll-anchor bottom); 35 | right: anchor(--scroll-anchor start); 36 | } 37 | -------------------------------------------------------------------------------- /public/anchor-size-extended.css: -------------------------------------------------------------------------------- 1 | #my-anchor-anchor-size-extended { 2 | anchor-name: --my-anchor-size-extended; 3 | resize: both; 4 | overflow: hidden; 5 | } 6 | 7 | #my-target-anchor-size-extended { 8 | position: absolute; 9 | position-anchor: --my-anchor-size-extended; 10 | right: anchor-size(width); 11 | margin: anchor-size(height); 12 | } 13 | -------------------------------------------------------------------------------- /public/anchor-size.css: -------------------------------------------------------------------------------- 1 | #my-anchor-size { 2 | anchor-name: --my-anchor; 3 | width: 10em; 4 | } 5 | 6 | #my-target-size { 7 | position: absolute; 8 | width: anchor-size(--my-anchor width); 9 | } 10 | -------------------------------------------------------------------------------- /public/anchor-update.css: -------------------------------------------------------------------------------- 1 | #my-anchor-update { 2 | anchor-name: --my-anchor-update; 3 | } 4 | 5 | #my-anchor-update[data-small] { 6 | width: 100px; 7 | } 8 | 9 | #my-anchor-update[data-large] { 10 | width: 400px; 11 | } 12 | 13 | #my-target-update { 14 | position: absolute; 15 | right: anchor(--my-anchor-update right); 16 | top: anchor(--my-anchor-update bottom); 17 | } 18 | -------------------------------------------------------------------------------- /public/anchor.css: -------------------------------------------------------------------------------- 1 | #my-anchor { 2 | anchor-name: --my-anchor; 3 | } 4 | 5 | #my-target { 6 | --center: anchor(--my-anchor 50%); 7 | --full: anchor(--my-anchor 100%); 8 | 9 | position: absolute; 10 | top: var(--center); 11 | right: var(--full); 12 | } 13 | -------------------------------------------------------------------------------- /public/demo.css: -------------------------------------------------------------------------------- 1 | /* base styles */ 2 | * { 3 | box-sizing: border-box; 4 | } 5 | 6 | html { 7 | --brand-blue: lch(38.953% 23.391 229.55deg); 8 | --brand-pink: lch(50.161% 77.603 3.8969deg); 9 | --brand-pink-light: lch(from var(--brand-pink) calc(l + 8) c h); 10 | --brand-pink-dark: lch(from var(--brand-pink) calc(l - 20) c h); 11 | --brand-orange: lch(70.149% 72.526 55.336deg); 12 | --brand-orange-dark: lch(from var(--brand-orange) calc(l - 20) c h); 13 | --gray-1: lch(from var(--brand-blue) calc(l + 53) calc(c - 19) h); 14 | --gray-2: lch(from var(--brand-blue) calc(l + 14) calc(c - 19) h); 15 | --gray-3: lch(from var(--brand-blue) calc(l + 4) calc(c - 13) h); 16 | --gray-4: lch(from var(--brand-blue) calc(l - 13) calc(c - 13) h); 17 | --gray-5: lch(from var(--brand-blue) calc(l - 26) calc(c - 13) h); 18 | --action: var(--brand-pink-dark); 19 | --anchor-color: var(--brand-pink); 20 | --bg: white; 21 | --border: var(--gray-3); 22 | --button-border: var(--text); 23 | --text: var(--gray-5); 24 | --callout: lch(89% 7 229deg); 25 | --disabled-bg: var(--gray-3); 26 | --disabled-text: white; 27 | --target: var(--brand-blue); 28 | --link-focus: var(--text); 29 | --outer-anchored: var(--brand-orange-dark); 30 | --note-color: var(--target); 31 | --page-margin: calc(1rem + 4vw); 32 | --pseudo-element: var(--action); 33 | --font-serif: 34 | freight-text-pro, baskerville, palatino, cambria, georgia, serif; 35 | --font-sans: freight-sans-pro, 'Helvetica Neue', helvetica, arial, sans-serif; 36 | --font-mono: 37 | 'Dank Mono', 'Operator Mono', inconsolata, 'Fira Mono', ui-monospace, 38 | 'SF Mono', monaco, 'Droid Sans Mono', 'Source Code Pro', 'Cascadia Code', 39 | menlo, consolas, 'DejaVu Sans Mono', monospace; 40 | 41 | background-color: var(--bg); 42 | color: var(--text); 43 | font-family: var(--font-serif); 44 | font-size: calc(1em + 0.25vw); 45 | } 46 | 47 | @media (prefers-color-scheme: dark) { 48 | html { 49 | --action: var(--brand-pink-light); 50 | --bg: var(--gray-5); 51 | --border: var(--gray-2); 52 | --callout: var(--gray-4); 53 | --button-text: var(--text); 54 | --text: white; 55 | } 56 | } 57 | 58 | body { 59 | display: grid; 60 | grid-template-columns: 61 | [full-start] minmax(var(--page-margin), 1fr) 62 | [main-start] minmax(0, 75ch) [main-end] 63 | minmax(var(--page-margin), 1fr) [full-end]; 64 | margin: 0; 65 | overflow-x: hidden; 66 | } 67 | 68 | @media (width >= 30em) { 69 | body { 70 | --page-margin: calc(1.75rem + 4vw); 71 | } 72 | } 73 | 74 | /* layout */ 75 | 76 | header, 77 | footer { 78 | grid-column: full; 79 | } 80 | 81 | header { 82 | background-color: var(--bg); 83 | border-block-end: thin dotted var(--text); 84 | display: grid; 85 | grid-column: full; 86 | grid-template: 87 | 'title title' auto 88 | 'nav nav' auto 89 | 'button .' auto; 90 | padding: var(--header-padding, 1em); 91 | position: sticky; 92 | top: 0; 93 | z-index: 1; 94 | } 95 | 96 | @media (width >= 30em) { 97 | header { 98 | --header-padding: 1em 0; 99 | 100 | align-items: start; 101 | display: grid; 102 | gap: 0.5em; 103 | grid-template: 104 | '. title button .' auto 105 | '. nav button .' auto 106 | / var(--page-margin) minmax(min-content, 1fr) minmax(2rem, auto) var( 107 | --page-margin 108 | ); 109 | } 110 | 111 | [data-button] { 112 | justify-self: end; 113 | margin-block-start: 0.25lh; 114 | } 115 | } 116 | 117 | :where(h1, h2, h3, h4, h5, h6) { 118 | font-family: var(--font-sans); 119 | } 120 | 121 | h1 { 122 | font-size: calc(1.75rem + 1vw); 123 | grid-area: title; 124 | margin: 0; 125 | } 126 | 127 | nav { 128 | display: flex; 129 | flex-wrap: wrap; 130 | gap: 1ch; 131 | grid-area: nav; 132 | } 133 | 134 | section { 135 | grid-column: main; 136 | padding: 3em 1em; 137 | } 138 | 139 | footer { 140 | border-block-start: thin dotted var(--text); 141 | display: grid; 142 | grid-template-columns: minmax(0, 75ch); 143 | justify-content: center; 144 | padding: 2em 0; 145 | } 146 | 147 | footer p { 148 | padding-inline: 1em; 149 | } 150 | 151 | .demo-item { 152 | display: grid; 153 | gap: 3em; 154 | grid-template: 155 | 'heading heading heading' min-content 156 | '. elements .' min-content 157 | 'note note note' min-content 158 | 'code code code' min-content/minmax(0, 5rem) minmax(0, 1fr) minmax(0, 5rem); 159 | } 160 | 161 | /* stylelint-disable no-descending-specificity */ 162 | .demo-item h2 { 163 | grid-area: heading; 164 | } 165 | 166 | .demo-elements { 167 | grid-area: elements; 168 | font-family: var(--font-sans); 169 | } 170 | 171 | .note { 172 | background-color: var(--callout); 173 | border-inline-start: 0.5em solid var(--note-color); 174 | grid-area: note; 175 | margin: 2em 0 0; 176 | padding: 1em; 177 | } 178 | 179 | /* links & buttons */ 180 | 181 | /* prettier-ignore */ 182 | [href*='://']::after { 183 | content: ' ↗'; 184 | font-family: system-ui,-apple-system,'Segoe UI',Roboto,Ubuntu,Cantarell,'Noto Sans',sans-serif; 185 | } 186 | 187 | a:any-link { 188 | color: var(--action); 189 | } 190 | 191 | a:any-link:hover, 192 | a:any-link:focus { 193 | color: var(--link-focus); 194 | } 195 | 196 | [data-button] { 197 | background-color: var(--button-bg-color, var(--action)); 198 | border: medium solid var(--button-border, transparent); 199 | border-radius: 0.25em 0.5em; 200 | color: var(--button-text, var(--bg)); 201 | cursor: pointer; 202 | font: inherit; 203 | font-weight: bold; 204 | grid-area: button; 205 | max-width: max-content; 206 | padding: 0.5em 1em; 207 | } 208 | 209 | [data-button]:hover, 210 | [data-button]:focus, 211 | [data-button]:active { 212 | --button-bg-color: var(--bg); 213 | --button-text: var(--action); 214 | } 215 | 216 | [data-button]:disabled { 217 | --button-bg-color: var(--disabled-bg); 218 | --button-border: transparent; 219 | 220 | cursor: not-allowed; 221 | } 222 | 223 | [data-button]:disabled, 224 | [data-button]:disabled:hover { 225 | --button-text: var(--disabled-text); 226 | } 227 | 228 | /* heading links */ 229 | 230 | h2 { 231 | font-size: calc(1.5rem + 0.5vw); 232 | position: relative; 233 | } 234 | 235 | h2 [aria-hidden]:any-link { 236 | display: inline-block; 237 | filter: grayscale(var(--grayscale, 100%)); 238 | margin-block-start: -0.4em; 239 | padding: 0.4em; 240 | text-decoration: none; 241 | transform: scale(var(--scale, 0.75)); 242 | transition: all 200ms ease-in-out; 243 | } 244 | 245 | h2 [aria-hidden]:hover, 246 | h2 [aria-hidden]:focus, 247 | h2 [aria-hidden]:active { 248 | --grayscale: 0; 249 | --scale: 1; 250 | } 251 | 252 | @media (width >= 50em) { 253 | h2 [aria-hidden]:any-link { 254 | position: absolute; 255 | right: 100%; 256 | } 257 | } 258 | 259 | /* components */ 260 | 261 | .anchor, 262 | .target { 263 | background: var(--element-color); 264 | border: thin solid var(--border); 265 | border-radius: var(--radius-1); 266 | color: white; 267 | font-weight: bold; 268 | padding: 0.5em; 269 | white-space: nowrap; 270 | } 271 | 272 | .anchor { 273 | --element-color: var(--anchor-color); 274 | 275 | text-align: center; 276 | } 277 | 278 | .target { 279 | --element-color: var(--target, var(--outer-anchored)); 280 | } 281 | 282 | .outer-anchored { 283 | --element-color: var(--outer-anchored); 284 | } 285 | 286 | /* code samples */ 287 | pre { 288 | border: thin dotted var(--border); 289 | background-color: var(--callout); 290 | font-family: var(--font-mono); 291 | grid-area: code; 292 | overflow-x: auto; 293 | max-width: 100%; 294 | padding: 1em; 295 | } 296 | 297 | code { 298 | font-size: 90%; 299 | } 300 | 301 | code[class*='language-'], 302 | pre[class*='language-'] { 303 | font-size: 0.9em; 304 | } 305 | 306 | :not(pre) > code[class*='language-'], 307 | pre[class*='language-'] { 308 | background-color: lch(98% 2 269.6deg); 309 | } 310 | -------------------------------------------------------------------------------- /public/import-has-import.css: -------------------------------------------------------------------------------- 1 | @import url('./import-is-imported-url.css') supports(display: grid) screen and 2 | (min-width: 400px); 3 | /* stylelint-disable-next-line import-notation */ 4 | @import './import-is-imported-string.css'; 5 | 6 | #imports .anchor { 7 | anchor-name: --import-anchor; 8 | } 9 | 10 | #imports .anchor::after { 11 | content: url('./mail.svg'); 12 | } 13 | 14 | #imports .target { 15 | position: absolute; 16 | position-anchor: --import-anchor; 17 | } 18 | 19 | #imports #target-1 { 20 | position-area: end; 21 | } 22 | -------------------------------------------------------------------------------- /public/import-is-imported-string.css: -------------------------------------------------------------------------------- 1 | #imports .anchor { 2 | color: var(--brand-orange); 3 | } 4 | -------------------------------------------------------------------------------- /public/import-is-imported-url.css: -------------------------------------------------------------------------------- 1 | #imports #target-1 { 2 | color: var(--brand-orange); 3 | } 4 | 5 | #imports #target-2 { 6 | position-area: block-end inline-start; 7 | color: var(--brand-orange); 8 | } 9 | 10 | #imports .anchor::before { 11 | content: url('./mail.svg'); 12 | } 13 | -------------------------------------------------------------------------------- /public/mail.svg: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /public/position-anchor.css: -------------------------------------------------------------------------------- 1 | #position-anchor #my-position-target-b { 2 | position-anchor: --my-position-anchor-b; 3 | } 4 | 5 | #position-anchor .target { 6 | position: absolute; 7 | bottom: anchor(top); 8 | left: anchor(right); 9 | position-anchor: --my-position-anchor-a; 10 | } 11 | 12 | #my-position-anchor-a { 13 | anchor-name: --my-position-anchor-a; 14 | margin-bottom: 3em; 15 | } 16 | 17 | #my-position-anchor-b { 18 | anchor-name: --my-position-anchor-b; 19 | } 20 | -------------------------------------------------------------------------------- /public/position-area-page.css: -------------------------------------------------------------------------------- 1 | .demo-elements { 2 | height: 12em; 3 | place-content: center; 4 | place-items: center; 5 | display: flex; 6 | grid-area: initial; 7 | anchor-scope: --position-area-anchor; 8 | } 9 | 10 | .demo-elements.tight { 11 | block-size: initial; 12 | } 13 | 14 | .demo-elements.semi-tight { 15 | block-size: 4em; 16 | inline-size: 4em; 17 | } 18 | 19 | .demo-elements.shifting, 20 | .demo-elements.non-centered { 21 | border: 1pt dashed var(--brand-blue); 22 | position: relative; 23 | display: grid; 24 | height: 300px; 25 | width: 200px; 26 | padding: 0.3em; 27 | align-content: space-between; 28 | } 29 | 30 | .shifting .target { 31 | position-area: left center; 32 | block-size: 4em; 33 | opacity: 0.5; 34 | } 35 | 36 | .non-centered .target { 37 | position-area: left span-all; 38 | } 39 | 40 | .scope { 41 | anchor-scope: --position-area-anchor; 42 | } 43 | 44 | .position-area-demo-item { 45 | display: grid; 46 | gap: 1em; 47 | } 48 | 49 | .position-area-demo-item .anchor { 50 | anchor-name: --position-area-anchor; 51 | width: min-content; 52 | } 53 | 54 | .position-area-demo-item .target { 55 | position: absolute; 56 | position-anchor: --position-area-anchor; 57 | text-wrap: wrap; 58 | } 59 | 60 | .target.bottom-center { 61 | position-area: bottom center; 62 | padding-left: 50%; 63 | } 64 | 65 | .target.spanleft-top { 66 | padding-right: 50%; 67 | position-area: span-left top; 68 | } 69 | 70 | .target.spanall-left { 71 | position-area: span-all left; 72 | } 73 | 74 | .target.spanall-top { 75 | position-area: span-all top; 76 | } 77 | 78 | .target.center-left { 79 | position-area: center left; 80 | } 81 | 82 | .target.inlinestart-blockend { 83 | position-area: inline-start block-end; 84 | } 85 | 86 | .target.start-end { 87 | position-area: start end; 88 | } 89 | 90 | .target.end-start { 91 | position-area: end start; 92 | } 93 | 94 | .target.end { 95 | position-area: end; 96 | } 97 | 98 | #writing-mode .demo-elements, 99 | #writing-mode-self .demo-elements { 100 | position: relative; 101 | } 102 | 103 | .target.logical-end { 104 | position-area: end; 105 | } 106 | 107 | .target.logical-self-end { 108 | position-area: self-block-end self-inline-end; 109 | } 110 | 111 | .target.shared-right-bottom { 112 | position-area: right bottom; 113 | } 114 | 115 | #cascade { 116 | position: relative; 117 | } 118 | 119 | #cascade-target { 120 | position-area: right top; 121 | } 122 | 123 | .cascade .target, 124 | .cascade-override #cascade-target { 125 | position-area: right bottom; 126 | } 127 | 128 | .show-wrapper polyfill-position-area { 129 | background: repeating-linear-gradient(45deg, #ccc 0 5px, transparent 0 10px); 130 | outline: 1pt dotted var(--brand-orange); 131 | } 132 | -------------------------------------------------------------------------------- /public/position-area.css: -------------------------------------------------------------------------------- 1 | #position-area .anchor { 2 | anchor-name: --position-area-anchor-a; 3 | } 4 | 5 | #position-area .target { 6 | position: absolute; 7 | position-area: top right; 8 | position-anchor: --position-area-anchor-a; 9 | } 10 | -------------------------------------------------------------------------------- /public/position-try-tactics-combined.css: -------------------------------------------------------------------------------- 1 | #my-anchor-try-tactics-combined { 2 | anchor-name: --my-anchor-try-tactics-combined; 3 | } 4 | 5 | #my-target-try-tactics-combined { 6 | position: absolute; 7 | position-anchor: --my-anchor-try-tactics-combined; 8 | bottom: anchor(top); 9 | left: anchor(right); 10 | position-try: 11 | flip-block flip-inline, 12 | flip-inline --bottom-left-combined; 13 | } 14 | 15 | @position-try --bottom-left-combined { 16 | top: anchor(bottom); 17 | left: anchor(left); 18 | bottom: revert; 19 | } 20 | -------------------------------------------------------------------------------- /public/position-try-tactics.css: -------------------------------------------------------------------------------- 1 | #my-anchor-try-tactics { 2 | anchor-name: --my-anchor-try-tactics; 3 | } 4 | 5 | #my-anchor-try-tactics-2 { 6 | anchor-name: --my-anchor-try-tactics-2; 7 | top: 5em; 8 | left: 5em; 9 | position: relative; 10 | } 11 | 12 | #my-target-try-tactics { 13 | position-anchor: --my-anchor-try-tactics; 14 | } 15 | 16 | #my-target-try-tactics-2 { 17 | position-anchor: --my-anchor-try-tactics-2; 18 | } 19 | 20 | #my-target-try-tactics, 21 | #my-target-try-tactics-2 { 22 | position: absolute; 23 | bottom: anchor(top); 24 | left: anchor(right); 25 | position-try-options: flip-block, flip-inline, flip-start; 26 | position-try-fallbacks: flip-block, flip-inline, flip-start; 27 | } 28 | -------------------------------------------------------------------------------- /public/position-try.css: -------------------------------------------------------------------------------- 1 | #my-anchor-fallback { 2 | anchor-name: --my-anchor-fallback; 3 | } 4 | 5 | #my-anchor-fallback-2 { 6 | anchor-name: --my-anchor-fallback-2; 7 | top: 70px; 8 | left: 50px; 9 | position: relative; 10 | } 11 | 12 | #my-target-fallback { 13 | position-anchor: --my-anchor-fallback; 14 | } 15 | 16 | #my-target-fallback-2 { 17 | position-anchor: --my-anchor-fallback-2; 18 | opacity: 0.8; 19 | } 20 | 21 | #my-target-fallback, 22 | #my-target-fallback-2 { 23 | position: absolute; 24 | 25 | /* First try to align the bottom, left edge of the target 26 | with the top, left edge of the anchor. */ 27 | bottom: anchor(top); 28 | left: anchor(left); 29 | width: anchor-size(width); 30 | position-try-options: --bottom-left, --top-right, --bottom-right; 31 | position-try-fallbacks: --bottom-left, --top-right, --bottom-right; 32 | } 33 | 34 | @position-try --bottom-left { 35 | /* Next try to align the top, left edge of the target 36 | with the bottom, left edge of the anchor. */ 37 | top: anchor(bottom); 38 | left: anchor(left); 39 | bottom: revert; 40 | } 41 | 42 | @position-try --top-right { 43 | /* Next try to align the bottom, right edge of the target 44 | with the top, right edge of the anchor. */ 45 | bottom: anchor(top); 46 | right: anchor(right); 47 | left: revert; 48 | width: revert; 49 | } 50 | 51 | @position-try --bottom-right { 52 | /* Finally, try to align the top, right edge of the target 53 | with the bottom, right edge of the anchor. */ 54 | 55 | top: anchor(bottom); 56 | right: anchor(right); 57 | width: 100px; 58 | height: 100px; 59 | bottom: revert; 60 | left: revert; 61 | } 62 | -------------------------------------------------------------------------------- /src/@types/css-tree.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'css-tree/walker' { 2 | import { walk } from 'css-tree'; 3 | 4 | export default walk; 5 | } 6 | 7 | declare module 'css-tree/utils' { 8 | export { clone, List } from 'css-tree'; 9 | } 10 | 11 | declare module 'css-tree/generator' { 12 | import { generate } from 'css-tree'; 13 | 14 | export default generate; 15 | } 16 | 17 | declare module 'css-tree/parser' { 18 | import { parse } from 'css-tree'; 19 | 20 | export default parse; 21 | } 22 | -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | import { type AnchorPositioningPolyfillOptions } from '../polyfill.ts'; 2 | 3 | export {}; 4 | 5 | declare global { 6 | interface Window { 7 | UPDATE_ANCHOR_ON_ANIMATION_FRAME?: boolean; 8 | ANCHOR_POSITIONING_POLYFILL_OPTIONS?: AnchorPositioningPolyfillOptions; 9 | CHECK_LAYOUT_DELAY?: boolean; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/cascade.ts: -------------------------------------------------------------------------------- 1 | import type { Block, CssNode } from 'css-tree'; 2 | import walk from 'css-tree/walker'; 3 | 4 | import { ACCEPTED_POSITION_TRY_PROPERTIES } from './syntax.js'; 5 | import { 6 | generateCSS, 7 | getAST, 8 | INSTANCE_UUID, 9 | isDeclaration, 10 | type StyleData, 11 | } from './utils.js'; 12 | 13 | /** 14 | * Map of CSS property to CSS custom property that the property's value is 15 | * shifted into. This is used to subject properties that are not yet natively 16 | * supported to the CSS cascade and inheritance rules. It is also used by the 17 | * fallback algorithm to find initial, non-computed values. 18 | */ 19 | export const SHIFTED_PROPERTIES: Record = [ 20 | ...ACCEPTED_POSITION_TRY_PROPERTIES, 21 | 'anchor-scope', 22 | 'anchor-name', 23 | ].reduce( 24 | (acc, prop) => { 25 | acc[prop] = `--${prop}-${INSTANCE_UUID}`; 26 | return acc; 27 | }, 28 | {} as Record, 29 | ); 30 | 31 | /** 32 | * Shift property declarations for properties that are not yet natively 33 | * supported into custom properties. 34 | */ 35 | function shiftUnsupportedProperties(node: CssNode, block?: Block) { 36 | if (isDeclaration(node) && SHIFTED_PROPERTIES[node.property] && block) { 37 | block.children.appendData({ 38 | ...node, 39 | property: SHIFTED_PROPERTIES[node.property], 40 | }); 41 | return { updated: true }; 42 | } 43 | return {}; 44 | } 45 | 46 | /** 47 | * Update the given style data to enable cascading and inheritance of properties 48 | * that are not yet natively supported. 49 | */ 50 | export function cascadeCSS(styleData: StyleData[]) { 51 | for (const styleObj of styleData) { 52 | let changed = false; 53 | const ast = getAST(styleObj.css); 54 | walk(ast, { 55 | visit: 'Declaration', 56 | enter(node) { 57 | const block = this.rule?.block; 58 | const { updated } = shiftUnsupportedProperties(node, block); 59 | if (updated) { 60 | changed = true; 61 | } 62 | }, 63 | }); 64 | 65 | if (changed) { 66 | // Update CSS 67 | styleObj.css = generateCSS(ast); 68 | styleObj.changed = true; 69 | } 70 | } 71 | return styleData.some((styleObj) => styleObj.changed === true); 72 | } 73 | -------------------------------------------------------------------------------- /src/dom.ts: -------------------------------------------------------------------------------- 1 | import { platform, type VirtualElement } from '@floating-ui/dom'; 2 | import { nanoid } from 'nanoid/non-secure'; 3 | 4 | import { SHIFTED_PROPERTIES } from './cascade.js'; 5 | 6 | /** 7 | * Representation of a CSS selector that allows getting the element part and 8 | * pseudo-element part. 9 | */ 10 | export interface Selector { 11 | selector: string; 12 | elementPart: string; 13 | pseudoElementPart?: string; 14 | } 15 | 16 | /** 17 | * Used instead of an HTMLElement as a handle for pseudo-elements. 18 | */ 19 | export interface PseudoElement extends VirtualElement { 20 | fakePseudoElement: HTMLElement; 21 | computedStyle: CSSStyleDeclaration; 22 | removeFakePseudoElement(): void; 23 | } 24 | 25 | /** 26 | * Possible values for `anchor-scope` 27 | * (in addition to any valid dashed identifier) 28 | */ 29 | export const enum AnchorScopeValue { 30 | All = 'all', 31 | None = 'none', 32 | } 33 | 34 | /** 35 | * Gets the computed value of a CSS property for an element or pseudo-element. 36 | * 37 | * Note: values for properties that are not natively supported are *always* 38 | * subject to CSS inheritance. 39 | */ 40 | export function getCSSPropertyValue( 41 | el: HTMLElement | PseudoElement, 42 | prop: string, 43 | ) { 44 | prop = SHIFTED_PROPERTIES[prop] ?? prop; 45 | const computedStyle = 46 | el instanceof HTMLElement ? getComputedStyle(el) : el.computedStyle; 47 | return computedStyle.getPropertyValue(prop).trim(); 48 | } 49 | 50 | /** 51 | * Checks whether a given element or pseudo-element has the given property 52 | * value. 53 | * 54 | * Note: values for properties that are not natively supported are *always* 55 | * subject to CSS inheritance. 56 | */ 57 | export function hasStyle( 58 | element: HTMLElement | PseudoElement, 59 | cssProperty: string, 60 | value: string, 61 | ) { 62 | return getCSSPropertyValue(element, cssProperty) === value; 63 | } 64 | 65 | /** 66 | * Creates a DOM element to use in place of a pseudo-element. 67 | */ 68 | function createFakePseudoElement( 69 | element: HTMLElement, 70 | { selector, pseudoElementPart }: Selector, 71 | ) { 72 | // Floating UI needs `Element.getBoundingClientRect` to calculate the position 73 | // for the anchored element, since there isn't a way to get it for 74 | // pseudo-elements; we create a temporary "fake pseudo-element" that we use as 75 | // reference. 76 | const computedStyle = getComputedStyle(element, pseudoElementPart); 77 | const fakePseudoElement = document.createElement('div'); 78 | const sheet = document.createElement('style'); 79 | 80 | fakePseudoElement.id = `fake-pseudo-element-${nanoid()}`; 81 | 82 | // Copy styles from pseudo-element to the "fake pseudo-element", `.cssText` 83 | // does not work on Firefox. 84 | for (const property of Array.from(computedStyle)) { 85 | const value = computedStyle.getPropertyValue(property); 86 | fakePseudoElement.style.setProperty(property, value); 87 | } 88 | 89 | // For the `content` property, since normal elements don't have it, 90 | // we add the content to a pseudo-element of the "fake pseudo-element". 91 | sheet.textContent += `#${fakePseudoElement.id}${pseudoElementPart} { content: ${computedStyle.content}; }`; 92 | // Hide the pseudo-element while the "fake pseudo-element" is visible. 93 | sheet.textContent += `${selector} { display: none !important; }`; 94 | 95 | document.head.append(sheet); 96 | 97 | const insertionPoint = 98 | pseudoElementPart === '::before' ? 'afterbegin' : 'beforeend'; 99 | element.insertAdjacentElement(insertionPoint, fakePseudoElement); 100 | return { fakePseudoElement, sheet, computedStyle }; 101 | } 102 | 103 | /** 104 | * Finds the first scrollable parent of the given element 105 | * (or the element itself if the element is scrollable). 106 | */ 107 | function findFirstScrollingElement(element: HTMLElement) { 108 | let currentElement: HTMLElement | null = element; 109 | 110 | while (currentElement) { 111 | if (hasStyle(currentElement, 'overflow', 'scroll')) { 112 | return currentElement; 113 | } 114 | 115 | currentElement = currentElement.parentElement; 116 | } 117 | 118 | return currentElement; 119 | } 120 | 121 | /** 122 | * Gets the scroll position of the first scrollable parent 123 | * (or the scroll position of the element itself, if it is scrollable). 124 | */ 125 | function getContainerScrollPosition(element: HTMLElement) { 126 | let containerScrollPosition: { 127 | scrollTop: number; 128 | scrollLeft: number; 129 | } | null = findFirstScrollingElement(element); 130 | 131 | // Avoid doubled scroll 132 | if (containerScrollPosition === document.documentElement) { 133 | containerScrollPosition = null; 134 | } 135 | 136 | return containerScrollPosition ?? { scrollTop: 0, scrollLeft: 0 }; 137 | } 138 | 139 | /** 140 | * Like `document.querySelectorAll`, but if the selector has a pseudo-element it 141 | * will return a wrapper for the rest of the polyfill to use. 142 | */ 143 | export function getElementsBySelector(selector: Selector) { 144 | const { elementPart, pseudoElementPart } = selector; 145 | const result: (HTMLElement | PseudoElement)[] = []; 146 | const isBefore = pseudoElementPart === '::before'; 147 | const isAfter = pseudoElementPart === '::after'; 148 | 149 | // Current we only support `::before` and `::after` pseudo-elements. 150 | if (pseudoElementPart && !(isBefore || isAfter)) return result; 151 | 152 | const elements = Array.from( 153 | document.querySelectorAll(elementPart), 154 | ); 155 | 156 | if (!pseudoElementPart) { 157 | result.push(...elements); 158 | return result; 159 | } 160 | 161 | for (const element of elements) { 162 | const { fakePseudoElement, sheet, computedStyle } = createFakePseudoElement( 163 | element, 164 | selector, 165 | ); 166 | 167 | const boundingClientRect = fakePseudoElement.getBoundingClientRect(); 168 | const { scrollY: startingScrollY, scrollX: startingScrollX } = globalThis; 169 | const containerScrollPosition = getContainerScrollPosition(element); 170 | 171 | result.push({ 172 | fakePseudoElement, 173 | computedStyle, 174 | 175 | removeFakePseudoElement() { 176 | fakePseudoElement.remove(); 177 | sheet.remove(); 178 | }, 179 | 180 | // For https://floating-ui.com/docs/autoupdate#ancestorscroll to work on 181 | // `VirtualElement`s. 182 | contextElement: element, 183 | 184 | // https://floating-ui.com/docs/virtual-elements 185 | getBoundingClientRect() { 186 | const { scrollY, scrollX } = globalThis; 187 | const { scrollTop, scrollLeft } = containerScrollPosition; 188 | 189 | return DOMRect.fromRect({ 190 | y: 191 | boundingClientRect.y + 192 | (startingScrollY - scrollY) + 193 | (containerScrollPosition.scrollTop - scrollTop), 194 | x: 195 | boundingClientRect.x + 196 | (startingScrollX - scrollX) + 197 | (containerScrollPosition.scrollLeft - scrollLeft), 198 | 199 | width: boundingClientRect.width, 200 | height: boundingClientRect.height, 201 | }); 202 | }, 203 | }); 204 | } 205 | 206 | return result; 207 | } 208 | 209 | /** 210 | * Checks whether the given element has the given anchor name, based on the 211 | * element's computed style. 212 | * 213 | * Note: because our `--anchor-name` custom property inherits, this function 214 | * should only be called for elements which are known to have an explicitly set 215 | * value for `anchor-name`. 216 | */ 217 | export function hasAnchorName( 218 | el: PseudoElement | HTMLElement, 219 | anchorName: string | null, 220 | ) { 221 | const computedAnchorName = getCSSPropertyValue(el, 'anchor-name'); 222 | if (!anchorName) { 223 | return !computedAnchorName; 224 | } 225 | return computedAnchorName 226 | .split(',') 227 | .map((name) => name.trim()) 228 | .includes(anchorName); 229 | } 230 | 231 | /** 232 | * Checks whether the given element serves as a scope for the given anchor. 233 | * 234 | * Note: because our `--anchor-scope` custom property inherits, this function 235 | * should only be called for elements which are known to have an explicitly set 236 | * value for `anchor-scope`. 237 | */ 238 | export function hasAnchorScope( 239 | el: PseudoElement | HTMLElement, 240 | anchorName: string, 241 | ) { 242 | const computedAnchorScope = getCSSPropertyValue(el, 'anchor-scope'); 243 | return ( 244 | computedAnchorScope === anchorName || 245 | computedAnchorScope === AnchorScopeValue.All 246 | ); 247 | } 248 | 249 | export const getOffsetParent = async (el: HTMLElement) => { 250 | let offsetParent = await platform.getOffsetParent?.(el); 251 | if (!(await platform.isElement?.(offsetParent))) { 252 | offsetParent = 253 | (await platform.getDocumentElement?.(el)) || 254 | window.document.documentElement; 255 | } 256 | return offsetParent as HTMLElement; 257 | }; 258 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid/non-secure'; 2 | 3 | import { type StyleData } from './utils.js'; 4 | 5 | const INVALID_MIME_TYPE_ERROR = 'InvalidMimeType'; 6 | 7 | export function isStyleLink(link: HTMLLinkElement): link is HTMLLinkElement { 8 | return Boolean( 9 | (link.type === 'text/css' || link.rel === 'stylesheet') && link.href, 10 | ); 11 | } 12 | 13 | function getStylesheetUrl(link: HTMLLinkElement): URL | undefined { 14 | const srcUrl = new URL(link.href, document.baseURI); 15 | if (isStyleLink(link) && srcUrl.origin === location.origin) { 16 | return srcUrl; 17 | } 18 | } 19 | 20 | async function fetchLinkedStylesheets( 21 | sources: Partial[], 22 | ): Promise { 23 | const results = await Promise.all( 24 | sources.map(async (data) => { 25 | if (!data.url) { 26 | return data as StyleData; 27 | } 28 | // TODO: Add MutationObserver to watch for disabled links being enabled 29 | // https://github.com/oddbird/css-anchor-positioning/issues/246 30 | if ((data.el as HTMLLinkElement | undefined)?.disabled) { 31 | // Do not fetch or parse disabled stylesheets 32 | return null; 33 | } 34 | // fetch css and add to array 35 | try { 36 | const response = await fetch(data.url.toString()); 37 | const type = response.headers.get('content-type'); 38 | if (!type?.startsWith('text/css')) { 39 | const error = new Error( 40 | `Error loading ${data.url}: expected content-type "text/css", got "${type}".`, 41 | ); 42 | error.name = INVALID_MIME_TYPE_ERROR; 43 | throw error; 44 | } 45 | const css = await response.text(); 46 | return { ...data, css } as StyleData; 47 | } catch (error) { 48 | if (error instanceof Error && error.name === INVALID_MIME_TYPE_ERROR) { 49 | // eslint-disable-next-line no-console 50 | console.warn(error); 51 | return null; 52 | } 53 | throw error; 54 | } 55 | }), 56 | ); 57 | return results.filter((loaded) => loaded !== null); 58 | } 59 | 60 | const ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY = '[style*="anchor"]'; 61 | const ELEMENTS_WITH_INLINE_POSITION_AREA = '[style*="position-area"]'; 62 | // Searches for all elements with inline style attributes that include `anchor`. 63 | // For each element found, adds a new 'data-has-inline-styles' attribute with a 64 | // random UUID value, and then formats the styles in the same manner as CSS from 65 | // style tags. 66 | function fetchInlineStyles(elements?: HTMLElement[]) { 67 | const elementsWithInlineAnchorStyles: HTMLElement[] = elements 68 | ? elements.filter( 69 | (el) => 70 | el instanceof HTMLElement && 71 | (el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY) || 72 | el.matches(ELEMENTS_WITH_INLINE_POSITION_AREA)), 73 | ) 74 | : Array.from( 75 | document.querySelectorAll( 76 | [ 77 | ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY, 78 | ELEMENTS_WITH_INLINE_POSITION_AREA, 79 | ].join(','), 80 | ), 81 | ); 82 | const inlineStyles: Partial[] = []; 83 | 84 | elementsWithInlineAnchorStyles 85 | .filter((el) => el instanceof HTMLElement) 86 | .forEach((el) => { 87 | const selector = nanoid(12); 88 | const dataAttribute = 'data-has-inline-styles'; 89 | el.setAttribute(dataAttribute, selector); 90 | const styles = el.getAttribute('style'); 91 | const css = `[${dataAttribute}="${selector}"] { ${styles} }`; 92 | inlineStyles.push({ el, css }); 93 | }); 94 | 95 | return inlineStyles; 96 | } 97 | 98 | export async function fetchCSS( 99 | elements?: HTMLElement[], 100 | excludeInlineStyles?: boolean, 101 | ): Promise { 102 | const targetElements: HTMLElement[] = 103 | elements ?? Array.from(document.querySelectorAll('link, style')); 104 | const sources: Partial[] = []; 105 | 106 | targetElements 107 | .filter((el) => el instanceof HTMLElement) 108 | .forEach((el) => { 109 | if (el.tagName.toLowerCase() === 'link') { 110 | const url = getStylesheetUrl(el as HTMLLinkElement); 111 | if (url) { 112 | sources.push({ el, url }); 113 | } 114 | } 115 | if (el.tagName.toLowerCase() === 'style') { 116 | sources.push({ el, css: el.innerHTML }); 117 | } 118 | }); 119 | 120 | const elementsForInlines = excludeInlineStyles ? (elements ?? []) : undefined; 121 | 122 | const inlines = fetchInlineStyles(elementsForInlines); 123 | 124 | return await fetchLinkedStylesheets([...sources, ...inlines]); 125 | } 126 | -------------------------------------------------------------------------------- /src/index-fn.ts: -------------------------------------------------------------------------------- 1 | import { polyfill } from './polyfill.js'; 2 | 3 | export default polyfill; 4 | -------------------------------------------------------------------------------- /src/index-wpt.ts: -------------------------------------------------------------------------------- 1 | import { polyfill } from './polyfill.js'; 2 | 3 | // Used by the WPT test harness to delay test assertions 4 | // and give the polyfill time to apply changes 5 | window.CHECK_LAYOUT_DELAY = true; 6 | 7 | // apply polyfill 8 | if (document.readyState !== 'complete') { 9 | window.addEventListener('load', () => { 10 | polyfill(true); 11 | }); 12 | } else { 13 | polyfill(true); 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { polyfill } from './polyfill.js'; 2 | 3 | // apply polyfill 4 | if (document.readyState !== 'complete') { 5 | window.addEventListener('load', () => { 6 | polyfill(); 7 | }); 8 | } else { 9 | polyfill(); 10 | } 11 | -------------------------------------------------------------------------------- /src/syntax.ts: -------------------------------------------------------------------------------- 1 | // Inset properties 2 | export const INSET_PROPS = [ 3 | 'left', 4 | 'right', 5 | 'top', 6 | 'bottom', 7 | 'inset-block-start', 8 | 'inset-block-end', 9 | 'inset-inline-start', 10 | 'inset-inline-end', 11 | 'inset-block', 12 | 'inset-inline', 13 | 'inset', 14 | ] as const; 15 | 16 | export type InsetProperty = (typeof INSET_PROPS)[number]; 17 | 18 | export function isInsetProp( 19 | property: string | AnchorSide, 20 | ): property is InsetProperty { 21 | return INSET_PROPS.includes(property as InsetProperty); 22 | } 23 | 24 | // Margin properties 25 | export const MARGIN_PROPERTIES = [ 26 | 'margin-block-start', 27 | 'margin-block-end', 28 | 'margin-block', 29 | 'margin-inline-start', 30 | 'margin-inline-end', 31 | 'margin-inline', 32 | 'margin-bottom', 33 | 'margin-left', 34 | 'margin-right', 35 | 'margin-top', 36 | 'margin', 37 | ] as const; 38 | 39 | export type MarginProperty = (typeof MARGIN_PROPERTIES)[number]; 40 | 41 | export function isMarginProp(property: string): property is MarginProperty { 42 | return MARGIN_PROPERTIES.includes(property as MarginProperty); 43 | } 44 | 45 | // Sizing properties 46 | export const SIZING_PROPS = [ 47 | 'width', 48 | 'height', 49 | 'min-width', 50 | 'min-height', 51 | 'max-width', 52 | 'max-height', 53 | 'block-size', 54 | 'inline-size', 55 | 'min-block-size', 56 | 'min-inline-size', 57 | 'max-block-size', 58 | 'max-inline-size', 59 | ] as const; 60 | 61 | export type SizingProperty = (typeof SIZING_PROPS)[number]; 62 | 63 | export function isSizingProp(property: string): property is SizingProperty { 64 | return SIZING_PROPS.includes(property as SizingProperty); 65 | } 66 | 67 | // Self Alignment Properties 68 | export const SELF_ALIGNMENT_PROPS = [ 69 | 'justify-self', 70 | 'align-self', 71 | 'place-self', 72 | ] as const; 73 | 74 | export type SelfAlignmentProperty = (typeof SELF_ALIGNMENT_PROPS)[number]; 75 | 76 | export function isSelfAlignmentProp( 77 | property: string, 78 | ): property is SelfAlignmentProperty { 79 | return SELF_ALIGNMENT_PROPS.includes(property as SelfAlignmentProperty); 80 | } 81 | 82 | // Accepted position try properties 83 | export const ACCEPTED_POSITION_TRY_PROPERTIES = [ 84 | ...INSET_PROPS, 85 | ...MARGIN_PROPERTIES, 86 | ...SIZING_PROPS, 87 | ...SELF_ALIGNMENT_PROPS, 88 | 'position-anchor', 89 | 'position-area', 90 | ] as const; 91 | 92 | export type AcceptedPositionTryProperty = 93 | (typeof ACCEPTED_POSITION_TRY_PROPERTIES)[number]; 94 | 95 | export function isAcceptedPositionTryProp( 96 | property: string, 97 | ): property is AcceptedPositionTryProperty { 98 | return ACCEPTED_POSITION_TRY_PROPERTIES.includes( 99 | property as AcceptedPositionTryProperty, 100 | ); 101 | } 102 | 103 | // Accepted anchor-size() properties 104 | export const ACCEPTED_ANCHOR_SIZE_PROPERTIES = [ 105 | ...SIZING_PROPS, 106 | ...INSET_PROPS, 107 | ...MARGIN_PROPERTIES, 108 | ] as const; 109 | 110 | export type AcceptedAnchorSizeProperty = 111 | (typeof ACCEPTED_ANCHOR_SIZE_PROPERTIES)[number]; 112 | 113 | export function isAcceptedAnchorSizeProp( 114 | property: string, 115 | ): property is AcceptedAnchorSizeProperty { 116 | return ACCEPTED_ANCHOR_SIZE_PROPERTIES.includes( 117 | property as AcceptedAnchorSizeProperty, 118 | ); 119 | } 120 | 121 | // Anchor Side properties 122 | export const ANCHOR_SIDES = [ 123 | 'top', 124 | 'left', 125 | 'right', 126 | 'bottom', 127 | 'start', 128 | 'end', 129 | 'self-start', 130 | 'self-end', 131 | 'center', 132 | 'inside', 133 | 'outside', 134 | ]; 135 | export type AnchorSideKeyword = (typeof ANCHOR_SIDES)[number]; 136 | 137 | export type AnchorSide = AnchorSideKeyword | number; 138 | 139 | export function isAnchorSide(property: string): property is AnchorSideKeyword { 140 | return ANCHOR_SIDES.includes(property as AnchorSideKeyword); 141 | } 142 | 143 | // Anchor Size 144 | export const ANCHOR_SIZES = [ 145 | 'width', 146 | 'height', 147 | 'block', 148 | 'inline', 149 | 'self-block', 150 | 'self-inline', 151 | ] as const; 152 | 153 | export type AnchorSize = (typeof ANCHOR_SIZES)[number]; 154 | 155 | export function isAnchorSize(property: string): property is AnchorSize { 156 | return ANCHOR_SIZES.includes(property as AnchorSize); 157 | } 158 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | import { type StyleData } from './utils.js'; 2 | 3 | // This is a list of non-global attributes that apply to link elements but do 4 | // not apply to style elements. These should be removed when converting from a 5 | // link element to a style element. These mostly define loading behavior, which 6 | // is not relevant to style elements or our use case. 7 | const excludeAttributes = [ 8 | 'as', 9 | 'blocking', 10 | 'crossorigin', 11 | // 'disabled' is not relevant for style elements, but this exclusion is 12 | // theoretical, as a will not be loaded, and 13 | // will not reach this part of the polyfill. See #246. 14 | 'disabled', 15 | 'fetchpriority', 16 | 'href', 17 | 'hreflang', 18 | 'integrity', 19 | 'referrerpolicy', 20 | 'rel', 21 | 'type', 22 | ]; 23 | 24 | export function transformCSS( 25 | styleData: StyleData[], 26 | inlineStyles?: Map>, 27 | cleanup = false, 28 | ) { 29 | const updatedStyleData: StyleData[] = []; 30 | for (const { el, css, changed, created = false } of styleData) { 31 | const updatedObject: StyleData = { el, css, changed: false }; 32 | if (changed) { 33 | if (el.tagName.toLowerCase() === 'style') { 34 | // Handle inline stylesheets 35 | el.innerHTML = css; 36 | } else if (el instanceof HTMLLinkElement) { 37 | // Replace link elements with style elements. 38 | // We use inline style elements rather than link elements with blob 39 | // URLs, as relative URLs for things like images and fonts are not 40 | // supported in blob URLs. See 41 | // https://github.com/oddbird/css-anchor-positioning/pull/324 for more 42 | // discussion. 43 | const styleEl = document.createElement('style'); 44 | styleEl.textContent = css; 45 | for (const name of el.getAttributeNames()) { 46 | if (!name.startsWith('on') && !excludeAttributes.includes(name)) { 47 | const attr = el.getAttribute(name); 48 | if (attr !== null) { 49 | styleEl.setAttribute(name, attr); 50 | } 51 | } 52 | } 53 | // Persist the href attribute to help with potential debugging. 54 | if (el.hasAttribute('href')) { 55 | styleEl.setAttribute('data-original-href', el.getAttribute('href')!); 56 | } 57 | if (!created) { 58 | // This is an existing stylesheet, so we replace it. 59 | el.insertAdjacentElement('beforebegin', styleEl); 60 | el.remove(); 61 | } else { 62 | styleEl.setAttribute('data-generated-by-polyfill', 'true'); 63 | // This is a new stylesheet, so we append it. 64 | document.head.insertAdjacentElement('beforeend', styleEl); 65 | } 66 | updatedObject.el = styleEl; 67 | } else if (el.hasAttribute('data-has-inline-styles')) { 68 | // Handle inline styles 69 | const attr = el.getAttribute('data-has-inline-styles'); 70 | if (attr) { 71 | const pre = `[data-has-inline-styles="${attr}"]{`; 72 | const post = `}`; 73 | let styles = css.slice(pre.length, 0 - post.length); 74 | // Check for custom anchor-element mapping, so it is not overwritten 75 | // when inline styles are updated 76 | const mappings = inlineStyles?.get(el); 77 | if (mappings) { 78 | for (const [key, val] of Object.entries(mappings)) { 79 | styles = `${key}: var(${val}); ${styles}`; 80 | } 81 | } 82 | el.setAttribute('style', styles); 83 | } 84 | } 85 | } 86 | // Remove no-longer-needed data-attribute 87 | if (cleanup && el.hasAttribute('data-has-inline-styles')) { 88 | el.removeAttribute('data-has-inline-styles'); 89 | } 90 | updatedStyleData.push(updatedObject); 91 | } 92 | return updatedStyleData; 93 | } 94 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CssNode, 3 | Declaration, 4 | FunctionNode, 5 | Identifier, 6 | List, 7 | Selector as CssTreeSelector, 8 | SelectorList, 9 | Value, 10 | } from 'css-tree'; 11 | import generate from 'css-tree/generator'; 12 | import parse from 'css-tree/parser'; 13 | import { clone } from 'css-tree/utils'; 14 | import { nanoid } from 'nanoid/non-secure'; 15 | 16 | import type { Selector } from './dom.js'; 17 | 18 | export const INSTANCE_UUID = nanoid(); 19 | 20 | // https://github.com/import-js/eslint-plugin-import/issues/3019 21 | 22 | export interface DeclarationWithValue extends Declaration { 23 | value: Value; 24 | } 25 | 26 | export function isAnchorFunction(node: CssNode | null): node is FunctionNode { 27 | return Boolean(node && node.type === 'Function' && node.name === 'anchor'); 28 | } 29 | 30 | export function getAST(cssText: string) { 31 | return parse(cssText, { 32 | parseAtrulePrelude: false, 33 | parseCustomProperty: true, 34 | }); 35 | } 36 | 37 | export function generateCSS(ast: CssNode) { 38 | return generate(ast, { 39 | // Default `safe` adds extra (potentially breaking) spaces for compatibility 40 | // with old browsers. 41 | mode: 'spec', 42 | }); 43 | } 44 | 45 | export function isDeclaration(node: CssNode): node is DeclarationWithValue { 46 | return node.type === 'Declaration'; 47 | } 48 | 49 | export function getDeclarationValue(node: DeclarationWithValue) { 50 | return (node.value.children.first as Identifier).name; 51 | } 52 | 53 | export interface StyleData { 54 | el: HTMLElement; 55 | css: string; 56 | url?: URL; 57 | changed?: boolean; 58 | created?: boolean; // Whether the element is created by the polyfill 59 | } 60 | 61 | export const POSITION_ANCHOR_PROPERTY = `--position-anchor-${INSTANCE_UUID}`; 62 | 63 | export function splitCommaList(list: List) { 64 | return list.toArray().reduce( 65 | (acc: Identifier[][], child) => { 66 | if (child.type === 'Operator' && child.value === ',') { 67 | acc.push([]); 68 | return acc; 69 | } 70 | if (child.type === 'Identifier') { 71 | acc[acc.length - 1].push(child); 72 | } 73 | 74 | return acc; 75 | }, 76 | [[]], 77 | ); 78 | } 79 | 80 | export function getSelectors(rule: SelectorList | undefined) { 81 | if (!rule) return []; 82 | 83 | return (rule.children as List) 84 | .map((selector) => { 85 | let pseudoElementPart: string | undefined; 86 | 87 | if (selector.children.last?.type === 'PseudoElementSelector') { 88 | selector = clone(selector) as CssTreeSelector; 89 | pseudoElementPart = generateCSS(selector.children.last!); 90 | selector.children.pop(); 91 | } 92 | 93 | const elementPart = generateCSS(selector); 94 | 95 | return { 96 | selector: elementPart + (pseudoElementPart ?? ''), 97 | elementPart, 98 | pseudoElementPart, 99 | } satisfies Selector; 100 | }) 101 | .toArray(); 102 | } 103 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import { platform } from '@floating-ui/dom'; 2 | 3 | import { 4 | getCSSPropertyValue, 5 | getElementsBySelector, 6 | hasAnchorName, 7 | hasAnchorScope, 8 | hasStyle, 9 | type Selector, 10 | } from './dom.js'; 11 | 12 | // Given a target element's containing block (CB) and an anchor element, 13 | // determines if the anchor element is a descendant of the target CB. 14 | // An additional check is added to see if the target CB is the anchor, 15 | // because `.contains()` will return true: "a node is contained inside itself." 16 | // https://developer.mozilla.org/en-US/docs/Web/API/Node/contains 17 | function isContainingBlockDescendant( 18 | containingBlock: Element | Window, 19 | anchor: Element, 20 | ): boolean { 21 | if (!containingBlock || containingBlock === anchor) { 22 | return false; 23 | } 24 | 25 | if (isWindow(containingBlock)) { 26 | return containingBlock.document.contains(anchor); 27 | } else { 28 | return containingBlock.contains(anchor); 29 | } 30 | } 31 | 32 | function isWindow(el: Element | Window | undefined): el is Window { 33 | return Boolean(el && el === (el as Window).window); 34 | } 35 | 36 | function isFixedPositioned(el: HTMLElement) { 37 | return hasStyle(el, 'position', 'fixed'); 38 | } 39 | 40 | function isAbsolutelyPositioned(el?: HTMLElement | null) { 41 | return Boolean( 42 | el && (isFixedPositioned(el) || hasStyle(el, 'position', 'absolute')), 43 | ); 44 | } 45 | 46 | function precedes(self: HTMLElement, other: HTMLElement) { 47 | return self.compareDocumentPosition(other) & Node.DOCUMENT_POSITION_FOLLOWING; 48 | } 49 | 50 | /** https://drafts.csswg.org/css-display-4/#formatting-context */ 51 | async function getFormattingContext(element: HTMLElement) { 52 | return await platform.getOffsetParent(element); 53 | } 54 | 55 | /** https://drafts.csswg.org/css-display-4/#containing-block */ 56 | async function getContainingBlock(element: HTMLElement) { 57 | if ( 58 | !['absolute', 'fixed'].includes(getCSSPropertyValue(element, 'position')) 59 | ) { 60 | return await getFormattingContext(element); 61 | } 62 | 63 | let currentParent = element.parentElement; 64 | 65 | while (currentParent) { 66 | if ( 67 | !hasStyle(currentParent, 'position', 'static') && 68 | hasStyle(currentParent, 'display', 'block') 69 | ) { 70 | return currentParent; 71 | } 72 | 73 | currentParent = currentParent.parentElement; 74 | } 75 | 76 | return window; 77 | } 78 | 79 | /** 80 | * Validates that el is a acceptable anchor element for an absolutely positioned 81 | * element query el 82 | * https://drafts.csswg.org/css-anchor-position-1/#acceptable-anchor-element 83 | */ 84 | export async function isAcceptableAnchorElement( 85 | el: HTMLElement, 86 | anchorName: string | null, 87 | queryEl: HTMLElement, 88 | scopeSelector: string | null, 89 | ) { 90 | const elContainingBlock = await getContainingBlock(el); 91 | const queryElContainingBlock = await getContainingBlock(queryEl); 92 | 93 | // Either el is a descendant of query el’s containing block 94 | // or query el’s containing block is the initial containing block. 95 | if ( 96 | !( 97 | isContainingBlockDescendant(queryElContainingBlock, el) || 98 | isWindow(queryElContainingBlock) 99 | ) 100 | ) { 101 | return false; 102 | } 103 | 104 | // If el has the same containing block as query el, 105 | // then either el is not absolutely positioned, 106 | // or el precedes query el in the tree order. 107 | if ( 108 | elContainingBlock === queryElContainingBlock && 109 | !(!isAbsolutelyPositioned(el) || precedes(el, queryEl)) 110 | ) { 111 | return false; 112 | } 113 | 114 | // If el has a different containing block from query el, 115 | // then the last containing block in el’s containing block chain 116 | // before reaching query el’s containing block 117 | // is either not absolutely positioned or precedes query el in the tree order. 118 | if (elContainingBlock !== queryElContainingBlock) { 119 | let currentCB: Element | Window; 120 | const anchorCBchain: (typeof currentCB)[] = []; 121 | 122 | currentCB = elContainingBlock; 123 | while ( 124 | currentCB && 125 | currentCB !== queryElContainingBlock && 126 | currentCB !== window 127 | ) { 128 | anchorCBchain.push(currentCB); 129 | currentCB = await getContainingBlock(currentCB as HTMLElement); 130 | } 131 | const lastInChain = anchorCBchain[anchorCBchain.length - 1]; 132 | 133 | if ( 134 | lastInChain instanceof HTMLElement && 135 | !(!isAbsolutelyPositioned(lastInChain) || precedes(lastInChain, queryEl)) 136 | ) { 137 | return false; 138 | } 139 | } 140 | 141 | // el is not in the skipped contents of another element. 142 | { 143 | let currentParent = el.parentElement; 144 | 145 | while (currentParent) { 146 | if (hasStyle(currentParent, 'content-visibility', 'hidden')) { 147 | return false; 148 | } 149 | 150 | currentParent = currentParent.parentElement; 151 | } 152 | } 153 | 154 | // el is in scope for query el, per the effects of anchor-scope on query el or 155 | // its ancestors. 156 | if ( 157 | anchorName && 158 | scopeSelector && 159 | getScope(el, anchorName, scopeSelector) !== 160 | getScope(queryEl, anchorName, scopeSelector) 161 | ) { 162 | return false; 163 | } 164 | 165 | return true; 166 | } 167 | 168 | function getScope( 169 | element: HTMLElement, 170 | anchorName: string, 171 | scopeSelector: string, 172 | ) { 173 | // Unlike the real `anchor-scope`, our `--anchor-scope` custom property 174 | // inherits. We first check that the element matches the scope selector, so we 175 | // can be guaranteed that the computed value we read was set explicitly, not 176 | // inherited. Then we verify that the specified anchor scope is actually the 177 | // one applied by the CSS cascade. 178 | while ( 179 | !(element.matches(scopeSelector) && hasAnchorScope(element, anchorName)) 180 | ) { 181 | if (!element.parentElement) { 182 | return null; 183 | } 184 | element = element.parentElement; 185 | } 186 | return element; 187 | } 188 | 189 | /** 190 | * Given a target element and CSS selector(s) for potential anchor element(s), 191 | * returns the first element that passes validation, 192 | * or `null` if no valid anchor element is found 193 | * https://drafts.csswg.org/css-anchor-position-1/#target 194 | */ 195 | export async function validatedForPositioning( 196 | targetEl: HTMLElement | null, 197 | anchorName: string | null, 198 | anchorSelectors: Selector[], 199 | scopeSelectors: Selector[], 200 | ) { 201 | if ( 202 | !( 203 | targetEl instanceof HTMLElement && 204 | anchorSelectors.length && 205 | isAbsolutelyPositioned(targetEl) 206 | ) 207 | ) { 208 | return null; 209 | } 210 | 211 | const anchorElements = anchorSelectors 212 | // Any element that matches a selector that sets the specified `anchor-name` 213 | // could be a potential match. 214 | .flatMap(getElementsBySelector) 215 | // Narrow down the potential match elements to just the ones whose computed 216 | // `anchor-name` matches the specified one. This accounts for the 217 | // `anchor-name` value that was actually applied by the CSS cascade. 218 | .filter((el) => hasAnchorName(el, anchorName)); 219 | 220 | // TODO: handle anchor-scope for pseudo-elements. 221 | const scopeSelector = scopeSelectors.map((s) => s.selector).join(',') || null; 222 | 223 | for (let index = anchorElements.length - 1; index >= 0; index--) { 224 | const anchor = anchorElements[index]; 225 | const isPseudoElement = 'fakePseudoElement' in anchor; 226 | 227 | if ( 228 | await isAcceptableAnchorElement( 229 | isPseudoElement ? anchor.fakePseudoElement : anchor, 230 | anchorName, 231 | targetEl, 232 | scopeSelector, 233 | ) 234 | ) { 235 | if (isPseudoElement) anchor.removeFakePseudoElement(); 236 | 237 | return anchor; 238 | } 239 | } 240 | 241 | return null; 242 | } 243 | -------------------------------------------------------------------------------- /tests/e2e/polyfill.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Page, test } from '@playwright/test'; 2 | 3 | import { expectWithinOne } from './utils.js'; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | // Listen for all console logs 7 | // eslint-disable-next-line no-console 8 | page.on('console', (msg) => console.log(msg.text())); 9 | await page.goto('/'); 10 | }); 11 | 12 | const btnSelector = '#apply-polyfill'; 13 | const targetSelector = '#my-target-positioning'; 14 | const anchorSelector = '#my-anchor-positioning'; 15 | 16 | async function applyPolyfill(page: Page) { 17 | const btn = page.locator(btnSelector); 18 | await btn.click(); 19 | return await expect(btn).toBeDisabled(); 20 | } 21 | 22 | async function getElementWidth(page: Page, sel: string) { 23 | return page 24 | .locator(sel) 25 | .first() 26 | .evaluate((node: HTMLElement) => node.getBoundingClientRect().width); 27 | } 28 | 29 | async function getParentWidth(page: Page, sel: string) { 30 | return page 31 | .locator(sel) 32 | .first() 33 | .evaluate((node: HTMLElement) => node.offsetParent?.clientWidth ?? 0); 34 | } 35 | 36 | async function getParentHeight(page: Page, sel: string) { 37 | return page 38 | .locator(sel) 39 | .first() 40 | .evaluate((node: HTMLElement) => node.offsetParent?.clientHeight ?? 0); 41 | } 42 | 43 | test('applies polyfill for `anchor()`', async ({ page }) => { 44 | const target = page.locator(targetSelector); 45 | const width = await getElementWidth(page, anchorSelector); 46 | const parentWidth = await getParentWidth(page, targetSelector); 47 | const parentHeight = await getParentHeight(page, targetSelector); 48 | const expected = parentWidth - width; 49 | 50 | await expect(target).toHaveCSS('top', '0px'); 51 | await expectWithinOne(target, 'right', expected, true); 52 | 53 | await applyPolyfill(page); 54 | 55 | await expectWithinOne(target, 'top', parentHeight); 56 | await expectWithinOne(target, 'right', expected); 57 | }); 58 | 59 | test('applies polyfill for inside and outside keywords', async ({ page }) => { 60 | const inoutAnchorSelector = '#inside-outside .anchor'; 61 | const inoutTargetSelector = '#inside-outside .target'; 62 | const target = page.locator(inoutTargetSelector); 63 | const height = await getParentHeight(page, inoutAnchorSelector); 64 | const parentWidth = await getParentWidth(page, inoutTargetSelector); 65 | const parentHeight = await getParentHeight(page, inoutTargetSelector); 66 | const expected = parentHeight - height; 67 | 68 | await expectWithinOne(target, 'left', 0); 69 | await expectWithinOne(target, 'bottom', expected, true); 70 | 71 | await applyPolyfill(page); 72 | 73 | await expectWithinOne(target, 'left', parentWidth); 74 | await expectWithinOne(target, 'bottom', expected); 75 | }); 76 | 77 | test('applies polyfill from inline styles', async ({ page }) => { 78 | const targetInLine = page.locator('#my-target-inline'); 79 | const width = await getElementWidth(page, anchorSelector); 80 | const parentWidth = await getParentWidth(page, targetSelector); 81 | const parentHeight = await getParentHeight(page, targetSelector); 82 | const expected = parentWidth - width; 83 | 84 | await expect(targetInLine).toHaveCSS('top', '0px'); 85 | await expectWithinOne(targetInLine, 'right', expected, true); 86 | 87 | await applyPolyfill(page); 88 | 89 | await expectWithinOne(targetInLine, 'top', parentHeight); 90 | await expectWithinOne(targetInLine, 'right', expected); 91 | }); 92 | 93 | test('updates when sizes change', async ({ page }) => { 94 | const target = page.locator(targetSelector); 95 | const width = await getElementWidth(page, anchorSelector); 96 | const parentWidth = await getParentWidth(page, targetSelector); 97 | const parentHeight = await getParentHeight(page, targetSelector); 98 | await applyPolyfill(page); 99 | 100 | await expectWithinOne(target, 'top', parentHeight); 101 | await expectWithinOne(target, 'right', parentWidth - width); 102 | 103 | await page 104 | .locator(anchorSelector) 105 | .evaluate((anchor) => (anchor.style.width = '50px')); 106 | 107 | await expectWithinOne(target, 'right', parentWidth - 50); 108 | }); 109 | 110 | test('applies polyfill for `@position-fallback`', async ({ page }) => { 111 | const targetSel = '#my-target-fallback'; 112 | const target = page.locator(targetSel); 113 | await target.scrollIntoViewIfNeeded(); 114 | 115 | await expect(target).toHaveCSS('left', '0px'); 116 | 117 | await applyPolyfill(page); 118 | 119 | await expect(target).not.toHaveCSS('left', '0px'); 120 | await expect(target).not.toHaveCSS('width', '100px'); 121 | 122 | await target.evaluate((node: HTMLElement) => { 123 | (node.offsetParent as HTMLElement).scrollLeft = 180; 124 | (node.offsetParent as HTMLElement).scrollTop = 120; 125 | }); 126 | 127 | await expect(target).toHaveCSS('width', '100px'); 128 | await expect(target).toHaveCSS('height', '100px'); 129 | }); 130 | 131 | test('applies manual polyfill', async ({ page }) => { 132 | const applyButton = page.locator('#apply-polyfill-manually'); 133 | await applyButton.click(); 134 | await expect(applyButton).toBeDisabled(); 135 | const anchorBox = (await page.locator('#my-anchor-manual').boundingBox())!; 136 | const target1Box = (await page 137 | .locator('#my-target-manual-style-el') 138 | .boundingBox())!; 139 | const target2Box = (await page 140 | .locator('#my-target-manual-link-el') 141 | .boundingBox())!; 142 | const target3Box = (await page 143 | .locator('#my-target-manual-inline-style') 144 | .boundingBox())!; 145 | 146 | expect(target1Box.x + target1Box.width).toBeCloseTo(anchorBox.x, 0); 147 | expect(target1Box.y + target1Box.height).toBeCloseTo(anchorBox.y, 0); 148 | 149 | expect(target2Box.x).toBeCloseTo(anchorBox.x + anchorBox.width, 0); 150 | expect(target2Box.y + target2Box.height).toBeCloseTo(anchorBox.y, 0); 151 | 152 | expect(target3Box.x).toBeCloseTo(anchorBox.x + anchorBox.width, 0); 153 | expect(target3Box.y).toBeCloseTo(anchorBox.y + anchorBox.height, 0); 154 | }); 155 | 156 | test('applies manual polyfill for multiple elements separately', async ({ 157 | page, 158 | }) => { 159 | const buttonContainer = page.locator('#anchor-manual-test-buttons'); 160 | await buttonContainer.evaluate((node: HTMLDivElement) => { 161 | node.hidden = false; 162 | }); 163 | await buttonContainer.waitFor({ state: 'visible' }); 164 | 165 | const prepareButton = page.locator('#prepare-manual-polyfill'); 166 | await prepareButton.click(); 167 | 168 | const anchorBox = (await page.locator('#my-anchor-manual').boundingBox())!; 169 | const target1Box = (await page 170 | .locator('#my-target-manual-style-el') 171 | .boundingBox())!; 172 | const target2Box = (await page 173 | .locator('#my-target-manual-link-el') 174 | .boundingBox())!; 175 | const target3Box = (await page 176 | .locator('#my-target-manual-inline-style') 177 | .boundingBox())!; 178 | 179 | expect(target1Box.x + target1Box.width).not.toBeCloseTo(anchorBox.x, 0); 180 | expect(target1Box.y + target1Box.height).not.toBeCloseTo(anchorBox.y, 0); 181 | 182 | expect(target2Box.x).not.toBeCloseTo(anchorBox.x + anchorBox.width, 0); 183 | expect(target2Box.y + target2Box.height).not.toBeCloseTo(anchorBox.y, 0); 184 | 185 | expect(target3Box.x).not.toBeCloseTo(anchorBox.x + anchorBox.width, 0); 186 | expect(target3Box.y).not.toBeCloseTo(anchorBox.y + anchorBox.height, 0); 187 | 188 | const set1Button = page.locator('#apply-polyfill-manually-set1'); 189 | const set2Button = page.locator('#apply-polyfill-manually-set2'); 190 | 191 | await set1Button.click(); 192 | await expect(set1Button).toBeDisabled(); 193 | 194 | const newTarget1Box = (await page 195 | .locator('#my-target-manual-style-el') 196 | .boundingBox())!; 197 | 198 | expect(newTarget1Box.x + newTarget1Box.width).toBeCloseTo(anchorBox.x, 0); 199 | expect(newTarget1Box.y + newTarget1Box.height).toBeCloseTo(anchorBox.y, 0); 200 | 201 | await set2Button.click(); 202 | await expect(set2Button).toBeDisabled(); 203 | 204 | const newTarget2Box = (await page 205 | .locator('#my-target-manual-link-el') 206 | .boundingBox())!; 207 | const newTarget3Box = (await page 208 | .locator('#my-target-manual-inline-style') 209 | .boundingBox())!; 210 | 211 | expect(newTarget2Box.x).toBeCloseTo(anchorBox.x + anchorBox.width, 0); 212 | expect(newTarget2Box.y + newTarget2Box.height).toBeCloseTo(anchorBox.y, 0); 213 | 214 | expect(newTarget3Box.x).toBeCloseTo(anchorBox.x + anchorBox.width, 0); 215 | expect(newTarget3Box.y).toBeCloseTo(anchorBox.y + anchorBox.height, 0); 216 | }); 217 | 218 | test('applies manual polyfill with automatic inline style polyfill', async ({ 219 | page, 220 | }) => { 221 | const buttonContainer = page.locator('#anchor-manual-test-buttons'); 222 | await buttonContainer.evaluate((node: HTMLDivElement) => { 223 | node.hidden = false; 224 | }); 225 | await buttonContainer.waitFor({ state: 'visible' }); 226 | 227 | const prepareButton = page.locator('#prepare-manual-polyfill'); 228 | await prepareButton.click(); 229 | 230 | const anchorBox = (await page.locator('#my-anchor-manual').boundingBox())!; 231 | const target1Box = (await page 232 | .locator('#my-target-manual-style-el') 233 | .boundingBox())!; 234 | const target2Box = (await page 235 | .locator('#my-target-manual-link-el') 236 | .boundingBox())!; 237 | const target3Box = (await page 238 | .locator('#my-target-manual-inline-style') 239 | .boundingBox())!; 240 | 241 | expect(target1Box.x + target1Box.width).not.toBeCloseTo(anchorBox.x, 0); 242 | expect(target1Box.y + target1Box.height).not.toBeCloseTo(anchorBox.y, 0); 243 | 244 | expect(target2Box.x).not.toBeCloseTo(anchorBox.x + anchorBox.width, 0); 245 | expect(target2Box.y + target2Box.height).not.toBeCloseTo(anchorBox.y, 0); 246 | 247 | expect(target3Box.x).not.toBeCloseTo(anchorBox.x + anchorBox.width, 0); 248 | expect(target3Box.y).not.toBeCloseTo(anchorBox.y + anchorBox.height, 0); 249 | 250 | const set3Button = page.locator('#apply-polyfill-manually-set3'); 251 | 252 | await set3Button.click(); 253 | await expect(set3Button).toBeDisabled(); 254 | 255 | const newTarget1Box = (await page 256 | .locator('#my-target-manual-style-el') 257 | .boundingBox())!; 258 | 259 | const newTarget2Box = (await page 260 | .locator('#my-target-manual-link-el') 261 | .boundingBox())!; 262 | const newTarget3Box = (await page 263 | .locator('#my-target-manual-inline-style') 264 | .boundingBox())!; 265 | 266 | expect(newTarget1Box.x + newTarget1Box.width).toBeCloseTo(anchorBox.x, 0); 267 | expect(newTarget1Box.y + newTarget1Box.height).toBeCloseTo(anchorBox.y, 0); 268 | 269 | expect(newTarget2Box.x).not.toBeCloseTo(anchorBox.x + anchorBox.width, 0); 270 | expect(newTarget2Box.y + newTarget2Box.height).not.toBeCloseTo( 271 | anchorBox.y, 272 | 0, 273 | ); 274 | 275 | expect(newTarget3Box.x).toBeCloseTo(anchorBox.x + anchorBox.width, 0); 276 | expect(newTarget3Box.y).toBeCloseTo(anchorBox.y + anchorBox.height, 0); 277 | }); 278 | -------------------------------------------------------------------------------- /tests/e2e/position-area.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Page, test } from '@playwright/test'; 2 | 3 | import { expectWithinOne } from './utils.js'; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | // Listen for all console logs 7 | // eslint-disable-next-line no-console 8 | page.on('console', (msg) => console.log(msg.text())); 9 | await page.goto('/position-area.html'); 10 | }); 11 | 12 | const btnSelector = '#apply-polyfill'; 13 | 14 | async function applyPolyfill(page: Page) { 15 | const btn = page.locator(btnSelector); 16 | await btn.click(); 17 | return await expect(btn).toBeDisabled(); 18 | } 19 | 20 | test('applies polyfill for position-area`', async ({ page }) => { 21 | await applyPolyfill(page); 22 | const section = page.locator('#spanleft-top'); 23 | const anchor = section.locator('.anchor'); 24 | const anchorBox = await anchor.boundingBox(); 25 | 26 | const targetWrapper = section.locator('POLYFILL-POSITION-AREA'); 27 | const targetWrapperBox = await targetWrapper.boundingBox(); 28 | const target = targetWrapper.locator('.target'); 29 | 30 | await expect(target).toHaveCSS('justify-self', 'end'); 31 | await expect(target).toHaveCSS('align-self', 'end'); 32 | await expectWithinOne(targetWrapper, 'top', 0); 33 | await expectWithinOne(targetWrapper, 'left', 0); 34 | 35 | // Right sides should be aligned 36 | expect(targetWrapperBox!.x + targetWrapperBox!.width).toBeCloseTo( 37 | anchorBox!.x + anchorBox!.width, 38 | 0, 39 | ); 40 | // Target bottom should be aligned with anchor top 41 | expect(targetWrapperBox!.y + targetWrapperBox!.height).toBeCloseTo( 42 | anchorBox!.y, 43 | 0, 44 | ); 45 | }); 46 | test('applies to declarations with different containing blocks`', async ({ 47 | page, 48 | }) => { 49 | await applyPolyfill(page); 50 | const section = page.locator('#different-containers'); 51 | 52 | // get elements 53 | const container1 = section.getByTestId('container1'); 54 | const container2 = section.getByTestId('container2'); 55 | const anchor1 = container1.locator('.anchor'); 56 | const anchor1Box = await anchor1.boundingBox(); 57 | const anchor2 = container2.locator('.anchor'); 58 | const anchor2Box = await anchor2.boundingBox(); 59 | const target1Wrapper = container1.locator('POLYFILL-POSITION-AREA'); 60 | const target1WrapperBox = await target1Wrapper.boundingBox(); 61 | const target2Wrapper = container2.locator('POLYFILL-POSITION-AREA'); 62 | const target2WrapperBox = await target2Wrapper.boundingBox(); 63 | const target1 = target1Wrapper.locator('.target'); 64 | const target2 = target2Wrapper.locator('.target'); 65 | 66 | // test container 1 67 | await expect(target1).toHaveCSS('justify-self', 'start'); 68 | await expect(target1).toHaveCSS('align-self', 'start'); 69 | await expectWithinOne(target1Wrapper, 'bottom', 0); 70 | await expectWithinOne(target1Wrapper, 'right', 0); 71 | 72 | // Target Left should be aligned with anchor right 73 | expect(target1WrapperBox!.x).toBeCloseTo( 74 | anchor1Box!.x + anchor1Box!.width, 75 | 0, 76 | ); 77 | // Target top should be aligned with anchor bottom 78 | expect(target1WrapperBox!.y).toBeCloseTo( 79 | anchor1Box!.y + anchor1Box!.height, 80 | 0, 81 | ); 82 | 83 | // test container 2 84 | await expect(target2).toHaveCSS('justify-self', 'start'); 85 | await expect(target2).toHaveCSS('align-self', 'start'); 86 | await expectWithinOne(target2Wrapper, 'bottom', 0); 87 | await expectWithinOne(target2Wrapper, 'right', 0); 88 | 89 | // Target Left should be aligned with anchor right 90 | expect(target2WrapperBox!.x).toBeCloseTo( 91 | anchor2Box!.x + anchor2Box!.width, 92 | 0, 93 | ); 94 | // Target top should be aligned with anchor bottom 95 | expect(target2WrapperBox!.y).toBeCloseTo( 96 | anchor2Box!.y + anchor2Box!.height, 97 | 0, 98 | ); 99 | }); 100 | 101 | test('respects cascade`', async ({ page }) => { 102 | await applyPolyfill(page); 103 | const section = page.locator('#spanleft-top'); 104 | const anchor = section.locator('.anchor'); 105 | const anchorBox = await anchor.boundingBox(); 106 | 107 | const targetWrapper = section.locator('POLYFILL-POSITION-AREA'); 108 | const targetWrapperBox = await targetWrapper.boundingBox(); 109 | const target = targetWrapper.locator('.target'); 110 | 111 | await expect(target).toHaveCSS('justify-self', 'end'); 112 | await expect(target).toHaveCSS('align-self', 'end'); 113 | await expectWithinOne(targetWrapper, 'top', 0); 114 | await expectWithinOne(targetWrapper, 'left', 0); 115 | 116 | // Right sides should be aligned 117 | expect(targetWrapperBox!.x + targetWrapperBox!.width).toBeCloseTo( 118 | anchorBox!.x + anchorBox!.width, 119 | 0, 120 | ); 121 | // Target bottom should be aligned with anchor top 122 | expect(targetWrapperBox!.y + targetWrapperBox!.height).toBeCloseTo( 123 | anchorBox!.y, 124 | 0, 125 | ); 126 | }); 127 | test('applies logical properties based on writing mode`', async ({ page }) => { 128 | await applyPolyfill(page); 129 | const section = page.getByTestId('vertical-rl-rtl'); 130 | const anchor = section.locator('.anchor'); 131 | const anchorBox = await anchor.boundingBox(); 132 | 133 | const targetWrapper = section.locator('POLYFILL-POSITION-AREA'); 134 | const targetWrapperBox = await targetWrapper.boundingBox(); 135 | const target = targetWrapper.locator('.target'); 136 | expect(target).toHaveText('vertical-rl rtl'); 137 | 138 | await expect(target).toHaveCSS('justify-self', 'start'); 139 | await expect(target).toHaveCSS('align-self', 'start'); 140 | await expectWithinOne(targetWrapper, 'top', 0); 141 | await expectWithinOne(targetWrapper, 'left', 0); 142 | 143 | // Right side should be aligned with anchor left 144 | expect(targetWrapperBox!.x + targetWrapperBox!.width).toBeCloseTo( 145 | anchorBox!.x, 146 | 0, 147 | ); 148 | // Target bottom should be aligned with anchor top 149 | expect(targetWrapperBox!.y + targetWrapperBox!.height).toBeCloseTo( 150 | anchorBox!.y, 151 | 0, 152 | ); 153 | }); 154 | test('applies logical self properties based on writing mode`', async ({ 155 | page, 156 | }) => { 157 | await applyPolyfill(page); 158 | const section = page.getByTestId('self-vertical-lr-rtl'); 159 | const anchor = section.locator('.anchor'); 160 | const anchorBox = await anchor.boundingBox(); 161 | 162 | const targetWrapper = section.locator('POLYFILL-POSITION-AREA'); 163 | const targetWrapperBox = await targetWrapper.boundingBox(); 164 | const target = targetWrapper.locator('.target'); 165 | expect(target).toHaveText('vertical-lr rtl'); 166 | 167 | await expect(target).toHaveCSS('justify-self', 'start'); 168 | await expect(target).toHaveCSS('align-self', 'end'); 169 | await expectWithinOne(targetWrapper, 'top', 0); 170 | await expectWithinOne(targetWrapper, 'right', 0); 171 | 172 | // Left side should be aligned with anchor right 173 | expect(targetWrapperBox!.x).toBeCloseTo(anchorBox!.x + anchorBox!.width, 0); 174 | // Target bottom should be aligned with anchor top 175 | expect(targetWrapperBox!.y + targetWrapperBox!.height).toBeCloseTo( 176 | anchorBox!.y, 177 | 0, 178 | ); 179 | }); 180 | -------------------------------------------------------------------------------- /tests/e2e/utils.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator } from '@playwright/test'; 2 | 3 | export async function expectWithinOne( 4 | locator: Locator, 5 | attr: string, 6 | expected: number, 7 | not?: boolean, 8 | ) { 9 | const getValue = async () => { 10 | const actual = await locator.evaluate( 11 | (node: HTMLElement, attribute: string) => 12 | window.getComputedStyle(node).getPropertyValue(attribute), 13 | attr, 14 | ); 15 | return Number(actual.slice(0, -2)); 16 | }; 17 | if (not) { 18 | return expect 19 | .poll(getValue, { timeout: 10 * 1000 }) 20 | .not.toBeCloseTo(expected, 0); 21 | } 22 | return expect.poll(getValue, { timeout: 10 * 1000 }).toBeCloseTo(expected, 0); 23 | } 24 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | import { cascadeCSS } from '../src/cascade.js'; 6 | import { type StyleData } from '../src/utils.js'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | export const getSampleCSS = (name: string) => 12 | cascadeCSSForTest( 13 | fs.readFileSync(path.join(__dirname, '../public', `${name}.css`), { 14 | encoding: 'utf8', 15 | }), 16 | ); 17 | 18 | export const sampleBaseCSS = '.a { color: red; } .b { color: green; }'; 19 | 20 | /** 21 | * Update a CSS string used in tests by running it through `cascadeCSS`. 22 | */ 23 | export function cascadeCSSForTest(css: string) { 24 | const styleObj: StyleData = { el: null!, css }; 25 | cascadeCSS([styleObj]); 26 | return styleObj.css; 27 | } 28 | 29 | export const requestWithCSSType = (css: string) => ({ 30 | body: css, 31 | headers: { 'Content-Type': 'text/css' }, 32 | }); 33 | -------------------------------------------------------------------------------- /tests/report.liquid: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | [WPT Report] {{ sourceBranch }} 6 | 15 | 16 | 17 | 46 | 47 | 48 | 49 | 50 | 51 | {%- for browser in results %} 52 | {%- for version in browser.versions %} 53 | 57 | 58 | 59 | {%- for item in resultsByPath %} 60 | {%- assign testPath = item[0] -%} 61 | {%- assign testResults = item[1] -%} 62 | 63 | 76 | {%- for result in testResults %} 77 | {%- assign pass = result.summary[0] -%} 78 | {%- assign total = result.summary[1] -%} 79 | 82 | {% endfor -%} 83 | 84 | {% endfor -%} 85 | 86 |
Test{{ browser.name }}
{{ version.name }} 54 | {% endfor -%} 55 | {% endfor -%} 56 |
64 | {%- if wptRepo and wptCommit %} 65 | 66 | {{ testPath }} 67 | 68 | {% else %} 69 | {{ testPath }} 70 | {% endif -%} 71 | 72 | 🌐 73 | 🏠 74 | 75 | 80 | {{ pass }} / {{ total }} 81 |
87 | 88 | 89 | -------------------------------------------------------------------------------- /tests/report.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { Liquid } from 'liquidjs'; 3 | 4 | import type { BrowserDefinition, ResultData } from './wpt.js'; 5 | 6 | export interface VersionResult { 7 | name: string; 8 | summary: [number, number]; 9 | } 10 | 11 | const localDomain = 'http://web-platform.test:8000/'; 12 | 13 | export default function writeReport( 14 | results: BrowserDefinition[], 15 | name?: string, 16 | ) { 17 | const timeStamp = new Date().toISOString(); 18 | const fileName = name || timeStamp.replaceAll(':', '-'); 19 | if (!fs.existsSync('test-results')) fs.mkdirSync('test-results'); 20 | 21 | // Save the raw JSON data to debug / process further 22 | fs.writeFileSync(`test-results/${fileName}.json`, JSON.stringify(results)); 23 | 24 | // Create an object mapping each test path with the results for all versions 25 | const resultsByPath: Record = {}; 26 | results.forEach((browser) => { 27 | browser.versions.forEach((version) => { 28 | const data = version.data as ResultData; 29 | data.results?.forEach(([longPath, result]) => { 30 | const path = longPath.replace(localDomain, ''); 31 | const passed = result.tests?.reduce( 32 | (total, test) => total + (test.status ? 0 : 1), 33 | 0, 34 | ); 35 | const total = result.tests?.length; 36 | const data: VersionResult = { 37 | name: `${browser.name} ${version.name}`, 38 | summary: total === undefined ? [0, -1] : [passed, total], 39 | }; 40 | resultsByPath[path] = [...(resultsByPath[path] || []), data]; 41 | }); 42 | }); 43 | }); 44 | 45 | // Render the HTML report 46 | const template = fs.readFileSync('tests/report.liquid', 'utf-8'); 47 | const context = { 48 | wptRepo: process.env.WPT_REPO, 49 | wptCommit: process.env.WPT_COMMIT, 50 | sourceRepo: process.env.SOURCE_REPO, 51 | sourceCommit: process.env.SOURCE_COMMIT, 52 | sourceBranch: process.env.SOURCE_BRANCH, 53 | timeStamp, 54 | localDomain, 55 | results, 56 | resultsByPath, 57 | }; 58 | const output = new Liquid().parseAndRenderSync(template, context); 59 | // Save with timestamp and as `index.html` to load the latest report by default 60 | fs.writeFileSync(`test-results/${fileName}.html`, output); 61 | fs.writeFileSync(`test-results/index.html`, output); 62 | } 63 | -------------------------------------------------------------------------------- /tests/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "isolatedModules": false, 5 | "types": ["vitest/globals"], 6 | "noEmit": true, 7 | "skipLibCheck": true, 8 | "allowJs": true, 9 | "noImplicitAny": false 10 | }, 11 | "include": [ 12 | "./**/*.ts", 13 | "./../src/@types/", 14 | "./../*.config.ts", 15 | "./../*.config.js" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/unit/__snapshots__/position-area.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`position-area > activeWrapperStyles > returns the active styles 1`] = `" [data-anchor-position-wrapper="selectorUUID"][data-pa-wrapper-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); --pa-value-justify-self: var(targetUUID-justify-self); --pa-value-align-self: var(targetUUID-align-self); } "`; 4 | -------------------------------------------------------------------------------- /tests/unit/cascade.test.ts: -------------------------------------------------------------------------------- 1 | import { cascadeCSS, SHIFTED_PROPERTIES } from '../../src/cascade.js'; 2 | import { INSTANCE_UUID, type StyleData } from '../../src/utils.js'; 3 | import { cascadeCSSForTest, getSampleCSS } from './../helpers.js'; 4 | 5 | describe('cascadeCSS', () => { 6 | it('moves position-anchor to custom property', () => { 7 | const srcCSS = getSampleCSS('position-anchor'); 8 | const css = cascadeCSSForTest(srcCSS); 9 | expect(css).toContain( 10 | `${SHIFTED_PROPERTIES['position-anchor']}:--my-position-anchor-b`, 11 | ); 12 | expect(css).toContain( 13 | `${SHIFTED_PROPERTIES['position-anchor']}:--my-position-anchor-a`, 14 | ); 15 | }); 16 | it('adds insets with anchors as custom properties', async () => { 17 | const srcCSS = getSampleCSS('position-try-tactics'); 18 | const styleData: StyleData[] = [ 19 | { css: srcCSS, el: document.createElement('div') }, 20 | ]; 21 | const cascadeCausedChanges = await cascadeCSS(styleData); 22 | expect(cascadeCausedChanges).toBe(true); 23 | const { css } = styleData[0]; 24 | expect(css).toContain(`--bottom-${INSTANCE_UUID}:anchor(top)`); 25 | expect(css).toContain(`--left-${INSTANCE_UUID}:anchor(right)`); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/unit/fallback.test.ts: -------------------------------------------------------------------------------- 1 | import type * as csstree from 'css-tree'; 2 | 3 | import { 4 | applyTryTacticsToSelector, 5 | getPositionTryDeclaration, 6 | } from '../../src/fallback.js'; 7 | import { getAST, INSTANCE_UUID } from '../../src/utils.js'; 8 | 9 | const setup = (styles: string) => { 10 | document.body.innerHTML = `
Test
`; 11 | }; 12 | const propWrap = (property: string) => `--${property}-${INSTANCE_UUID}`; 13 | describe('fallback', () => { 14 | afterAll(() => { 15 | document.body.innerHTML = ''; 16 | }); 17 | describe('applyTryTactics', () => { 18 | describe('flip-block', () => { 19 | it.each([ 20 | [ 21 | 'flips raw values', 22 | `${propWrap('bottom')}:12px`, 23 | { top: '12px', bottom: 'revert' }, 24 | ], 25 | ['ignores non-inset values', `red:12px`, {}], 26 | [ 27 | 'flips top and bottom anchors', 28 | `${propWrap('bottom')}: anchor(top);${propWrap('top')}:anchor(--a top)`, 29 | { 30 | top: 'anchor(bottom)', 31 | bottom: 'anchor(--a bottom)', 32 | }, 33 | ], 34 | [ 35 | 'flips top and bottom logical anchors', 36 | `${propWrap('bottom')}: anchor(start);${propWrap('top')}:anchor(end)`, 37 | { 38 | top: 'anchor(end)', 39 | bottom: 'anchor(start)', 40 | }, 41 | ], 42 | [ 43 | 'flips top and bottom logical self anchors', 44 | `${propWrap('bottom')}: anchor(self-start);${propWrap('top')}:anchor(self-end)`, 45 | { 46 | top: 'anchor(self-end)', 47 | bottom: 'anchor(self-start)', 48 | }, 49 | ], 50 | [ 51 | 'flips anchor functions that are nested', 52 | `${propWrap('bottom')}: calc(anchor(top) + 5px);${propWrap('top')}:calc(calc(anchor(--a top) + 5px) - 0.5em)`, 53 | { 54 | top: 'calc(anchor(bottom) + 5px)', 55 | bottom: 'calc(calc(anchor(--a bottom) + 5px) - 0.5em)', 56 | }, 57 | ], 58 | [ 59 | 'does not flip left and right anchors', 60 | `${propWrap('left')}: anchor(right);${propWrap('right')}:anchor(--a left)`, 61 | { 62 | left: 'anchor(right)', 63 | right: 'anchor(--a left)', 64 | }, 65 | ], 66 | [ 67 | 'position-area left top', 68 | `${propWrap('position-area')}: left top;`, 69 | { 70 | 'position-area': 'left bottom', 71 | }, 72 | ], 73 | [ 74 | 'position-area right bottom', 75 | `${propWrap('position-area')}: right bottom;`, 76 | { 77 | 'position-area': 'right top', 78 | }, 79 | ], 80 | [ 81 | 'margin shorthand', 82 | `${propWrap('margin')}: 5px 15px 25px 35px;`, 83 | { 84 | margin: '25px 15px 5px 35px', 85 | }, 86 | ], 87 | [ 88 | 'margin shorthand 3 values', 89 | `${propWrap('margin')}: 5px 15px 25px;`, 90 | { 91 | margin: '25px 15px 5px', 92 | }, 93 | ], 94 | [ 95 | 'margin long hands', 96 | `${propWrap('margin-top')}: 5px; 97 | ${propWrap('margin-bottom')}: 15px; 98 | ${propWrap('margin-left')}: 25px; 99 | ${propWrap('margin-right')}: 35px; 100 | `, 101 | { 102 | 'margin-bottom': '5px', 103 | 'margin-top': '15px', 104 | 'margin-left': '25px', 105 | 'margin-right': '35px', 106 | }, 107 | ], 108 | [ 109 | 'margin medium hands', 110 | `${propWrap('margin-block')}: 5px 25px; 111 | ${propWrap('margin-inline')}: 15px 35px; 112 | `, 113 | { 114 | 'margin-block': '25px 5px', 115 | 'margin-inline': '15px 35px', 116 | }, 117 | ], 118 | ])('%s', (name, styles, expected) => { 119 | setup(styles); 120 | expect(applyTryTacticsToSelector('#ref', ['flip-block'])).toMatchObject( 121 | expected, 122 | ); 123 | }); 124 | }); 125 | 126 | describe('flip-inline', () => { 127 | it.each([ 128 | [ 129 | 'flips raw values', 130 | `${propWrap('left')}:12px`, 131 | { right: '12px', left: 'revert' }, 132 | ], 133 | ['ignores non-inset values', `red:12px`, {}], 134 | [ 135 | 'flips left and right anchors', 136 | `${propWrap('left')}: anchor(right);${propWrap('right')}:anchor(--a left)`, 137 | { 138 | right: 'anchor(left)', 139 | left: 'anchor(--a right)', 140 | }, 141 | ], 142 | [ 143 | 'flips left and right logical anchors', 144 | `${propWrap('right')}: anchor(start);${propWrap('left')}:anchor(end)`, 145 | { 146 | left: 'anchor(end)', 147 | right: 'anchor(start)', 148 | }, 149 | ], 150 | [ 151 | 'flips left and right logical self anchors', 152 | `${propWrap('right')}: anchor(self-start);${propWrap('left')}:anchor(self-end)`, 153 | { 154 | left: 'anchor(self-end)', 155 | right: 'anchor(self-start)', 156 | }, 157 | ], 158 | [ 159 | 'flips anchor functions that are nested', 160 | `${propWrap('right')}: calc(anchor(left) + 5px);${propWrap('left')}:calc(calc(anchor(--a left) + 5px) - 0.5em)`, 161 | { 162 | left: 'calc(anchor(right) + 5px)', 163 | right: 'calc(calc(anchor(--a right) + 5px) - 0.5em)', 164 | }, 165 | ], 166 | [ 167 | 'does not flip top and bottom anchors', 168 | `${propWrap('top')}: anchor(bottom);${propWrap('bottom')}:anchor(--a top)`, 169 | { 170 | top: 'anchor(bottom)', 171 | bottom: 'anchor(--a top)', 172 | }, 173 | ], 174 | [ 175 | 'position-area left top', 176 | `${propWrap('position-area')}: left top;`, 177 | { 178 | 'position-area': 'right top', 179 | }, 180 | ], 181 | [ 182 | 'position-area right bottom', 183 | `${propWrap('position-area')}: right bottom;`, 184 | { 185 | 'position-area': 'left bottom', 186 | }, 187 | ], 188 | [ 189 | 'margin shorthand', 190 | `${propWrap('margin')}: 5px 15px 25px 35px;`, 191 | { 192 | margin: '5px 35px 25px 15px', 193 | }, 194 | ], 195 | [ 196 | 'margin shorthand 3 values', 197 | `${propWrap('margin')}: 5px 15px 25px;`, 198 | { 199 | margin: '5px 15px 25px', 200 | }, 201 | ], 202 | [ 203 | 'margin long hands', 204 | `${propWrap('margin-top')}: 5px; 205 | ${propWrap('margin-bottom')}: 15px; 206 | ${propWrap('margin-left')}: 25px; 207 | ${propWrap('margin-right')}: 35px; 208 | `, 209 | { 210 | 'margin-top': '5px', 211 | 'margin-bottom': '15px', 212 | 'margin-left': '35px', 213 | 'margin-right': '25px', 214 | }, 215 | ], 216 | [ 217 | 'margin medium hands', 218 | `${propWrap('margin-block')}: 5px 25px; 219 | ${propWrap('margin-inline')}: 15px 35px; 220 | `, 221 | { 222 | 'margin-block': '5px 25px', 223 | 'margin-inline': '35px 15px', 224 | }, 225 | ], 226 | ])('%s', (name, styles, expected) => { 227 | setup(styles); 228 | expect( 229 | applyTryTacticsToSelector('#ref', ['flip-inline']), 230 | ).toMatchObject(expected); 231 | }); 232 | }); 233 | describe('flip-start', () => { 234 | it.each([ 235 | ['ignores non-inset values', `red:12px`, {}], 236 | [ 237 | 'flips physical anchors right to bottom', 238 | `${propWrap('right')}: anchor(left);`, 239 | { 240 | right: 'revert', 241 | bottom: 'anchor(top)', 242 | }, 243 | ], 244 | [ 245 | 'flips physical anchors bottom to right', 246 | `${propWrap('bottom')}: anchor(top);`, 247 | { 248 | bottom: 'revert', 249 | right: 'anchor(left)', 250 | }, 251 | ], 252 | [ 253 | 'flips physical anchors left to top', 254 | `${propWrap('left')}: anchor(right);`, 255 | { 256 | left: 'revert', 257 | top: 'anchor(bottom)', 258 | }, 259 | ], 260 | [ 261 | 'flips physical anchors top to left', 262 | `${propWrap('top')}: anchor(bottom);`, 263 | { 264 | top: 'revert', 265 | left: 'anchor(right)', 266 | }, 267 | ], 268 | [ 269 | 'flips bottom and right logical anchors', 270 | `${propWrap('right')}: anchor(start);`, 271 | { 272 | bottom: 'anchor(start)', 273 | right: 'revert', 274 | }, 275 | ], 276 | [ 277 | 'flips left and top logical anchors', 278 | `${propWrap('left')}: anchor(end);`, 279 | { 280 | top: 'anchor(end)', 281 | left: 'revert', 282 | }, 283 | ], 284 | [ 285 | 'flips left and top logical self anchors', 286 | `${propWrap('left')}: anchor(self-end);`, 287 | { 288 | left: 'revert', 289 | top: 'anchor(self-end)', 290 | }, 291 | ], 292 | [ 293 | 'position-area left top', 294 | `${propWrap('position-area')}: left top;`, 295 | { 296 | 'position-area': 'left top', 297 | }, 298 | ], 299 | // [ 300 | // 'position-area left bottom', 301 | // `${propWrap('position-area')}: left bottom;`, 302 | // { 303 | // 'position-area': 'right top', 304 | // }, 305 | // ], 306 | [ 307 | 'position-area right bottom', 308 | `${propWrap('position-area')}: right bottom;`, 309 | { 310 | 'position-area': 'right bottom', 311 | }, 312 | ], 313 | // [ 314 | // 'position-area right top', 315 | // `${propWrap('position-area')}: right top;`, 316 | // { 317 | // 'position-area': 'left bottom', 318 | // }, 319 | // ], 320 | // [ 321 | // 'margin shorthand', 322 | // `${propWrap('margin')}: 5px 15px 25px 35px;`, 323 | // { 324 | // 'margin': '5px 35px 25px 15px' 325 | // }, 326 | // ], 327 | // [ 328 | // 'margin long hands', 329 | // `${propWrap('margin-top')}: 5px; 330 | // ${propWrap('margin-bottom')}: 15px; 331 | // ${propWrap('margin-left')}: 25px; 332 | // ${propWrap('margin-right')}: 35px; 333 | // `, 334 | // { 335 | // 'margin-top': '5px', 336 | // 'margin-bottom': '15px', 337 | // 'margin-left': '35px', 338 | // 'margin-right': '25px', 339 | // }, 340 | // ], 341 | // [ 342 | // 'margin medium hands', 343 | // `${propWrap('margin-block')}: 5px 25px; 344 | // ${propWrap('margin-inline')}: 15px 35px; 345 | // `, 346 | // { 347 | // 'margin-block': '5px 25px', 348 | // 'margin-inline': '35px 15px', 349 | // }, 350 | // ], 351 | ])('%s', (name, styles, expected) => { 352 | setup(styles); 353 | expect(applyTryTacticsToSelector('#ref', ['flip-start'])).toMatchObject( 354 | expected, 355 | ); 356 | }); 357 | }); 358 | }); 359 | 360 | describe('getPositionTryDeclaration', () => { 361 | const getResult = (css: string) => { 362 | const res = getAST(`a{${css}}`); 363 | 364 | return getPositionTryDeclaration( 365 | ((res as csstree.StyleSheet).children.first as csstree.Rule).block 366 | .children.first as csstree.Declaration, 367 | ); 368 | }; 369 | 370 | it('parses order', () => { 371 | const res = getResult('position-try: most-inline-size flip-block'); 372 | expect(res).toMatchObject({ 373 | order: 'most-inline-size', 374 | options: [{ tactics: ['flip-block'], type: 'try-tactic' }], 375 | }); 376 | }); 377 | 378 | it('parses try-tactics', () => { 379 | const res = getResult('position-try: flip-block, flip-inline;'); 380 | expect(res).toMatchObject({ 381 | order: undefined, 382 | options: [ 383 | { tactics: ['flip-block'], type: 'try-tactic' }, 384 | { tactics: ['flip-inline'], type: 'try-tactic' }, 385 | ], 386 | }); 387 | }); 388 | it('parses try-tactics with multiple', () => { 389 | const res = getResult( 390 | 'position-try: flip-block flip-start, flip-inline;', 391 | ); 392 | expect(res).toMatchObject({ 393 | order: undefined, 394 | options: [ 395 | { tactics: ['flip-block', 'flip-start'], type: 'try-tactic' }, 396 | { tactics: ['flip-inline'], type: 'try-tactic' }, 397 | ], 398 | }); 399 | }); 400 | it('parses position-try rules', () => { 401 | const res = getResult('position-try: --top, --bottom;'); 402 | expect(res).toMatchObject({ 403 | order: undefined, 404 | options: [ 405 | { atRule: '--top', type: 'at-rule' }, 406 | { atRule: '--bottom', type: 'at-rule' }, 407 | ], 408 | }); 409 | }); 410 | it('parses position-try rules modified by try tactic', () => { 411 | const res = getResult( 412 | 'position-try: --top flip-block, flip-inline --bottom;', 413 | ); 414 | expect(res).toMatchObject({ 415 | order: undefined, 416 | options: [ 417 | { 418 | atRule: '--top', 419 | tactics: ['flip-block'], 420 | type: 'at-rule-with-try-tactic', 421 | }, 422 | { 423 | atRule: '--bottom', 424 | tactics: ['flip-inline'], 425 | type: 'at-rule-with-try-tactic', 426 | }, 427 | ], 428 | }); 429 | }); 430 | }); 431 | }); 432 | -------------------------------------------------------------------------------- /tests/unit/fetch.test.ts: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | 3 | import { fetchCSS } from '../../src/fetch.js'; 4 | import { getSampleCSS, requestWithCSSType } from '../helpers.js'; 5 | 6 | describe('fetch stylesheet', () => { 7 | beforeAll(() => { 8 | // Set up our document head 9 | document.head.innerHTML = ` 10 | 11 | 12 | 13 | 16 | `; 17 | }); 18 | 19 | afterAll(() => { 20 | document.head.innerHTML = ''; 21 | }); 22 | 23 | it('fetches CSS', async () => { 24 | const css = getSampleCSS('anchor-positioning'); 25 | fetchMock.getOnce('end:sample.css', requestWithCSSType(css)); 26 | const styleData = await fetchCSS(); 27 | 28 | expect(styleData).toHaveLength(2); 29 | expect(styleData[0].url?.toString()).toBe(`${location.origin}/sample.css`); 30 | expect(styleData[0].css).toEqual(css); 31 | expect(styleData[1].url).toBeUndefined(); 32 | expect(styleData[1].css.trim()).toBe('p { color: red; }'); 33 | }); 34 | }); 35 | 36 | describe('fetch inline styles', () => { 37 | beforeAll(() => { 38 | document.head.innerHTML = ` 39 | 40 | 41 | 42 | 45 | `; 46 | document.body.innerHTML = ` 47 |
48 |
57 | Target 58 |
59 |
71 | Anchor 72 |
73 |
74 | `; 75 | }); 76 | 77 | afterAll(() => { 78 | document.head.innerHTML = ''; 79 | document.body.innerHTML = ''; 80 | }); 81 | 82 | it('fetch returns inline CSS', async () => { 83 | const css = getSampleCSS('anchor-positioning'); 84 | fetchMock.getOnce('end:sample.css', requestWithCSSType(css)); 85 | const styleData = await fetchCSS(); 86 | 87 | expect(styleData).toHaveLength(4); 88 | expect(styleData[2].url).toBeUndefined(); 89 | expect(styleData[3].url).toBeUndefined(); 90 | expect(styleData[2].css.trim()).toContain('[data-has-inline-styles='); 91 | expect(styleData[2].css.trim()).toContain( 92 | 'top: anchor(--my-anchor-in-line end)', 93 | ); 94 | expect(styleData[3].css.trim()).toContain('[data-has-inline-styles='); 95 | expect(styleData[3].css.trim()).toContain( 96 | 'anchor-name: --my-anchor-in-line', 97 | ); 98 | }); 99 | }); 100 | 101 | describe('fetch styles manually', () => { 102 | let target5Css: string; 103 | let target6Css: string; 104 | 105 | beforeAll(() => { 106 | document.head.innerHTML = ` 107 | 110 | 117 | 124 | 125 | 126 | `; 127 | document.body.innerHTML = ` 128 |
Anchor
129 |
Target 1
130 |
Target 2
131 |
Target 3
136 |
Target 3
141 |
Target 5
142 |
Target 6
143 | `; 144 | target5Css = ` 145 | .target5 { 146 | position: absolute; 147 | left: anchor(--anchor center); 148 | bottom: anchor(--anchor top); 149 | } 150 | `; 151 | target6Css = ` 152 | .target6 { 153 | position: absolute; 154 | left: anchor(--anchor center); 155 | top: anchor(--anchor bottom); 156 | } 157 | `; 158 | }); 159 | 160 | afterAll(() => { 161 | document.head.innerHTML = ''; 162 | document.body.innerHTML = ''; 163 | }); 164 | 165 | it('fetches only inline styles if `elements` is empty', async () => { 166 | const styleData = await fetchCSS([]); 167 | 168 | expect(styleData).toHaveLength(2); 169 | }); 170 | 171 | it('fetches nothing if `elements` is empty and exclusing inline styles', async () => { 172 | const styleData = await fetchCSS([], true); 173 | 174 | expect(styleData).toHaveLength(0); 175 | }); 176 | 177 | it('fetches styles only from the given elements', async () => { 178 | fetchMock.getOnce('end:target5.css', requestWithCSSType(target5Css)); 179 | fetchMock.getOnce('end:target6.css', requestWithCSSType(target6Css)); 180 | 181 | const el1 = document.getElementById('el1')!; 182 | const el2 = document.getElementById('el2')!; 183 | const el3 = document.getElementById('el3')!; 184 | const el4 = document.getElementById('el4')!; 185 | const el5 = document.getElementById('el5')!; 186 | 187 | const styleData = await fetchCSS( 188 | [ 189 | el1, 190 | el2, 191 | el3, 192 | el4, 193 | // should be ignored 194 | el5, 195 | // @ts-expect-error should be ignored 196 | undefined, 197 | // @ts-expect-error should be ignored 198 | null, 199 | // @ts-expect-error should be ignored 200 | 123, 201 | ], 202 | true, 203 | ); 204 | 205 | expect(styleData).toHaveLength(4); 206 | 207 | expect(styleData[0].el).toBe(el1); 208 | expect(styleData[0].url).toBeUndefined(); 209 | expect(styleData[0].css).toContain('anchor-name: --anchor'); 210 | 211 | expect(styleData[1].el).toBe(el2); 212 | expect(styleData[1].url).toBeUndefined(); 213 | expect(styleData[1].css).toContain('right: anchor(--anchor left);'); 214 | expect(styleData[1].css).toContain('bottom: anchor(--anchor top);'); 215 | 216 | expect(styleData[2].el).toBe(el3); 217 | expect(styleData[2].url?.toString()).toBe(`${location.origin}/target5.css`); 218 | expect(styleData[2].css).toContain('left: anchor(--anchor center);'); 219 | expect(styleData[2].css).toContain('bottom: anchor(--anchor top);'); 220 | 221 | expect(styleData[3].el).toBe(el4); 222 | expect(styleData[3].url).toBeUndefined(); 223 | expect(styleData[3].css.trim()).toContain('[data-has-inline-styles='); 224 | expect(styleData[3].css).toContain('right: anchor(--anchor left);'); 225 | expect(styleData[3].css).toContain('top: anchor(--anchor bottom);'); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /tests/unit/polyfill.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAxis, 3 | getAxisProperty, 4 | getPixelValue, 5 | type GetPixelValueOpts, 6 | resolveLogicalSideKeyword, 7 | resolveLogicalSizeKeyword, 8 | } from '../../src/polyfill.js'; 9 | import { type AnchorSide, type AnchorSize } from '../../src/syntax.js'; 10 | 11 | describe('resolveLogicalSideKeyword', () => { 12 | it.each([ 13 | ['start', false, 0], 14 | ['start', true, 100], 15 | ['self-start', false, 0], 16 | ['self-start', true, 100], 17 | ['end', false, 100], 18 | ['end', true, 0], 19 | ['self-end', false, 100], 20 | ['self-end', true, 0], 21 | ['center', false, undefined], 22 | [10, false, 10], 23 | [10, true, 90], 24 | ] as [AnchorSide, boolean, number | undefined][])( 25 | 'resolves logical side keyword %s to %i', 26 | (side, rtl, expected) => { 27 | const result = resolveLogicalSideKeyword(side, rtl); 28 | 29 | expect(result).toEqual(expected); 30 | }, 31 | ); 32 | }); 33 | 34 | describe('resolveLogicalSizeKeyword', () => { 35 | it.each([ 36 | ['block', false, 'height'], 37 | ['block', true, 'width'], 38 | ['self-block', false, 'height'], 39 | ['self-block', true, 'width'], 40 | ['inline', false, 'width'], 41 | ['inline', true, 'height'], 42 | ['self-inline', false, 'width'], 43 | ['self-inline', true, 'height'], 44 | ] as [AnchorSize, boolean, string | undefined][])( 45 | 'resolves logical size keyword %s to %s', 46 | (size, vertical, expected) => { 47 | const result = resolveLogicalSizeKeyword(size, vertical); 48 | 49 | expect(result).toEqual(expected); 50 | }, 51 | ); 52 | }); 53 | 54 | describe('getAxis', () => { 55 | it.each([ 56 | ['top', 'y'], 57 | ['bottom', 'y'], 58 | ['left', 'x'], 59 | ['right', 'x'], 60 | ['--my-var', null], 61 | [undefined, null], 62 | ])('resolves position %s to axis %s', (position, expected) => { 63 | const result = getAxis(position); 64 | 65 | expect(result).toEqual(expected); 66 | }); 67 | }); 68 | 69 | describe('getAxisProperty', () => { 70 | it.each([ 71 | ['x', 'width'], 72 | ['y', 'height'], 73 | [null, null], 74 | ] as ['x' | 'y' | null, 'width' | 'height' | null][])( 75 | 'resolves axis %s to length property %s', 76 | (axis, expected) => { 77 | const result = getAxisProperty(axis); 78 | 79 | expect(result).toEqual(expected); 80 | }, 81 | ); 82 | }); 83 | 84 | describe('getPixelValue [anchor() fn]', () => { 85 | const anchorRect = { 86 | x: 10, 87 | y: 50, 88 | width: 20, 89 | height: 40, 90 | }; 91 | const obj = { 92 | anchorRect, 93 | fallback: '0px', 94 | targetEl: document.createElement('test'), 95 | }; 96 | 97 | beforeAll(() => { 98 | Object.defineProperty(window, 'getComputedStyle', { 99 | value: () => ({ 100 | getPropertyValue: () => { 101 | return 'ltr'; 102 | }, 103 | }), 104 | }); 105 | }); 106 | 107 | it.each([ 108 | [{ ...obj, anchorSide: 'left', targetProperty: 'left' }, '10px'], 109 | [ 110 | { ...obj, anchorSide: 'left', targetProperty: 'left', targetEl: null }, 111 | '0px', 112 | ], 113 | [{ ...obj, anchorSide: 'left', targetProperty: 'top' }, '0px'], 114 | [{ ...obj, anchorSide: 'left', targetProperty: 'width' }, '0px'], 115 | [{ ...obj, anchorSide: 'right', targetProperty: 'left' }, '30px'], 116 | [{ ...obj, anchorSide: 'top', targetProperty: 'top' }, '50px'], 117 | [{ ...obj, anchorSide: 'bottom', targetProperty: 'top' }, '90px'], 118 | [{ ...obj, anchorSide: 'center', targetProperty: 'top' }, '70px'], 119 | [{ ...obj, anchorSide: 'center', targetProperty: 'left' }, '20px'], 120 | [ 121 | { 122 | ...obj, 123 | anchorSide: 'center', 124 | fallback: '100px', 125 | }, 126 | '100px', 127 | ], 128 | [{ ...obj, anchorSide: 25, targetProperty: 'top' }, '60px'], 129 | [ 130 | { 131 | ...obj, 132 | anchorSide: 'end', 133 | targetProperty: 'left', 134 | }, 135 | '30px', 136 | ], 137 | [ 138 | { 139 | ...obj, 140 | anchorSide: 'start', 141 | fallback: '100px', 142 | }, 143 | '100px', 144 | ], 145 | ] as [GetPixelValueOpts, string][])( 146 | 'returns pixel value for anchor fn', 147 | async (opts, expected) => { 148 | const result = await getPixelValue(opts); 149 | 150 | expect(result).toEqual(expected); 151 | }, 152 | ); 153 | }); 154 | 155 | describe('getPixelValue [anchor-size() fn]', () => { 156 | const anchorRect = { 157 | x: 10, 158 | y: 50, 159 | width: 20, 160 | height: 40, 161 | }; 162 | const obj = { 163 | anchorRect, 164 | fallback: '0px', 165 | targetEl: document.createElement('test'), 166 | }; 167 | 168 | beforeAll(() => { 169 | Object.defineProperty(window, 'getComputedStyle', { 170 | value: () => ({ 171 | getPropertyValue: () => { 172 | return 'horizontal-tb'; 173 | }, 174 | }), 175 | }); 176 | }); 177 | 178 | it.each([ 179 | [{ ...obj, anchorSize: 'width', targetProperty: 'width' }, '20px'], 180 | [{ ...obj, anchorSize: 'width', targetProperty: 'color' }, '0px'], 181 | [{ ...obj, anchorSize: 'foo', targetProperty: 'width' }, '0px'], 182 | [{ ...obj, anchorSize: 'block', targetProperty: 'height' }, '40px'], 183 | [ 184 | { ...obj, anchorSize: 'width', targetProperty: 'width', targetEl: null }, 185 | '0px', 186 | ], 187 | ] as [GetPixelValueOpts, string][])( 188 | 'returns pixel value for anchor fn', 189 | async (opts, expected) => { 190 | const result = await getPixelValue(opts); 191 | 192 | expect(result).toEqual(expected); 193 | }, 194 | ); 195 | }); 196 | -------------------------------------------------------------------------------- /tests/unit/position-area.test.ts: -------------------------------------------------------------------------------- 1 | import type { Rule, StyleSheet } from 'css-tree'; 2 | 3 | import { 4 | activeWrapperStyles, 5 | axisForPositionAreaValue, 6 | dataForPositionAreaTarget, 7 | getPositionAreaDeclaration, 8 | wrapperForPositionedElement, 9 | } from '../../src/position-area.js'; 10 | import { getAST } from '../../src/utils.js'; 11 | 12 | const createPositionAreaNode = (input: string[]) => { 13 | const css = getAST(`a{position-area:${input.join(' ')}}`) as StyleSheet; 14 | return (css.children.first! as Rule).block.children.first!; 15 | }; 16 | 17 | const createEl = () => { 18 | const el = document.createElement('div'); 19 | return el; 20 | }; 21 | 22 | describe('position-area', () => { 23 | afterAll(() => { 24 | document.head.innerHTML = ''; 25 | document.body.innerHTML = ''; 26 | }); 27 | 28 | describe('axisForPositionAreaValue', () => { 29 | it.each([ 30 | ['block-start', 'block'], 31 | ['block-end', 'block'], 32 | ['top', 'block'], 33 | ['span-top', 'block'], 34 | ['span-self-block-end', 'block'], 35 | ['left', 'inline'], 36 | ['span-right', 'inline'], 37 | ['span-x-self-start', 'inline'], 38 | ['center', 'ambiguous'], 39 | ['span-all', 'ambiguous'], 40 | ['start', 'ambiguous'], 41 | ['end', 'ambiguous'], 42 | ['span-end', 'ambiguous'], 43 | ['self-span-start', 'ambiguous'], 44 | ])('%s as %s', (input, expected) => { 45 | expect(axisForPositionAreaValue(input)).toBe(expected); 46 | }); 47 | }); 48 | describe('parsePositionAreaValue', () => { 49 | // Valid cases 50 | it.each([ 51 | [['left', 'bottom'], { block: 'bottom', inline: 'left' }], 52 | [['bottom', 'left'], { block: 'bottom', inline: 'left' }], 53 | [['x-start', 'y-end'], { block: 'y-end', inline: 'x-start' }], 54 | ])('%s parses', (input, expected) => { 55 | expect( 56 | getPositionAreaDeclaration(createPositionAreaNode(input))?.values, 57 | ).toMatchObject(expected); 58 | }); 59 | 60 | // With ambiguous values 61 | it.each([ 62 | [['left', 'center'], { block: 'center', inline: 'left' }], 63 | [['center', 'left'], { block: 'center', inline: 'left' }], 64 | [['span-all', 'y-end'], { block: 'y-end', inline: 'span-all' }], 65 | ])('%s parses values', (input, expected) => { 66 | expect( 67 | getPositionAreaDeclaration(createPositionAreaNode(input))?.values, 68 | ).toMatchObject(expected); 69 | }); 70 | it.each([ 71 | [ 72 | ['left', 'center'], 73 | { block: [1, 2, 'Irrelevant'], inline: [0, 1, 'Irrelevant'] }, 74 | ], 75 | [ 76 | ['center', 'left'], 77 | { block: [1, 2, 'Irrelevant'], inline: [0, 1, 'Irrelevant'] }, 78 | ], 79 | [ 80 | ['span-all', 'y-end'], 81 | { block: [2, 3, 'Physical'], inline: [0, 3, 'Irrelevant'] }, 82 | ], 83 | ])('%s parses grid', (input, expected) => { 84 | expect( 85 | getPositionAreaDeclaration(createPositionAreaNode(input))?.grid, 86 | ).toMatchObject(expected); 87 | }); 88 | 89 | // With single values 90 | it.each([ 91 | [['top'], { block: 'top', inline: 'span-all' }], 92 | [['center'], { block: 'center', inline: 'center' }], 93 | [['start'], { block: 'start', inline: 'start' }], 94 | [['self-start'], { block: 'self-start', inline: 'self-start' }], 95 | [['span-end'], { block: 'span-end', inline: 'span-end' }], 96 | ])('%s parses', (input, expected) => { 97 | expect( 98 | getPositionAreaDeclaration(createPositionAreaNode(input))?.values, 99 | ).toMatchObject(expected); 100 | }); 101 | 102 | // Invalid, can't parse 103 | it.each([[['left', 'left']], [['left', 'block-end']]])( 104 | '%s is undefined', 105 | (input) => { 106 | expect( 107 | getPositionAreaDeclaration(createPositionAreaNode(input)), 108 | ).toEqual(undefined); 109 | }, 110 | ); 111 | }); 112 | 113 | describe('insets', () => { 114 | it.each([ 115 | [ 116 | ['top', 'right'], 117 | [0, 'top'], 118 | ['right', 0], 119 | ], 120 | [ 121 | ['bottom', 'left'], 122 | ['bottom', 0], 123 | [0, 'left'], 124 | ], 125 | [ 126 | ['center', 'center'], 127 | ['top', 'bottom'], 128 | ['left', 'right'], 129 | ], 130 | ])('%s', async (input, block, inline) => { 131 | const res = await dataForPositionAreaTarget( 132 | createEl(), 133 | getPositionAreaDeclaration(createPositionAreaNode(input))!, 134 | null, 135 | ); 136 | const insets = res!.insets; 137 | expect(insets.block).toEqual(block); 138 | expect(insets.inline).toEqual(inline); 139 | }); 140 | }); 141 | 142 | describe('axisAlignment', () => { 143 | it.each([ 144 | [['top', 'right'], 'end', 'start'], 145 | [['bottom', 'left'], 'start', 'end'], 146 | [['center', 'center'], 'center', 'center'], 147 | ])('%s', async (input, block, inline) => { 148 | const res = await dataForPositionAreaTarget( 149 | createEl(), 150 | getPositionAreaDeclaration(createPositionAreaNode(input))!, 151 | null, 152 | ); 153 | const alignments = res!.alignments; 154 | expect(alignments.block).toEqual(block); 155 | expect(alignments.inline).toEqual(inline); 156 | }); 157 | }); 158 | 159 | describe('wrapperForPositionedElement', () => { 160 | let element: HTMLElement; 161 | beforeEach(() => { 162 | element = document.createElement('div'); 163 | }); 164 | it('creates a wrapper', () => { 165 | const wrapper = wrapperForPositionedElement(element, 'uuid'); 166 | expect(wrapper.tagName).toBe('POLYFILL-POSITION-AREA'); 167 | const style = getComputedStyle(wrapper); 168 | expect(style.position).toBe('absolute'); 169 | expect(style.display).toBe('grid'); 170 | expect(style.top).toBe(`var(--pa-value-top)`); 171 | expect(style.bottom).toBe(`var(--pa-value-bottom)`); 172 | expect(style.left).toBe(`var(--pa-value-left)`); 173 | expect(style.right).toBe(`var(--pa-value-right)`); 174 | expect(wrapper.getAttribute('data-pa-wrapper-for-uuid')).toBeDefined(); 175 | }); 176 | it('does not rewrap an element', () => { 177 | const wrapper = wrapperForPositionedElement(element, 'uuid1'); 178 | const secondWrapper = wrapperForPositionedElement(element, 'uuid2'); 179 | expect( 180 | secondWrapper.getAttribute('data-pa-wrapper-for-uuid1'), 181 | ).toBeDefined(); 182 | expect( 183 | secondWrapper.getAttribute('data-pa-wrapper-for-uuid2'), 184 | ).toBeDefined(); 185 | expect(wrapper).toBe(secondWrapper); 186 | }); 187 | }); 188 | 189 | describe('activeWrapperStyles', () => { 190 | it('returns the active styles', () => { 191 | expect( 192 | activeWrapperStyles('targetUUID', 'selectorUUID'), 193 | ).toMatchSnapshot(); 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /tests/unit/setup.ts: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | 3 | beforeAll(() => { 4 | fetchMock.mockGlobal(); 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore 7 | window.CSS = { 8 | escape: (str) => str, 9 | }; 10 | }); 11 | 12 | afterEach(() => { 13 | fetchMock.removeRoutes(); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/unit/transform.test.ts: -------------------------------------------------------------------------------- 1 | import { transformCSS } from '../../src/transform.js'; 2 | 3 | describe('transformCSS', () => { 4 | it('parses and removes new anchor positioning CSS after transformation to JS', () => { 5 | document.head.innerHTML = ` 6 | 7 | 10 | `; 11 | document.body.innerHTML = ` 12 |
13 |
14 | `; 15 | const link = document.querySelector('link') as HTMLLinkElement; 16 | const style = document.querySelector('style') as HTMLStyleElement; 17 | const div = document.getElementById('div') as HTMLDivElement; 18 | const div2 = document.getElementById('div2') as HTMLDivElement; 19 | const styleData = [ 20 | { el: link, css: 'html { margin: 0; }', changed: true }, 21 | { el: style, css: 'html { padding: 0; }', changed: true }, 22 | { 23 | el: div, 24 | css: '[data-has-inline-styles="key"]{color:blue;}', 25 | changed: true, 26 | }, 27 | { 28 | el: div2, 29 | css: '[data-has-inline-styles="key2"]{color:blue;}', 30 | changed: false, 31 | }, 32 | ]; 33 | const inlineStyles = new Map(); 34 | inlineStyles.set(div, { '--foo': '--bar' }); 35 | transformCSS(styleData, inlineStyles, true); 36 | 37 | expect(link.isConnected).toBe(false); 38 | const newLink = document.querySelector( 39 | 'style[data-original-href]', 40 | ) as HTMLStyleElement; 41 | expect(newLink.getAttribute('data-link')).toBe('true'); 42 | expect(newLink.getAttribute('crossorigin')).toBeNull(); 43 | expect(newLink.textContent).toBe('html { margin: 0; }'); 44 | 45 | expect(style.innerHTML).toBe('html { padding: 0; }'); 46 | expect(div.getAttribute('style')).toBe('--foo: var(--bar); color:blue;'); 47 | expect(div2.getAttribute('style')).toBe('color: red;'); 48 | expect(div.hasAttribute('data-has-inline-styles')).toBeFalsy(); 49 | expect(div2.hasAttribute('data-has-inline-styles')).toBeFalsy(); 50 | }); 51 | 52 | it('preserves id, media, and title attributes when replacing link elements', () => { 53 | document.head.innerHTML = ` 54 | 55 | `; 56 | const link = document.querySelector('link') as HTMLLinkElement; 57 | const styleData = [{ el: link, css: 'html { margin: 0; }', changed: true }]; 58 | const inlineStyles = new Map(); 59 | const initialStyleElement = document.querySelector('style'); 60 | expect(initialStyleElement).toBe(null); 61 | transformCSS(styleData, inlineStyles, true); 62 | const transformedStyleElement = document.querySelector( 63 | 'style', 64 | ) as HTMLStyleElement; 65 | expect(transformedStyleElement.id).toBe('the-link'); 66 | expect(transformedStyleElement.media).toBe('screen'); 67 | expect(transformedStyleElement.title).toBe('stylish'); 68 | 69 | const transformedLink = document.querySelector('link') as HTMLLinkElement; 70 | expect(transformedLink).toBe(null); 71 | }); 72 | 73 | it('creates new style elements for created styles', () => { 74 | document.head.innerHTML = ``; 75 | const styleData = [ 76 | { 77 | el: document.createElement('link'), 78 | css: 'html { margin: 0; }', 79 | changed: true, 80 | created: true, 81 | }, 82 | ]; 83 | transformCSS(styleData, undefined, true); 84 | 85 | const createdStyleElement = document.querySelector( 86 | 'style', 87 | ) as HTMLStyleElement; 88 | expect(createdStyleElement.hasAttribute('data-original-href')).toBe(false); 89 | expect(createdStyleElement.hasAttribute('data-generated-by-polyfill')).toBe( 90 | true, 91 | ); 92 | expect(createdStyleElement.textContent).toBe('html { margin: 0; }'); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import type * as csstree from 'css-tree'; 2 | 3 | import { getAST, splitCommaList } from '../../src/utils.js'; 4 | 5 | describe('splitCommaList', () => { 6 | it('works', () => { 7 | const { children } = getAST('a{b: c d, e, f;}') as csstree.StyleSheet; 8 | const value = ( 9 | (children.first as csstree.Rule).block.children 10 | .first as csstree.Declaration 11 | ).value as csstree.Value; 12 | const res = splitCommaList(value.children); 13 | expect(res).toEqual([ 14 | [ 15 | { name: 'c', type: 'Identifier', loc: null }, 16 | { name: 'd', type: 'Identifier', loc: null }, 17 | ], 18 | [{ name: 'e', type: 'Identifier', loc: null }], 19 | [{ name: 'f', type: 'Identifier', loc: null }], 20 | ]); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/wpt.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | /** 4 | * Copyright 2022 Google Inc. All Rights Reserved. 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | import { eachLimit, retry } from 'async'; 17 | import browserslist from 'browserslist'; 18 | import { Local } from 'browserstack-local'; 19 | import fs from 'fs'; 20 | import { readFile } from 'fs/promises'; 21 | import { Agent } from 'http'; 22 | import { Builder, By, until } from 'selenium-webdriver'; 23 | 24 | import writeReport from './report.js'; 25 | 26 | type Capabilities = Record; 27 | 28 | const enum DataType { 29 | FetchDescriptor, 30 | Result, 31 | } 32 | 33 | export interface ResultData { 34 | type: DataType.Result; 35 | summary: [number, number]; 36 | results?: TestResult[]; 37 | } 38 | 39 | interface FetchDescriptorData { 40 | type: DataType.FetchDescriptor; 41 | capabilities: Capabilities; 42 | } 43 | 44 | interface BrowserVersion { 45 | name: string; 46 | data: FetchDescriptorData | ResultData; 47 | } 48 | 49 | export interface BrowserDefinition { 50 | name: string; 51 | logo: string; 52 | versions: BrowserVersion[]; 53 | } 54 | 55 | interface Subtest { 56 | name: string; 57 | properties: object; 58 | index: number; 59 | phase: number; 60 | phases: object; 61 | PASS: number; 62 | FAIL: number; 63 | TIMEOUT: number; 64 | NOTRUN: number; 65 | PRECONDITION_FAILED: number; 66 | status: number; 67 | message: string; 68 | stack: string; 69 | } 70 | 71 | interface ResultDataDetail { 72 | type: string; 73 | tests: Subtest[]; 74 | status: object; 75 | asserts: object; 76 | } 77 | 78 | type TestResult = [string, ResultDataDetail]; 79 | 80 | interface TestSuite { 81 | js: string[]; 82 | iframe: [string, string][]; 83 | } 84 | 85 | const TEST_FOLDERS: string[] = ['css/css-anchor-position']; 86 | 87 | // Tests that check DOM implementation details instead of user-facing behavior 88 | const TEST_BLOCKLIST = [ 89 | 'anchor-default-basics.html', 90 | 'anchor-name-basics.html', 91 | 'anchor-parse-invalid.html', 92 | 'anchor-parse-valid.html', 93 | 'anchor-query-custom-property-registration.html', 94 | 'anchor-size-parse-invalid.html', 95 | 'anchor-size-parse-valid.html', 96 | 'at-fallback-position-allowed-declarations.html', 97 | 'at-fallback-position-parse.html', 98 | 'position-fallback-basics.html', 99 | ]; 100 | const TEST_FILTERS = [new RegExp(TEST_BLOCKLIST.join('|'))]; 101 | 102 | const SUBTEST_FILTERS: RegExp[] = [ 103 | // /calc\(.*\)/, 104 | // /max\(.*\)/, 105 | // /style\(.*\)/, 106 | // /#container width 399px after padding is applied. #second is removed from the rendering/, 107 | // /ex units/, 108 | // /ch units/, 109 | // /ex relative/, 110 | // /ch relative/, 111 | ]; 112 | 113 | function getBrowserVersions(query: string | string[]) { 114 | return browserslist(query).map((browserString) => { 115 | const [name, version] = browserString.split(' '); 116 | return { 117 | name, 118 | version: 119 | !version.includes('.') && !isNaN(parseFloat(version)) 120 | ? `${version}.0` 121 | : version, 122 | }; 123 | }); 124 | } 125 | 126 | const CHROME_DEFINITION: BrowserDefinition = { 127 | name: 'Chrome', 128 | logo: 'https://unpkg.com/@browser-logos/chrome@2.0.0/chrome.svg', 129 | versions: getBrowserVersions('last 2 Chrome versions').map(({ version }) => ({ 130 | name: version, 131 | data: { 132 | type: DataType.FetchDescriptor, 133 | capabilities: { 134 | 'bstack:options': { 135 | os: 'Windows', 136 | osVersion: '11', 137 | }, 138 | browserName: 'Chrome', 139 | browserVersion: version, 140 | }, 141 | }, 142 | })), 143 | }; 144 | 145 | // Safari on iOS requires specific OS/browser pairs, so we can't use browserslist 146 | const SAFARI_IOS_DEFINITION: BrowserDefinition = { 147 | name: 'Safari (iOS)', 148 | logo: 'https://unpkg.com/@browser-logos/safari-ios@1.0.15/safari-ios.svg', 149 | versions: [ 150 | ['iPhone 14', '16'], 151 | ['iPhone 13', '15'], 152 | ].map(([deviceName, browserVersion]) => ({ 153 | name: browserVersion, 154 | data: { 155 | type: DataType.FetchDescriptor, 156 | capabilities: { 157 | 'bstack:options': { 158 | deviceName, 159 | }, 160 | browserName: 'safari', 161 | browserVersion, 162 | }, 163 | }, 164 | })), 165 | }; 166 | 167 | // Safari on macOS requires specific OS/browser pairs, so we can't use browserslist 168 | const SAFARI_MACOS_DEFINITION: BrowserDefinition = { 169 | name: 'Safari (macOS)', 170 | logo: 'https://unpkg.com/@browser-logos/safari-ios@1.0.15/safari-ios.svg', 171 | versions: [ 172 | ['Ventura', '16'], 173 | ['Monterey', '15.6'], 174 | ].map(([osVersion, browserVersion]) => ({ 175 | name: browserVersion, 176 | data: { 177 | type: DataType.FetchDescriptor, 178 | capabilities: { 179 | 'bstack:options': { 180 | os: 'OS X', 181 | osVersion, 182 | }, 183 | browserName: 'safari', 184 | browserVersion, 185 | }, 186 | }, 187 | })), 188 | }; 189 | 190 | const EDGE_DEFINITION: BrowserDefinition = { 191 | name: 'Edge', 192 | logo: 'https://unpkg.com/@browser-logos/edge@2.0.5/edge.svg', 193 | versions: getBrowserVersions('last 2 Edge versions').map(({ version }) => ({ 194 | name: version, 195 | data: { 196 | type: DataType.FetchDescriptor, 197 | capabilities: { 198 | 'bstack:options': { 199 | os: 'Windows', 200 | osVersion: '11', 201 | }, 202 | browserName: 'Edge', 203 | browserVersion: version, 204 | }, 205 | }, 206 | })), 207 | }; 208 | 209 | const FIREFOX_DEFINITION: BrowserDefinition = { 210 | name: 'Firefox', 211 | logo: 'https://unpkg.com/@browser-logos/firefox@3.0.9/firefox.svg', 212 | versions: getBrowserVersions('last 2 Firefox versions').map( 213 | ({ version }) => ({ 214 | name: version, 215 | data: { 216 | type: DataType.FetchDescriptor, 217 | capabilities: { 218 | 'bstack:options': { 219 | os: 'Windows', 220 | osVersion: '11', 221 | }, 222 | browserName: 'Firefox', 223 | browserVersion: version, 224 | }, 225 | }, 226 | }), 227 | ), 228 | }; 229 | 230 | const SAMSUNG_INTERNET_DEFINITION: BrowserDefinition = { 231 | name: 'Samsung', 232 | logo: 'https://unpkg.com/@browser-logos/samsung-internet@4.0.6/samsung-internet.svg', 233 | versions: getBrowserVersions('last 2 Samsung versions').map( 234 | ({ version }) => ({ 235 | name: version, 236 | data: { 237 | type: DataType.FetchDescriptor, 238 | capabilities: { 239 | 'bstack:options': { 240 | osVersion: '12.0', 241 | deviceName: 'Samsung Galaxy S22 Ultra', 242 | }, 243 | browserName: 'samsung', 244 | browserVersion: version, 245 | }, 246 | }, 247 | }), 248 | ), 249 | }; 250 | 251 | const BROWSERS: BrowserDefinition[] = [ 252 | CHROME_DEFINITION, 253 | SAFARI_IOS_DEFINITION, 254 | SAFARI_MACOS_DEFINITION, 255 | EDGE_DEFINITION, 256 | FIREFOX_DEFINITION, 257 | SAMSUNG_INTERNET_DEFINITION, 258 | ]; 259 | 260 | function createLocalServer(): Promise { 261 | return new Promise((resolve, reject) => { 262 | const server = new Local(); 263 | server.start( 264 | { 265 | key: process.env.BROWSERSTACK_ACCESS_KEY, 266 | }, 267 | (err) => { 268 | if (err) { 269 | reject(err); 270 | } else { 271 | resolve(server); 272 | } 273 | }, 274 | ); 275 | }); 276 | } 277 | 278 | function stopLocalServer(server: Local): Promise { 279 | return new Promise((resolve) => { 280 | server.stop(resolve); 281 | }); 282 | } 283 | 284 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 285 | function getValue(obj: any, path: string) { 286 | const paths = path.split('/'); 287 | for (let i = 0, len = paths.length; i < len; i++) obj = obj[paths[i]]; 288 | return obj; 289 | } 290 | 291 | async function getTests(manifestPath: string): Promise { 292 | const manifestBuffer = await readFile(manifestPath); 293 | const manifest = JSON.parse(manifestBuffer.toString()); 294 | 295 | const js: string[] = []; 296 | const iframe: [string, string][] = []; 297 | 298 | for (const folder_path of TEST_FOLDERS) { 299 | // console.info(`folder_path => ${folder_path}`); 300 | 301 | const htmlTests = getValue(manifest.items.testharness, folder_path); 302 | const refTests = getValue(manifest.items.reftest, folder_path); 303 | 304 | if (refTests) { 305 | Object.keys(refTests).forEach((name, index) => { 306 | const data = refTests[name][1][1][0]; 307 | iframe.push( 308 | [ 309 | `ref${index}_test`, 310 | `http://web-platform.test:8000/${folder_path}/${name}`, 311 | ], 312 | [`ref${index}_match`, `http://web-platform.test:8000${data[0]}`], 313 | ); 314 | }); 315 | } 316 | 317 | if (htmlTests) { 318 | js.push( 319 | ...Object.keys(htmlTests) 320 | .filter((name) => !TEST_FILTERS.some((filter) => filter.test(name))) 321 | .map( 322 | (name) => `http://web-platform.test:8000/${folder_path}/${name}`, 323 | ), 324 | ); 325 | } 326 | } 327 | 328 | return { js, iframe }; 329 | } 330 | 331 | function createWebDriver(capabilities: Record) { 332 | try { 333 | return new Builder() 334 | .usingHttpAgent( 335 | new Agent({ 336 | keepAlive: true, 337 | keepAliveMsecs: 30 * 1000, 338 | }), 339 | ) 340 | .usingServer('http://hub-cloud.browserstack.com/wd/hub') 341 | .withCapabilities({ 342 | ...capabilities, 343 | 'bstack:options': { 344 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 345 | ...(capabilities as any)['bstack:options'], 346 | userName: process.env.BROWSERSTACK_USERNAME, 347 | accessKey: process.env.BROWSERSTACK_ACCESS_KEY, 348 | local: true, 349 | debug: true, 350 | consoleLogs: 'verbose', 351 | networkLogs: true, 352 | seleniumVersion: '4.1.0', 353 | }, 354 | }) 355 | .build(); 356 | } catch (e) { 357 | console.warn( 358 | `Failed while creating driver with capabilities: ${JSON.stringify( 359 | capabilities, 360 | )}`, 361 | ); 362 | throw e; 363 | } 364 | } 365 | 366 | async function runTestSuite( 367 | name: string, 368 | capabilities: Record, 369 | testSuite: TestSuite, 370 | ): Promise { 371 | const driver = createWebDriver(capabilities); 372 | 373 | try { 374 | console.info(`[${name}] Connecting...`); 375 | await driver.get('http://bs-local.com:9606/runner.html'); 376 | 377 | console.info(`[${name}] Running tests...`); 378 | await driver.executeScript( 379 | `window.RUN_TESTS(${JSON.stringify(testSuite)})`, 380 | ); 381 | 382 | const resultsElem = await driver.wait( 383 | until.elementLocated(By.id('__test_results__')), 384 | 3 * 60 * 1000, 385 | 'Timed out', 386 | 5 * 1000, 387 | ); 388 | const result = JSON.parse(await resultsElem.getAttribute('innerHTML')); 389 | console.info(`[${name}] Finished successfully`); 390 | return result; 391 | } catch (err) { 392 | console.warn(`[${name}] Failed: ${err}`); 393 | throw err; 394 | } finally { 395 | try { 396 | await driver.close(); 397 | await driver.quit(); 398 | } catch { 399 | // Some buggy WebDriver implementations could fail during closing, 400 | // but we want to keep any results we already returned. 401 | } 402 | } 403 | } 404 | 405 | async function tryOrDefault(fn: () => Promise, def: () => T): Promise { 406 | try { 407 | return await fn(); 408 | } catch { 409 | return def(); 410 | } 411 | } 412 | 413 | async function main() { 414 | const manifestPath = process.env.WPT_MANIFEST; 415 | if (!manifestPath) { 416 | throw new Error('invariant: WPT_MANIFEST environment variable must be set'); 417 | } 418 | 419 | const testSuite = await getTests(manifestPath); 420 | console.info(`JS Tests:\n${testSuite.js.join('\n')}\n`); 421 | console.info( 422 | `Iframe Tests:\n${testSuite.iframe.map(([, path]) => path).join('\n')}\n`, 423 | ); 424 | 425 | const tests: (() => Promise)[] = []; 426 | const results: BrowserDefinition[] = BROWSERS.map((browser) => ({ 427 | ...browser, 428 | versions: browser.versions.map((version) => { 429 | const result: BrowserVersion = { 430 | ...version, 431 | }; 432 | tests.push(async () => { 433 | const data = version.data; 434 | if (data.type === DataType.FetchDescriptor) { 435 | const results = await tryOrDefault( 436 | async () => 437 | await retry( 438 | 5, 439 | async () => 440 | await runTestSuite( 441 | `${browser.name} ${version.name}`, 442 | data.capabilities, 443 | testSuite, 444 | ), 445 | ), 446 | () => [], 447 | ); 448 | 449 | let passed = 0; 450 | let failed = 0; 451 | 452 | for (const test of results) { 453 | if (Array.isArray(test) && Array.isArray(test[1].tests)) { 454 | for (const subtest of test[1].tests) { 455 | if ( 456 | SUBTEST_FILTERS.some((filter) => filter.test(subtest.name)) 457 | ) { 458 | continue; 459 | } 460 | if (subtest.status === subtest.PASS) { 461 | passed++; 462 | } else if (subtest.status !== subtest.PRECONDITION_FAILED) { 463 | failed++; 464 | } 465 | } 466 | } 467 | } 468 | 469 | result.data = { 470 | type: DataType.Result, 471 | summary: [passed, failed], 472 | results, 473 | }; 474 | } 475 | }); 476 | return result; 477 | }), 478 | })); 479 | 480 | const server = await createLocalServer(); 481 | try { 482 | await eachLimit(tests, 5, async (test) => await test()); 483 | console.info(`Writing report for ${results.length} results`); 484 | writeReport(results); 485 | 486 | /* Write an HTML page with links to all historic WPT results */ 487 | const rows = fs 488 | .readdirSync('test-results') 489 | .map((name) => `
  • ${name}
  • `) 490 | .sort() 491 | .reverse() 492 | .join('\n'); 493 | const html = ` 494 | 495 | 496 | 497 | 498 | Test Results 499 | 500 | 501 |
      502 | ${rows} 503 |
    504 | 505 | `; 506 | fs.writeFileSync('test-results/history.html', html); 507 | } finally { 508 | await stopLocalServer(server); 509 | } 510 | } 511 | 512 | try { 513 | await main(); 514 | } catch (e) { 515 | console.error('Failed to complete tests:'); 516 | console.error(e); 517 | } 518 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "node16", 4 | "target": "es2022", 5 | "moduleResolution": "Node16", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "isolatedModules": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "declaration": true, 11 | "outDir": "./dist", 12 | "sourceMap": true 13 | }, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import replace from '@rollup/plugin-replace'; 4 | import { resolve } from 'path'; 5 | import { bundleStats } from 'rollup-plugin-bundle-stats'; 6 | import { defineConfig } from 'vite'; 7 | 8 | export default defineConfig({ 9 | server: { 10 | port: 3000, 11 | }, 12 | build: process.env.BUILD_DEMO 13 | ? { 14 | rollupOptions: { 15 | input: { 16 | main: resolve(__dirname, 'index.html'), 17 | positionArea: resolve(__dirname, 'position-area.html'), 18 | }, 19 | }, 20 | } 21 | : { 22 | lib: process.env.BUILD_WPT 23 | ? // build that adds a delay variable for WPT test-runner 24 | { 25 | entry: resolve(__dirname, 'src/index-wpt.ts'), 26 | name: 'CssAnchorPositioning', 27 | formats: ['umd'], 28 | // the proper extensions will be added 29 | fileName: 'css-anchor-positioning-wpt', 30 | } 31 | : process.env.BUILD_FN 32 | ? // build that exposes the polyfill as a fn 33 | { 34 | entry: resolve(__dirname, 'src/index-fn.ts'), 35 | name: 'CssAnchorPositioning', 36 | // the proper extensions will be added 37 | fileName: 'css-anchor-positioning-fn', 38 | } 39 | : // build that runs the polyfill on import 40 | { 41 | entry: resolve(__dirname, 'src/index.ts'), 42 | name: 'CssAnchorPositioning', 43 | // the proper extensions will be added 44 | fileName: 'css-anchor-positioning', 45 | }, 46 | emptyOutDir: false, 47 | target: 'es6', 48 | sourcemap: true, 49 | rollupOptions: { 50 | plugins: [ 51 | // Remove unused source-map-js module to minimize build size 52 | // @ts-expect-error https://github.com/rollup/plugins/issues/1541 53 | replace({ 54 | values: { 55 | "import { SourceMapGenerator } from 'source-map-js/lib/source-map-generator.js';": 56 | '', 57 | }, 58 | delimiters: ['', ''], 59 | preventAssignment: true, 60 | }), 61 | ], 62 | }, 63 | }, 64 | plugins: [bundleStats({ compare: false, silent: true })], 65 | /** 66 | * @see https://vitest.dev/config/#configuration 67 | */ 68 | test: { 69 | include: ['./tests/unit/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 70 | globals: true, 71 | environment: 'jsdom', 72 | watch: false, 73 | setupFiles: './tests/unit/setup.ts', 74 | clearMocks: true, 75 | reporters: 'dot', 76 | coverage: { 77 | enabled: true, 78 | provider: 'istanbul', 79 | reporter: ['text-summary', 'html'], 80 | include: ['src/**/*.{js,ts}'], 81 | exclude: ['src/index.ts', 'src/index-fn.ts', 'src/index-wpt.ts'], 82 | skipFull: true, 83 | all: true, 84 | }, 85 | }, 86 | }); 87 | --------------------------------------------------------------------------------