diff --git a/.editorconfig b/.editorconfig index 50dc583..bacace3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ insert_final_newline = true # editorconfig-tools is unable to ignore longs strings or urls max_line_length = off -[*.md] +[{*.md,*.mdx}] trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index fce0b9b..0876b0d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,3 @@ -# Set the default behavior, in case people don't have core.autocrlf set. -* text eol=lf - -*.png binary -*.jpg binary -*.ico binary -*.jpg binary -*.eot binary -*.ttf binary -*.woff binary -*.woff2 binary +# Set the default behavior as unix line endings for all text files, +# in case people don't have core.autocrlf set. +* text=auto eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..272473b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [eser] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..552726d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,46 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +### Describe the bug + +A clear and concise description of what the bug is. + +### To Reproduce + +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +### Expected behavior + +A clear and concise description of what you expected to happen. + +### Screenshots + +If applicable, add screenshots to help explain your problem. + +### Desktop (please complete the following information): + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +### Smartphone (please complete the following information): + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +### Additional context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d33aa64 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +### Is your feature request related to a problem? Please describe. + +A clear and concise description of what the problem is. Ex. I'm always +frustrated when [...] + +### Describe the solution you'd like + +A clear and concise description of what you want to happen. + +### Describe alternatives you've considered + +A clear and concise description of any alternative solutions or features you've +considered. + +### Additional context + +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ba94433 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,2 @@ +- [x] My submissions follows the **Submission Rules** +- [x] I have read and accepted the **Terms and Conditions** diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..df4d15b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7042968..b8b8e08 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -22,8 +22,10 @@ jobs: contents: read steps: - - name: Clone repository + - name: Checkout repository uses: actions/checkout@v3 + with: + fetch-depth: 2 - name: Upload to Deno Deploy uses: denoland/deployctl@v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eff77a..e58d0d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,8 @@ jobs: - 1.35.3 steps: - - name: Clone repository - uses: actions/checkout@v3.5.3 + - name: Checkout repository + uses: actions/checkout@v3 with: fetch-depth: 2 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4694258..bec83f0 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -68,7 +68,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.gitignore b/.gitignore index 02eafd1..d99745c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,21 +7,30 @@ Thumbs.db # Editor metadata .vscode/* !.vscode/extensions.json +!.vscode/launch.json !.vscode/settings.json -.idea/ +.idea/* # local API keys and secrets .env.local .env.*.local +# tool outputs +*.tsbuildinfo + # sensitive files *.pem *.swp # coverage files -etc/coverage/*.json -etc/coverage/cov_profile.lcov +/etc/coverage/*.json +/etc/coverage/cov_profile.lcov + +# package managers +node_modules/* +package-lock.json # temporary files -tmp/ -.pup.*/ +/tmp/* +!/tmp/.gitkeep +.pup.*/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b2562b..202ef71 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ repos: rev: v4.4.0 hooks: - id: check-added-large-files + args: ["--maxkb=1024"] - id: check-case-conflict - id: check-executables-have-shebangs - id: check-json @@ -19,24 +20,55 @@ repos: - id: mixed-line-ending - repo: local hooks: - # - id: local-precommit - # name: local pre-commit tasks - # description: Runs local pre-commit tasks. - # entry: bash -c 'deno run --unstable --allow-all pre-commit.ts; git add -u' -- - # always_run: true - # pass_filenames: false - # language: system - - id: lint-check - name: lint check - description: Lint JavaScript/TypeScript source code. - entry: yarn lint - types_or: [javascript, jsx, ts, tsx, json, markdown] - pass_filenames: false - language: system - - id: test - name: test - description: Run tests using the test runner. - entry: yarn test - types_or: [javascript, jsx, ts, tsx, json] - pass_filenames: false - language: system + # - id: local-precommit + # name: local pre-commit tasks + # description: Runs local pre-commit tasks. + # entry: bash -c 'deno run --unstable --allow-all ./pre-commit.ts; git add -u' -- + # always_run: true + # pass_filenames: false + # language: system + - id: kebab-case-files-only + name: kebab-case files only + entry: filenames must be kebab-case only + language: fail + files: '[^a-z0-9.\-\/\[\]\@]' + exclude: | + (?x)^( + .github/ISSUE_TEMPLATE/bug_report.md| + .github/ISSUE_TEMPLATE/feature_request.md| + .github/PULL_REQUEST_TEMPLATE.md| + .github/FUNDING.yml| + CODE_OF_CONDUCT.md| + LICENSE| + README.md| + SECURITY.md| + Dockerfile + )$ + - id: deno-fmt + name: verify formatting + description: Auto-format JavaScript, TypeScript, Markdown, and JSON files. + entry: deno fmt --check + types_or: [javascript, jsx, ts, tsx, json, markdown] + pass_filenames: false + language: system + - id: deno-lint + name: lint + description: Lint JavaScript/TypeScript source code. + entry: deno lint + types_or: [javascript, jsx, ts, tsx, json, markdown] + pass_filenames: false + language: system + # - id: deno-check + # name: checks if modules are broken + # description: Checks if modules are broken. + # entry: deno task check + # types_or: [javascript, jsx, ts, tsx, json, markdown] + # pass_filenames: false + # language: system + # - id: deno-test + # name: run tests + # description: Run tests using Deno's built-in test runner. + # entry: deno task test:run + # types_or: [javascript, jsx, ts, tsx, json] + # pass_filenames: false + # language: system diff --git a/.tool-versions b/.tool-versions index cbb24f3..d65a0af 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -deno 1.36.2 -v8 11.7.439.1 +deno 1.36.3 +v8 11.6.189.12 typescript 5.1.6 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 70a40f2..acdc8b1 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ "denoland.vscode-deno", - "ryanluker.vscode-coverage-gutters" + "ryanluker.vscode-coverage-gutters", + "ronilaukkarinen.vscode-stylefmt" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..26e2330 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Deno Launch: API", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "deno", + "runtimeArgs": ["task", "api:dev"], + "outputCapture": "std" + }, + { + "name": "Deno Launch: Web", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "deno", + "runtimeArgs": ["task", "web:dev"], + "outputCapture": "std" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 8125661..ea98254 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,13 @@ "coverage-gutters.showLineCoverage": true, "coverage-gutters.showRulerCoverage": true, "deno.enable": true, + "deno.codeLens.test": true, "deno.lint": true, + "deno.suggest.autoImports": true, + "deno.suggest.completeFunctionCalls": true, + "deno.suggest.imports.autoDiscover": true, + "deno.suggest.names": true, + "deno.suggest.paths": true, "editor.defaultFormatter": "denoland.vscode-deno", "yaml.schemas": { "https://json.schemastore.org/github-workflow.json": "./.github/workflows/*.yml" @@ -26,5 +32,17 @@ }, "[postcss]": { "editor.defaultFormatter": "ronilaukkarinen.vscode-stylefmt" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[javascript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" } } diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9e6a501 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at [INSERT CONTACT +METHOD]. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..396300e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM denoland/deno:debian-1.36.3 + +EXPOSE 8080 + +WORKDIR /app + +USER deno + +COPY ./src/ ./src/ +COPY ./etc/ ./etc/ +COPY ./.env ./ +COPY ./.env.* ./ +RUN deno cache ./src/mod.ts + +ENTRYPOINT [] +CMD ["deno", "task", "start"] diff --git a/LICENSE b/LICENSE index 0bad036..ccec245 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,13 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Copyright 2023 Eser Ozvataf - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at - 1. Definitions. + http://www.apache.org/licenses/LICENSE-2.0 - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2023 Açık Yazılım - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.en.md b/README.en.md index 4dda742..8293d3a 100644 --- a/README.en.md +++ b/README.en.md @@ -14,7 +14,7 @@ These are the technologies we use to build our projects: For Frontend: -- [Fresh](https://fresh.deno.dev) +- [cool lime](https://coollime.deno.dev) - [Shadcn](https://shadcn/ui) - [PostCSS](https://postcss.org) diff --git a/README.md b/README.md index 38d9d95..78e3614 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Projelerimizi oluşturmak için kullandığımız teknolojiler şunlardır: Frontend için: -- [Fresh](https://fresh.deno.dev) +- [cool lime](https://coollime.deno.dev) - [Shadcn](https://shadcn/ui) - [PostCSS](https://postcss.org) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1e7dbde --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | +| < 0.9 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a reported +vulnerability, what to expect if the vulnerability is accepted or declined, etc. diff --git a/components.json b/components.json index abfdefb..54f6fcc 100644 --- a/components.json +++ b/components.json @@ -11,7 +11,7 @@ "cssVariables": true }, "aliases": { - "components": "@web/shared/components", - "utils": "@web/shared/lib/utils.ts" + "components": "$web/shared/components", + "utils": "$web/shared/lib/utils.ts" } } diff --git a/deno.jsonc b/deno.jsonc index 75ce5f9..3bdb79f 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -3,18 +3,50 @@ "jsx": "react-jsx", "jsxImportSource": "react", + "allowJs": true, "allowArbitraryExtensions": true, + "checkJs": true, - "strict": true, - "exactOptionalPropertyTypes": false, "noImplicitOverride": true, "noImplicitReturns": true, "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "strict": true, + + "exactOptionalPropertyTypes": false }, "lock": false, "importMap": "./import_map.json", + "lint": { + "rules": { + "tags": [ + "recommended", + "fresh" + ] + }, + "exclude": [ + "./pkg/web/_lime/", + "./tmp/" + ] + }, + "fmt": { + "exclude": [ + "./pkg/web/_lime/", + "./tmp/" + ] + }, + "test": { + "include": [ + "./src/**/*.test.[js,jsx,ts,tsx]" + ] + }, + "bench": { + "include": [ + "./src/**/*.bench.[js,jsx,ts,tsx]" + ] + }, "tasks": { + "cleanup": "rm deno.lock && deno cache --unstable --reload ./pkg/api/mod.ts", "generate": "deno task api:data-seed", "test": "deno test --unstable --allow-sys --allow-net --allow-env --allow-read --allow-write --allow-run --parallel --watch", @@ -34,23 +66,6 @@ "web:deploy": "export $(grep -v '^#' .env.local | xargs -0) && deployctl deploy --project=$DENO_DEPLOY_PROJECT_ID ./pkg/web/main.ts", "web:dev": "deno run --allow-sys --allow-net --allow-env --allow-read --allow-write --allow-run --watch=./pkg/web/shared/assets/,./pkg/web/routes/ ./pkg/web/dev.ts", "web:preview": "deno run --allow-all ./pkg/web/main.ts", - "web:start": "deno run --allow-sys --allow-net --allow-env --allow-read --allow-write --allow-run ./pkg/web/main.ts", - "web:update": "echo deno run --allow-all --reload https://fresh.deno.dev/update ./pkg/web/" - }, - "lint": { - "exclude": [ - "./pkg/web/_fresh/" - ], - "rules": { - "tags": [ - "recommended", - "fresh" - ] - } - }, - "fmt": { - "exclude": [ - "./pkg/web/_fresh/" - ] + "web:start": "deno run --allow-sys --allow-net --allow-env --allow-read --allow-write --allow-run ./pkg/web/main.ts" } } diff --git a/import_map.json b/import_map.json index b5c5dde..a44d073 100644 --- a/import_map.json +++ b/import_map.json @@ -1,23 +1,26 @@ { "imports": { - "@protocol/": "./pkg/protocol/", - "@api/": "./pkg/api/", - "@web/": "./pkg/web/", - "@ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts", - "@hex/": "https://deno.land/x/hex@0.6.5/src/", - "@std/": "https://deno.land/std@0.199.0/", - "@zod": "https://deno.land/x/zod@v3.22.2/mod.ts", - - "$fresh/": "../fresh/", + "$protocol/": "./pkg/protocol/", + "$api/": "./pkg/api/", + "$web/": "./pkg/web/", + "$ulid/": "https://deno.land/x/ulid@v0.3.0/", + "$zod/": "https://deno.land/x/zod@v3.22.2/", + "$cool/": "../cool/", "$std/": "https://deno.land/std@0.200.0/", - "preact": "https://esm.sh/preact@10.17.1", - "preact/": "https://esm.sh/preact@10.17.1/", - "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.1", - "@preact/signals": "https://esm.sh/*@preact/signals@1.2.1", - "@preact/signals-core": "https://esm.sh/@preact/signals-core@1.4.0", - "react": "https://esm.sh/*preact@10.17.1/compat", - "react-dom": "https://esm.sh/*preact@10.17.1/compat", - "react/jsx-runtime": "https://esm.sh/*preact@10.17.1/jsx-runtime", + + "preact": "https://esm.sh/preact@10.17.1?target=deno", + "preact/compat": "https://esm.sh/*preact@10.17.1/compat?target=deno", + "preact/debug": "https://esm.sh/*preact@10.17.1/debug?target=deno", + "preact/devtools": "https://esm.sh/*preact@10.17.1/devtools?target=deno", + "preact/hooks": "https://esm.sh/*preact@10.17.1/hooks?target=deno", + "preact/jsx-runtime": "https://esm.sh/*preact@10.17.1/jsx-runtime?target=deno", + "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.1?target=deno", + "@preact/signals": "https://esm.sh/*@preact/signals@1.2.1?target=deno", + "@preact/signals-core": "https://esm.sh/@preact/signals-core@1.4.0?target=deno", + + "react": "https://esm.sh/*preact@10.17.1/compat?target=deno", + "react-dom": "https://esm.sh/*preact@10.17.1/compat?target=deno", + "react/jsx-runtime": "https://esm.sh/*preact@10.17.1/jsx-runtime?target=deno", "postcss": "https://deno.land/x/postcss@8.4.16/mod.js", "autoprefixer": "npm:autoprefixer", @@ -45,56 +48,63 @@ "@floating-ui/react-dom": "https://esm.sh/*@floating-ui/react-dom@1.0.1", "@floating-ui/utils": "https://esm.sh/*@floating-ui/utils@0.1.1", "@floating-ui/utils/dom": "https://esm.sh/*@floating-ui/utils@0.1.1/dom", - "@radix-ui/number": "https://esm.sh/*@radix-ui/number@1.0.1", - "@radix-ui/primitive": "https://esm.sh/*@radix-ui/primitive@1.0.1", - "@radix-ui/react-accordion": "https://esm.sh/*@radix-ui/react-accordion@1.1.2", - "@radix-ui/react-alert-dialog": "https://esm.sh/*@radix-ui/react-alert-dialog@1.0.4", - "@radix-ui/react-arrow": "https://esm.sh/*@radix-ui/react-arrow@1.0.3", - "@radix-ui/react-aspect-ratio": "https://esm.sh/*@radix-ui/react-aspect-ratio@1.0.3", - "@radix-ui/react-avatar": "https://esm.sh/*@radix-ui/react-avatar@1.0.3", - "@radix-ui/react-checkbox": "https://esm.sh/*@radix-ui/react-checkbox@1.0.4", - "@radix-ui/react-collapsible": "https://esm.sh/*@radix-ui/react-collapsible@1.0.3", - "@radix-ui/react-collection": "https://esm.sh/*@radix-ui/react-collection@1.0.3", - "@radix-ui/react-compose-refs": "https://esm.sh/*@radix-ui/react-compose-refs@1.0.1", - "@radix-ui/react-context-menu": "https://esm.sh/*@radix-ui/react-context-menu@2.1.4", - "@radix-ui/react-context": "https://esm.sh/*@radix-ui/react-context@1.0.1", - "@radix-ui/react-dialog": "https://esm.sh/*@radix-ui/react-dialog@1.0.4", - "@radix-ui/react-direction": "https://esm.sh/*@radix-ui/react-direction@1.0.1", - "@radix-ui/react-dismissable-layer": "https://esm.sh/*@radix-ui/react-dismissable-layer@1.0.4", - "@radix-ui/react-dropdown-menu": "https://esm.sh/*@radix-ui/react-dropdown-menu@2.0.5", - "@radix-ui/react-focus-guards": "https://esm.sh/*@radix-ui/react-focus-guards@1.0.1", - "@radix-ui/react-focus-scope": "https://esm.sh/*@radix-ui/react-focus-scope@1.0.3", - "@radix-ui/react-hover-card": "https://esm.sh/*@radix-ui/react-hover-card@1.0.6", + "@radix-ui/number": "./pkg/radix-ui-primitives/core/number/mod.ts", + "@radix-ui/primitive": "./pkg/radix-ui-primitives/core/primitive/mod.ts", + "@radix-ui/rect": "./pkg/radix-ui-primitives/core/rect/mod.ts", + "@radix-ui/react-accessible-icon": "./pkg/radix-ui-primitives/preact/accessible-icon/mod.ts", + "@radix-ui/react-accordion": "./pkg/radix-ui-primitives/preact/accordion/mod.ts", + "@radix-ui/react-alert-dialog": "./pkg/radix-ui-primitives/preact/alert-dialog/mod.ts", + "@radix-ui/react-announce": "./pkg/radix-ui-primitives/preact/announce/mod.ts", + "@radix-ui/react-arrow": "./pkg/radix-ui-primitives/preact/arrow/mod.ts", + "@radix-ui/react-aspect-ratio": "./pkg/radix-ui-primitives/preact/aspect-ratio/mod.ts", + "@radix-ui/react-avatar": "./pkg/radix-ui-primitives/preact/avatar/mod.ts", + "@radix-ui/react-checkbox": "./pkg/radix-ui-primitives/preact/checkbox/mod.ts", + "@radix-ui/react-collapsible": "./pkg/radix-ui-primitives/preact/collapsible/mod.ts", + "@radix-ui/react-collection": "./pkg/radix-ui-primitives/preact/collection/mod.ts", + "@radix-ui/react-compose-refs": "./pkg/radix-ui-primitives/preact/compose-refs/mod.ts", + "@radix-ui/react-context-menu": "./pkg/radix-ui-primitives/preact/context-menu/mod.ts", + "@radix-ui/react-context": "./pkg/radix-ui-primitives/preact/context/mod.ts", + "@radix-ui/react-dialog": "./pkg/radix-ui-primitives/preact/dialog/mod.ts", + "@radix-ui/react-direction": "./pkg/radix-ui-primitives/preact/direction/mod.ts", + "@radix-ui/react-dismissable-layer": "./pkg/radix-ui-primitives/preact/dismissable-layer/mod.ts", + "@radix-ui/react-dropdown-menu": "./pkg/radix-ui-primitives/preact/dropdown-menu/mod.ts", + "@radix-ui/react-focus-guards": "./pkg/radix-ui-primitives/preact/focus-guards/mod.ts", + "@radix-ui/react-focus-scope": "./pkg/radix-ui-primitives/preact/focus-scope/mod.ts", + "@radix-ui/react-form": "./pkg/radix-ui-primitives/preact/form/mod.ts", + "@radix-ui/react-hover-card": "./pkg/radix-ui-primitives/preact/hover-card/mod.ts", "@radix-ui/react-icons": "https://esm.sh/*@radix-ui/react-icons@1.3.0", - "@radix-ui/react-id": "https://esm.sh/*@radix-ui/react-id@1.0.1", - "@radix-ui/react-label": "https://esm.sh/*@radix-ui/react-label@2.0.2", - "@radix-ui/react-menu": "https://esm.sh/*@radix-ui/react-menu@2.0.5", - "@radix-ui/react-menubar": "https://esm.sh/*@radix-ui/react-menubar@1.0.3", - "@radix-ui/react-navigation-menu": "https://esm.sh/*@radix-ui/react-navigation-menu@1.1.3", - "@radix-ui/react-popover": "https://esm.sh/*@radix-ui/react-popover@1.0.6", - "@radix-ui/react-popper": "https://esm.sh/*@radix-ui/react-popper@1.1.2", - "@radix-ui/react-portal": "https://esm.sh/*@radix-ui/react-portal@1.0.3", - "@radix-ui/react-presence": "https://esm.sh/*@radix-ui/react-presence@1.0.1", - "@radix-ui/react-primitive": "https://esm.sh/*@radix-ui/react-primitive@1.0.3", - "@radix-ui/react-progress": "https://esm.sh/*@radix-ui/react-progress@1.0.3", - "@radix-ui/react-radio-group": "https://esm.sh/*@radix-ui/react-radio-group@1.1.3", - "@radix-ui/react-roving-focus": "https://esm.sh/*@radix-ui/react-roving-focus@1.0.4", - "@radix-ui/react-scroll-area": "https://esm.sh/*@radix-ui/react-scroll-area@1.0.4", - "@radix-ui/react-select": "https://esm.sh/*@radix-ui/react-select@1.2.2", - "@radix-ui/react-separator": "https://esm.sh/*@radix-ui/react-separator@1.0.3", - "@radix-ui/react-slider": "https://esm.sh/*@radix-ui/react-slider@1.1.2", - "@radix-ui/react-slot": "https://esm.sh/*@radix-ui/react-slot@1.0.2", - "@radix-ui/react-switch": "https://esm.sh/*@radix-ui/react-switch@1.0.3", - "@radix-ui/react-tabs": "https://esm.sh/*@radix-ui/react-tabs@1.0.4", - "@radix-ui/react-toast": "https://esm.sh/*@radix-ui/react-toast@1.1.4", - "@radix-ui/react-toggle": "https://esm.sh/*@radix-ui/react-toggle@1.0.3", - "@radix-ui/react-tooltip": "https://esm.sh/*@radix-ui/react-tooltip@1.0.6", - "@radix-ui/react-use-callback-ref": "https://esm.sh/*@radix-ui/react-use-callback-ref@1.0.1", - "@radix-ui/react-use-controllable-state": "https://esm.sh/*@radix-ui/react-use-controllable-state@1.0.1", - "@radix-ui/react-use-escape-keydown": "https://esm.sh/*@radix-ui/react-use-escape-keydown@1.0.3", - "@radix-ui/react-use-layout-effect": "https://esm.sh/*@radix-ui/react-use-layout-effect@1.0.1", - "@radix-ui/react-use-previous": "https://esm.sh/*@radix-ui/react-use-previous@1.0.1", - "@radix-ui/react-use-size": "https://esm.sh/*@radix-ui/react-use-size@1.0.1", - "@radix-ui/react-visually-hidden": "https://esm.sh/*@radix-ui/react-visually-hidden@1.0.3" + "@radix-ui/react-id": "./pkg/radix-ui-primitives/preact/id/mod.ts", + "@radix-ui/react-label": "./pkg/radix-ui-primitives/preact/label/mod.ts", + "@radix-ui/react-menu": "./pkg/radix-ui-primitives/preact/menu/mod.ts", + "@radix-ui/react-menubar": "./pkg/radix-ui-primitives/preact/menubar/mod.ts", + "@radix-ui/react-navigation-menu": "./pkg/radix-ui-primitives/preact/navigation-menu/mod.ts", + "@radix-ui/react-popover": "./pkg/radix-ui-primitives/preact/popover/mod.ts", + "@radix-ui/react-popper": "./pkg/radix-ui-primitives/preact/popper/mod.ts", + "@radix-ui/react-portal": "./pkg/radix-ui-primitives/preact/portal/mod.ts", + "@radix-ui/react-presence": "./pkg/radix-ui-primitives/preact/presence/mod.ts", + "@radix-ui/react-primitive": "./pkg/radix-ui-primitives/preact/primitive/mod.ts", + "@radix-ui/react-progress": "./pkg/radix-ui-primitives/preact/progress/mod.ts", + "@radix-ui/react-radio-group": "./pkg/radix-ui-primitives/preact/radio-group/mod.ts", + "@radix-ui/react-roving-focus": "./pkg/radix-ui-primitives/preact/roving-focus/mod.ts", + "@radix-ui/react-scroll-area": "./pkg/radix-ui-primitives/preact/scroll-area/mod.ts", + "@radix-ui/react-select": "./pkg/radix-ui-primitives/preact/select/mod.ts", + "@radix-ui/react-separator": "./pkg/radix-ui-primitives/preact/separator/mod.ts", + "@radix-ui/react-slider": "./pkg/radix-ui-primitives/preact/slider/mod.ts", + "@radix-ui/react-slot": "./pkg/radix-ui-primitives/preact/slot/mod.ts", + "@radix-ui/react-switch": "./pkg/radix-ui-primitives/preact/switch/mod.ts", + "@radix-ui/react-tabs": "./pkg/radix-ui-primitives/preact/tabs/mod.ts", + "@radix-ui/react-toast": "./pkg/radix-ui-primitives/preact/toast/mod.ts", + "@radix-ui/react-toggle": "./pkg/radix-ui-primitives/preact/toggle/mod.ts", + "@radix-ui/react-toggle-group": "./pkg/radix-ui-primitives/preact/toggle-group/mod.ts", + "@radix-ui/react-toolbar": "./pkg/radix-ui-primitives/preact/toolbar/mod.ts", + "@radix-ui/react-tooltip": "./pkg/radix-ui-primitives/preact/tooltip/mod.ts", + "@radix-ui/react-use-callback-ref": "./pkg/radix-ui-primitives/preact/use-callback-ref/mod.ts", + "@radix-ui/react-use-controllable-state": "./pkg/radix-ui-primitives/preact/use-controllable-state/mod.ts", + "@radix-ui/react-use-escape-keydown": "./pkg/radix-ui-primitives/preact/use-escape-keydown/mod.ts", + "@radix-ui/react-use-layout-effect": "./pkg/radix-ui-primitives/preact/use-layout-effect/mod.ts", + "@radix-ui/react-use-previous": "./pkg/radix-ui-primitives/preact/use-previous/mod.ts", + "@radix-ui/react-use-rect": "./pkg/radix-ui-primitives/preact/use-rect/mod.ts", + "@radix-ui/react-use-size": "./pkg/radix-ui-primitives/preact/use-size/mod.ts", + "@radix-ui/react-visually-hidden": "./pkg/radix-ui-primitives/preact/visually-hidden/mod.ts" } } diff --git a/pkg/api/cli-init.ts b/pkg/api/cli-init.ts index 015ce41..6af3631 100644 --- a/pkg/api/cli-init.ts +++ b/pkg/api/cli-init.ts @@ -1,6 +1,6 @@ -import { loadEnv } from "@hex/lib/options/env.ts"; -import { Connection } from "@api/data/connection.ts"; -import * as mod from "@api/mod.ts"; +import { loadEnv } from "$cool/hex/options/env.ts"; +import { Connection } from "$api/data/connection.ts"; +import * as mod from "$api/mod.ts"; // TODO(@eser) get dependency injection container entries instead of this const init = async () => { diff --git a/pkg/api/data/seed.ts b/pkg/api/data/seed.ts index bd45d32..b2a02d6 100644 --- a/pkg/api/data/seed.ts +++ b/pkg/api/data/seed.ts @@ -1,6 +1,6 @@ -import { ulid } from "@ulid"; -import { type ProfileEntity } from "@protocol/profile.ts"; -import { Connection } from "@api/data/connection.ts"; +import { ulid } from "$ulid/mod.ts"; +import { type ProfileEntity } from "$protocol/profile.ts"; +import { Connection } from "$api/data/connection.ts"; export const seed = async () => { const kv = await Connection.instance.getKv(); diff --git a/pkg/api/functions/profile-get/mod.ts b/pkg/api/functions/profile-get/mod.ts index b7e8a9f..1ca712c 100644 --- a/pkg/api/functions/profile-get/mod.ts +++ b/pkg/api/functions/profile-get/mod.ts @@ -1,8 +1,8 @@ -import { z } from "@zod"; -import { type LanguageCode } from "@protocol/languages.ts"; -import { type Profile, type ProfileEntity } from "@protocol/profile.ts"; -import { type ResultType } from "@protocol/result-type.ts"; -import { Connection } from "@api/data/connection.ts"; +import { z } from "$zod/mod.ts"; +import { type LanguageCode } from "$protocol/languages.ts"; +import { type Profile, type ProfileEntity } from "$protocol/profile.ts"; +import { type ResultType } from "$protocol/result-type.ts"; +import { Connection } from "$api/data/connection.ts"; export const profileGet = async ( slug: string, diff --git a/pkg/radix-ui-primitives/CODE_OF_CONDUCT.md b/pkg/radix-ui-primitives/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0ab2324 --- /dev/null +++ b/pkg/radix-ui-primitives/CODE_OF_CONDUCT.md @@ -0,0 +1,77 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and +expression, level of experience, education, socio-economic status, nationality, +personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, +offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at colm@workos.com. All complaints will +be reviewed and investigated and will result in a response that is deemed +necessary and appropriate to the circumstances. The project team is obligated to +maintain confidentiality with regard to the reporter of an incident. Further +details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 1.4, available at +https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/pkg/radix-ui-primitives/LICENSE b/pkg/radix-ui-primitives/LICENSE new file mode 100644 index 0000000..a18858f --- /dev/null +++ b/pkg/radix-ui-primitives/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 WorkOS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pkg/radix-ui-primitives/README.md b/pkg/radix-ui-primitives/README.md new file mode 100644 index 0000000..68a7a60 --- /dev/null +++ b/pkg/radix-ui-primitives/README.md @@ -0,0 +1,71 @@ +[![Radix Primitives Logo](primitives.png)](https://radix-ui.com/primitives) + +# Radix Primitives + +**An open-source UI component library for building high-quality, accessible +design systems and web apps.** + +Radix Primitives is a low-level UI component library with a focus on +accessibility, customization and developer experience. You can use these +components either as the base layer of your design system, or adopt them +incrementally. + +--- + +## Documentation + +For full documentation, visit +[radix-ui.com/docs/primitives](https://radix-ui.com/docs/primitives). + +## Releases + +For changelog, visit +[radix-ui.com/docs/primitives/overview/releases](https://radix-ui.com/docs/primitives/overview/releases). + +## Contributing + +Please follow our [contributing guidelines](./.github/CONTRIBUTING.md). + +## Authors + +- Benoit Grelard ([@benoitgrelard](https://twitter.com/benoitgrelard)) - + [WorkOS](https://workos.com) +- Jenna Smith ([@jjenzz](https://twitter.com/jjenzz)) +- Andy Hook ([@Andy_Hook](https://twitter.com/Andy_Hook)) - + [WorkOS](https://workos.com) +- Pedro Duarte ([@peduarte](https://twitter.com/peduarte)) +- Chance Strickland ([@chancethedev](https://twitter.com/chancethedev)) + +## Contributors + +- Ar Nazeh ([@Nazeh](https://github.com/Nazeh)) +- Fabio Capucci ([@cappuc](https://github.com/cappuc)) + +--- + +## Community + +- Pedro Duarte ([@peduarte](https://twitter.com/peduarte)) +- Colm Tuite ([@colmtuite](https://twitter.com/colmtuite)) - + [WorkOS](https://workos.com) + +- [Discord](https://discord.com/invite/7Xb99uG) - To get involved with the Radix + community, ask questions and share tips. +- [Twitter](https://twitter.com/radix_ui) - To receive updates, announcements, + blog posts, and general Radix tips. + +## Thanks + +Chromatic + +Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual +testing platform that helps us review UI changes and catch visual regressions. + +--- + +## License + +Licensed under the MIT License, Copyright © 2022-present +[WorkOS](https://workos.com). + +See [LICENSE](./LICENSE) for more information. diff --git a/pkg/radix-ui-primitives/core/number/mod.ts b/pkg/radix-ui-primitives/core/number/mod.ts new file mode 100644 index 0000000..2473260 --- /dev/null +++ b/pkg/radix-ui-primitives/core/number/mod.ts @@ -0,0 +1 @@ +export { clamp } from "./number.ts"; diff --git a/pkg/radix-ui-primitives/core/number/number.ts b/pkg/radix-ui-primitives/core/number/number.ts new file mode 100644 index 0000000..8b36e2e --- /dev/null +++ b/pkg/radix-ui-primitives/core/number/number.ts @@ -0,0 +1,5 @@ +function clamp(value: number, [min, max]: [number, number]): number { + return Math.min(max, Math.max(min, value)); +} + +export { clamp }; diff --git a/pkg/radix-ui-primitives/core/primitive/mod.ts b/pkg/radix-ui-primitives/core/primitive/mod.ts new file mode 100644 index 0000000..69c3be3 --- /dev/null +++ b/pkg/radix-ui-primitives/core/primitive/mod.ts @@ -0,0 +1 @@ +export { composeEventHandlers } from "./primitive.tsx"; diff --git a/pkg/radix-ui-primitives/core/primitive/primitive.tsx b/pkg/radix-ui-primitives/core/primitive/primitive.tsx new file mode 100644 index 0000000..1d3d6af --- /dev/null +++ b/pkg/radix-ui-primitives/core/primitive/primitive.tsx @@ -0,0 +1,18 @@ +function composeEventHandlers( + originalEventHandler?: (event: E) => void, + ourEventHandler?: (event: E) => void, + { checkForDefaultPrevented = true } = {}, +) { + return function handleEvent(event: E) { + originalEventHandler?.(event); + + if ( + checkForDefaultPrevented === false || + !((event as unknown) as Event).defaultPrevented + ) { + return ourEventHandler?.(event); + } + }; +} + +export { composeEventHandlers }; diff --git a/pkg/radix-ui-primitives/core/rect/mod.ts b/pkg/radix-ui-primitives/core/rect/mod.ts new file mode 100644 index 0000000..962e7ce --- /dev/null +++ b/pkg/radix-ui-primitives/core/rect/mod.ts @@ -0,0 +1,2 @@ +export { observeElementRect } from "./observeElementRect.ts"; +export type { Measurable } from "./observeElementRect.ts"; diff --git a/pkg/radix-ui-primitives/core/rect/observeElementRect.ts b/pkg/radix-ui-primitives/core/rect/observeElementRect.ts new file mode 100644 index 0000000..956627d --- /dev/null +++ b/pkg/radix-ui-primitives/core/rect/observeElementRect.ts @@ -0,0 +1,109 @@ +type Measurable = { getBoundingClientRect(): DOMRect }; + +/** + * Observes an element's rectangle on screen (getBoundingClientRect) + * This is useful to track elements on the screen and attach other elements + * that might be in different layers, etc. + */ +function observeElementRect( + /** The element whose rect to observe */ + elementToObserve: Measurable, + /** The callback which will be called when the rect changes */ + callback: CallbackFn, +) { + const observedData = observedElements.get(elementToObserve); + + if (observedData === undefined) { + // add the element to the map of observed elements with its first callback + // because this is the first time this element is observed + observedElements.set(elementToObserve, { + rect: {} as DOMRect, + callbacks: [callback], + }); + + if (observedElements.size === 1) { + // start the internal loop once at least 1 element is observed + rafId = requestAnimationFrame(runLoop); + } + } else { + // only add a callback for this element as it's already observed + observedData.callbacks.push(callback); + callback(elementToObserve.getBoundingClientRect()); + } + + return () => { + const observedData = observedElements.get(elementToObserve); + if (observedData === undefined) return; + + // start by removing the callback + const index = observedData.callbacks.indexOf(callback); + if (index > -1) { + observedData.callbacks.splice(index, 1); + } + + if (observedData.callbacks.length === 0) { + // stop observing this element because there are no + // callbacks registered for it anymore + observedElements.delete(elementToObserve); + + if (observedElements.size === 0) { + // stop the internal loop once no elements are observed anymore + cancelAnimationFrame(rafId); + } + } + }; +} + +// ======================================================================== +// module internals + +type CallbackFn = (rect: DOMRect) => void; + +type ObservedData = { + rect: DOMRect; + callbacks: Array; +}; + +let rafId: number; +const observedElements: Map = new Map(); + +function runLoop() { + const changedRectsData: Array = []; + + // process all DOM reads first (getBoundingClientRect) + observedElements.forEach((data, element) => { + const newRect = element.getBoundingClientRect(); + + // gather all the data for elements whose rects have changed + if (!rectEquals(data.rect, newRect)) { + data.rect = newRect; + changedRectsData.push(data); + } + }); + + // group DOM writes here after the DOM reads (getBoundingClientRect) + // as DOM writes will most likely happen with the callbacks + changedRectsData.forEach((data) => { + data.callbacks.forEach((callback) => callback(data.rect)); + }); + + rafId = requestAnimationFrame(runLoop); +} +// ======================================================================== + +/** + * Returns whether 2 rects are equal in values + */ +function rectEquals(rect1: DOMRect, rect2: DOMRect) { + return ( + rect1.width === rect2.width && + rect1.height === rect2.height && + rect1.top === rect2.top && + rect1.right === rect2.right && + rect1.bottom === rect2.bottom && + rect1.left === rect2.left + ); +} + +export { observeElementRect }; +export type { Measurable }; diff --git a/pkg/radix-ui-primitives/philosophy.md b/pkg/radix-ui-primitives/philosophy.md new file mode 100644 index 0000000..617413d --- /dev/null +++ b/pkg/radix-ui-primitives/philosophy.md @@ -0,0 +1,146 @@ +# Primitives: Philosophy and Guiding Principles + +## Vision + +Most of us share similar definitions for common UI patterns like accordion, +checkbox, combobox, dialog, dropdown, select, slider, and tooltip. These UI +patterns are +[documented by WAI-ARIA](https://www.w3.org/TR/wai-aria-practices/#aria_ex) and +generally understood by the community. + +However, the implementations provided to us by the web platform are inadequate. +They're either non-existent, lacking in functionality, or cannot be customised +sufficiently. + +As a result, developers are forced to build custom components—an incredibly +difficult task. As a result, most components on the web are inaccessible, +non-performant, and lacking important features. + +Our goal is to create a well-funded open-source component library that the +community can use to build accessible design systems. + +## Principles at a glance + +1. Accessible +2. Functional +3. Interoperable +4. Composable +5. Customizable + +--- + +## Principles + +### Accessible + +- Components adhere to WAI-ARIA guidelines and are tested regularly in a wide + selection of modern browsers and assistive technologies. +- Where WAI-ARIA guidelines do not cover a particular use case, prior research + is done to determine the patterns and behaviors we adopt when designing a new + component. We look to similar, well-tested native solutions to capture nuances + that WAI-ARIA guidelines may overlook. +- Developers should know about accessibility but shouldn't have to spend too + much time implementing accessible patterns. +- Most behavior and markup related to accessibility should be abstracted, and + bits that can't should be simplified where possible. +- Individual components will be tested to ensure maximum accessibility, but + where app context is required the component library should provide useful + guidance and supporting materials where possible to build fully accessible + applications. +- Try to name things as closely to `aria` and `html` as possible where + applicable; wherever we require developers to engage with accessibility + directly, our platform should be a learning opportunity and a bridge for + better understanding the underlying problems we solve. +- Components are thoroughly tested on a variety of devices and assistive + technology, including all major screen reader vendors (VoiceOver, JAWS, NVDA); + components respond and adapt effectively to input and appearance distinctions + between platforms. + +### Functional + +- Components are feature-rich, with support for keyboard interaction, collision + detection, focus trapping, dynamic resizing, scroll locking, native fallbacks, + and more. + +### Composable + +- Components are designed with an open API that provides consumers with direct + access to the underlying DOM node that is rendered to the page. +- We achieve this API with a 1-to-1 strategy, where a single component only + renders a single DOM element (if a DOM node is rendered at all). +- Some abstractions may require slight deviation from this pattern, in which + case the rationale should be clearly explained in supporting documentation. +- This API also empowers us to forward user-provided DOM refs to the correct + underlying DOM node without doing anything too clever, meaning refs function + exactly as the consumer would expect. +- Just as DOM nodes are composable, so are DOM event handlers; consumers should + be able to pass their own event handlers directly to a component and stop + internal handlers from firing. + +### Customizable + +- Components are built to be themed; no need to override opinionated styles, as + primitives ship with zero presentational styles applied by default. +- Components ship with CSS-in-JS style objects that provide a minimal set styles + needed to easily reset user agent styles and provide a clean slate for + consumers to build upon. +- Our components can be composed or styled the same way underlying JSX + components are composed or styled, with limitations only introduced to prevent + UX/accessibility dark patterns where needed. +- Consumers can choose whether or not to apply these styles in their app, as + well as the styling tool; we do not enforce a particular methodology or + library. + +## Other considerations + +### Internationalization + +- Components support international string formatting and make behavioral + adjustments for right-to-left languages + +### Stateful components can be controlled or uncontrolled + +- Similar to form field JSX elements in React, all components with internal + state can either be uncontrolled (internally managed) or controlled (managed + by the consumer) + +### Components exist in a finite number of predefined states + +- State in this context refers to a component's state representable by a finite + state machine; not to be confused with arbitrary stateful data as typically + referenced in React libraries +- States are predetermined during the component design phase and expressed as + strings in component code, making state transitions more explicit, + deterministic, and clearer to follow +- Use the `data-state` attribute to expose a component's state directly to its + DOM element +- When tempted to use a boolean to track a piece of stateful data, consider + enumerated strings instead + +### Developer experience + +- Component APIs should be relatively intuitive and as declarative as possible +- Provide in-code documentation for complex/unclear abstractions for easier + source debugging +- Anticipate errors and provide thorough console warnings with links back to + documentation + +### Balancing tradeoffs between design goals + +- Composition is preferred over configuration +- Code clarity is preferred over bundle terseness except in extreme cases +- Smart abstractions preferred over over-exposing internal state + +### Documentation + +TODO + +### Misc + +- Not concerned with design system components like `Box`, `Chip` or `Badge` that + provide visual language consistency but provide no underlying semantic meaning + or abstracted behavior +- Keep file structure flat so logic is easier to follow; avoid early + abstractions +- Don't repeat yourself _too much_ but don't be afraid to repeat yourself if an + implementation detail hasn't been thoroughly vetted diff --git a/pkg/radix-ui-primitives/preact/accessible-icon/AccessibleIcon.tsx b/pkg/radix-ui-primitives/preact/accessible-icon/AccessibleIcon.tsx new file mode 100644 index 0000000..b57a56f --- /dev/null +++ b/pkg/radix-ui-primitives/preact/accessible-icon/AccessibleIcon.tsx @@ -0,0 +1,39 @@ +import * as React from "preact/compat"; +import * as VisuallyHiddenPrimitive from "../visually-hidden/mod.ts"; + +const NAME = "AccessibleIcon"; + +interface AccessibleIconProps { + children?: React.ComponentChildren; + /** + * The accessible label for the icon. This label will be visually hidden but announced to screen + * reader users, similar to `alt` text for `img` tags. + */ + label: string; +} + +const AccessibleIcon: React.FC = ( + { children, label }, +) => { + return ( + <> + {React.cloneElement(children as React.VNode, { + // accessibility + "aria-hidden": "true", + focusable: "false", // See: https://allyjs.io/tutorials/focusing-in-svg.html#making-svg-elements-focusable + })} + {label} + + ); +}; + +AccessibleIcon.displayName = NAME; + +const Root = AccessibleIcon; + +export { + AccessibleIcon, + // + Root, +}; +export type { AccessibleIconProps }; diff --git a/pkg/radix-ui-primitives/preact/accessible-icon/mod.ts b/pkg/radix-ui-primitives/preact/accessible-icon/mod.ts new file mode 100644 index 0000000..3b4f759 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/accessible-icon/mod.ts @@ -0,0 +1,6 @@ +export { + AccessibleIcon, + // + Root, +} from "./AccessibleIcon.tsx"; +export type { AccessibleIconProps } from "./AccessibleIcon.tsx"; diff --git a/pkg/radix-ui-primitives/preact/accordion/Accordion.tsx b/pkg/radix-ui-primitives/preact/accordion/Accordion.tsx new file mode 100644 index 0000000..d920ece --- /dev/null +++ b/pkg/radix-ui-primitives/preact/accordion/Accordion.tsx @@ -0,0 +1,649 @@ +import * as React from "preact/compat"; +import { createContextScope } from "../context/mod.ts"; +import { createCollection } from "../collection/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import * as CollapsiblePrimitive from "../collapsible/mod.ts"; +import { createCollapsibleScope } from "../collapsible/mod.ts"; +import { useId } from "../id/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; +import { useDirection } from "../direction/mod.ts"; + +type Direction = "ltr" | "rtl"; + +/* ------------------------------------------------------------------------------------------------- + * Accordion + * -----------------------------------------------------------------------------------------------*/ + +const ACCORDION_NAME = "Accordion"; +const ACCORDION_KEYS = [ + "Home", + "End", + "ArrowDown", + "ArrowUp", + "ArrowLeft", + "ArrowRight", +]; + +const [Collection, useCollection, createCollectionScope] = createCollection< + AccordionTriggerElement +>(ACCORDION_NAME); + +type ScopedProps

= P & { __scopeAccordion?: Scope }; +const [createAccordionContext, createAccordionScope] = createContextScope( + ACCORDION_NAME, + [ + createCollectionScope, + createCollapsibleScope, + ], +); +const useCollapsibleScope = createCollapsibleScope(); + +type AccordionElement = + | AccordionImplMultipleElement + | AccordionImplSingleElement; +interface AccordionSingleProps extends AccordionImplSingleProps { + type: "single"; +} +interface AccordionMultipleProps extends AccordionImplMultipleProps { + type: "multiple"; +} + +const Accordion = React.forwardRef< + AccordionElement, + AccordionSingleProps | AccordionMultipleProps +>( + ( + props: ScopedProps, + forwardedRef, + ) => { + const { type, ...accordionProps } = props; + const singleProps = accordionProps as AccordionImplSingleProps; + const multipleProps = accordionProps as AccordionImplMultipleProps; + return ( + + {type === "multiple" + ? + : } + + ); + }, +); + +Accordion.displayName = ACCORDION_NAME; + +Accordion.propTypes = { + type(props) { + const value = props.value || props.defaultValue; + if (props.type && !["single", "multiple"].includes(props.type)) { + return new Error( + "Invalid prop `type` supplied to `Accordion`. Expected one of `single | multiple`.", + ); + } + if (props.type === "multiple" && typeof value === "string") { + return new Error( + "Invalid prop `type` supplied to `Accordion`. Expected `single` when `defaultValue` or `value` is type `string`.", + ); + } + if (props.type === "single" && Array.isArray(value)) { + return new Error( + "Invalid prop `type` supplied to `Accordion`. Expected `multiple` when `defaultValue` or `value` is type `string[]`.", + ); + } + return null; + }, +}; + +/* -----------------------------------------------------------------------------------------------*/ + +type AccordionValueContextValue = { + value: string[]; + onItemOpen(value: string): void; + onItemClose(value: string): void; +}; + +const [AccordionValueProvider, useAccordionValueContext] = + createAccordionContext(ACCORDION_NAME); + +const [AccordionCollapsibleProvider, useAccordionCollapsibleContext] = + createAccordionContext( + ACCORDION_NAME, + { collapsible: false }, + ); + +type AccordionImplSingleElement = AccordionImplElement; +interface AccordionImplSingleProps extends AccordionImplProps { + /** + * The controlled stateful value of the accordion item whose content is expanded. + */ + value?: string; + /** + * The value of the item whose content is expanded when the accordion is initially rendered. Use + * `defaultValue` if you do not need to control the state of an accordion. + */ + defaultValue?: string; + /** + * The callback that fires when the state of the accordion changes. + */ + onValueChange?(value: string): void; + /** + * Whether an accordion item can be collapsed after it has been opened. + * @default false + */ + collapsible?: boolean; +} + +const AccordionImplSingle = React.forwardRef< + AccordionImplSingleElement, + AccordionImplSingleProps +>( + (props: ScopedProps, forwardedRef) => { + const { + value: valueProp, + defaultValue, + onValueChange = () => {}, + collapsible = false, + ...accordionSingleProps + } = props; + + const [value, setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue, + onChange: onValueChange, + }); + + return ( + collapsible && setValue(""), [ + collapsible, + setValue, + ])} + > + + + + + ); + }, +); + +/* -----------------------------------------------------------------------------------------------*/ + +type AccordionImplMultipleElement = AccordionImplElement; +interface AccordionImplMultipleProps extends AccordionImplProps { + /** + * The controlled stateful value of the accordion items whose contents are expanded. + */ + value?: string[]; + /** + * The value of the items whose contents are expanded when the accordion is initially rendered. Use + * `defaultValue` if you do not need to control the state of an accordion. + */ + defaultValue?: string[]; + /** + * The callback that fires when the state of the accordion changes. + */ + onValueChange?(value: string[]): void; +} + +const AccordionImplMultiple = React.forwardRef< + AccordionImplMultipleElement, + AccordionImplMultipleProps +>((props: ScopedProps, forwardedRef) => { + const { + value: valueProp, + defaultValue, + onValueChange = () => {}, + ...accordionMultipleProps + } = props; + + const [value = [], setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue, + onChange: onValueChange, + }); + + const handleItemOpen = React.useCallback( + (itemValue: string) => + setValue((prevValue = []) => [...prevValue, itemValue]), + [setValue], + ); + + const handleItemClose = React.useCallback( + (itemValue: string) => + setValue((prevValue = []) => + prevValue.filter((value) => value !== itemValue) + ), + [setValue], + ); + + return ( + + + + + + ); +}); + +/* -----------------------------------------------------------------------------------------------*/ + +type AccordionImplContextValue = { + disabled?: boolean; + direction: AccordionImplProps["dir"]; + orientation: AccordionImplProps["orientation"]; +}; + +const [AccordionImplProvider, useAccordionContext] = createAccordionContext< + AccordionImplContextValue +>(ACCORDION_NAME); + +type AccordionImplElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface AccordionImplProps extends PrimitiveDivProps { + /** + * Whether or not an accordion is disabled from user interaction. + * + * @defaultValue false + */ + disabled?: boolean; + /** + * The layout in which the Accordion operates. + * @default vertical + */ + orientation?: React.AriaAttributes["aria-orientation"]; + /** + * The language read direction. + */ + dir?: Direction; +} + +const AccordionImpl = React.forwardRef< + AccordionImplElement, + AccordionImplProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopeAccordion, + disabled, + dir, + orientation = "vertical", + ...accordionProps + } = props; + const accordionRef = React.useRef(null); + const composedRefs = useComposedRefs(accordionRef, forwardedRef); + const getItems = useCollection(__scopeAccordion); + const direction = useDirection(dir); + const isDirectionLTR = direction === "ltr"; + + const handleKeyDown = composeEventHandlers(props.onKeyDown, (event) => { + if (!ACCORDION_KEYS.includes(event.key)) return; + const target = event.target as HTMLElement; + const triggerCollection = getItems().filter((item) => + !item.ref.current?.disabled + ); + const triggerIndex = triggerCollection.findIndex((item) => + item.ref.current === target + ); + const triggerCount = triggerCollection.length; + + if (triggerIndex === -1) return; + + // Prevents page scroll while user is navigating + event.preventDefault(); + + let nextIndex = triggerIndex; + const homeIndex = 0; + const endIndex = triggerCount - 1; + + const moveNext = () => { + nextIndex = triggerIndex + 1; + if (nextIndex > endIndex) { + nextIndex = homeIndex; + } + }; + + const movePrev = () => { + nextIndex = triggerIndex - 1; + if (nextIndex < homeIndex) { + nextIndex = endIndex; + } + }; + + switch (event.key) { + case "Home": + nextIndex = homeIndex; + break; + case "End": + nextIndex = endIndex; + break; + case "ArrowRight": + if (orientation === "horizontal") { + if (isDirectionLTR) { + moveNext(); + } else { + movePrev(); + } + } + break; + case "ArrowDown": + if (orientation === "vertical") { + moveNext(); + } + break; + case "ArrowLeft": + if (orientation === "horizontal") { + if (isDirectionLTR) { + movePrev(); + } else { + moveNext(); + } + } + break; + case "ArrowUp": + if (orientation === "vertical") { + movePrev(); + } + break; + } + + const clampedIndex = nextIndex % triggerCount; + triggerCollection[clampedIndex].ref.current?.focus(); + }); + + return ( + + + + + + ); + }, +); + +/* ------------------------------------------------------------------------------------------------- + * AccordionItem + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_NAME = "AccordionItem"; + +type AccordionItemContextValue = { + open?: boolean; + disabled?: boolean; + triggerId: string; +}; +const [AccordionItemProvider, useAccordionItemContext] = createAccordionContext< + AccordionItemContextValue +>(ITEM_NAME); + +type AccordionItemElement = React.ElementRef; +type CollapsibleProps = Radix.ComponentPropsWithoutRef< + typeof CollapsiblePrimitive.Root +>; +interface AccordionItemProps + extends Omit { + /** + * Whether or not an accordion item is disabled from user interaction. + * + * @defaultValue false + */ + disabled?: boolean; + /** + * A string value for the accordion item. All items within an accordion should use a unique value. + */ + value: string; +} + +/** + * `AccordionItem` contains all of the parts of a collapsible section inside of an `Accordion`. + */ +const AccordionItem = React.forwardRef< + AccordionItemElement, + AccordionItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAccordion, value, ...accordionItemProps } = props; + const accordionContext = useAccordionContext(ITEM_NAME, __scopeAccordion); + const valueContext = useAccordionValueContext(ITEM_NAME, __scopeAccordion); + const collapsibleScope = useCollapsibleScope(__scopeAccordion); + const triggerId = useId(); + const open = (value && valueContext.value.includes(value)) || false; + const disabled = accordionContext.disabled || props.disabled; + + return ( + + { + if (open) { + valueContext.onItemOpen(value); + } else { + valueContext.onItemClose(value); + } + }} + /> + + ); + }, +); + +AccordionItem.displayName = ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AccordionHeader + * -----------------------------------------------------------------------------------------------*/ + +const HEADER_NAME = "AccordionHeader"; + +type AccordionHeaderElement = React.ElementRef; +type PrimitiveHeading3Props = Radix.ComponentPropsWithoutRef< + typeof Primitive.h3 +>; +interface AccordionHeaderProps extends PrimitiveHeading3Props {} + +/** + * `AccordionHeader` contains the content for the parts of an `AccordionItem` that will be visible + * whether or not its content is collapsed. + */ +const AccordionHeader = React.forwardRef< + AccordionHeaderElement, + AccordionHeaderProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAccordion, ...headerProps } = props; + const accordionContext = useAccordionContext( + ACCORDION_NAME, + __scopeAccordion, + ); + const itemContext = useAccordionItemContext(HEADER_NAME, __scopeAccordion); + return ( + + ); + }, +); + +AccordionHeader.displayName = HEADER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AccordionTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "AccordionTrigger"; + +type AccordionTriggerElement = React.ElementRef< + typeof CollapsiblePrimitive.Trigger +>; +type CollapsibleTriggerProps = Radix.ComponentPropsWithoutRef< + typeof CollapsiblePrimitive.Trigger +>; +interface AccordionTriggerProps extends CollapsibleTriggerProps {} + +/** + * `AccordionTrigger` is the trigger that toggles the collapsed state of an `AccordionItem`. It + * should always be nested inside of an `AccordionHeader`. + */ +const AccordionTrigger = React.forwardRef< + AccordionTriggerElement, + AccordionTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAccordion, ...triggerProps } = props; + const accordionContext = useAccordionContext( + ACCORDION_NAME, + __scopeAccordion, + ); + const itemContext = useAccordionItemContext(TRIGGER_NAME, __scopeAccordion); + const collapsibleContext = useAccordionCollapsibleContext( + TRIGGER_NAME, + __scopeAccordion, + ); + const collapsibleScope = useCollapsibleScope(__scopeAccordion); + return ( + + + + ); + }, +); + +AccordionTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AccordionContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "AccordionContent"; + +type AccordionContentElement = React.ElementRef< + typeof CollapsiblePrimitive.Content +>; +type CollapsibleContentProps = Radix.ComponentPropsWithoutRef< + typeof CollapsiblePrimitive.Content +>; +interface AccordionContentProps extends CollapsibleContentProps {} + +/** + * `AccordionContent` contains the collapsible content for an `AccordionItem`. + */ +const AccordionContent = React.forwardRef< + AccordionContentElement, + AccordionContentProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAccordion, ...contentProps } = props; + const accordionContext = useAccordionContext( + ACCORDION_NAME, + __scopeAccordion, + ); + const itemContext = useAccordionItemContext(CONTENT_NAME, __scopeAccordion); + const collapsibleScope = useCollapsibleScope(__scopeAccordion); + return ( + + ); + }, +); + +AccordionContent.displayName = CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +function getState(open?: boolean) { + return open ? "open" : "closed"; +} + +const Root = Accordion; +const Item = AccordionItem; +const Header = AccordionHeader; +const Trigger = AccordionTrigger; +const Content = AccordionContent; + +export { + // + Accordion, + AccordionContent, + AccordionHeader, + AccordionItem, + AccordionTrigger, + Content, + createAccordionScope, + Header, + Item, + // + Root, + Trigger, +}; +export type { + AccordionContentProps, + AccordionHeaderProps, + AccordionItemProps, + AccordionMultipleProps, + AccordionSingleProps, + AccordionTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/accordion/mod.ts b/pkg/radix-ui-primitives/preact/accordion/mod.ts new file mode 100644 index 0000000..ba9a77b --- /dev/null +++ b/pkg/radix-ui-primitives/preact/accordion/mod.ts @@ -0,0 +1,23 @@ +export { + // + Accordion, + AccordionContent, + AccordionHeader, + AccordionItem, + AccordionTrigger, + Content, + createAccordionScope, + Header, + Item, + // + Root, + Trigger, +} from "./Accordion.tsx"; +export type { + AccordionContentProps, + AccordionHeaderProps, + AccordionItemProps, + AccordionMultipleProps, + AccordionSingleProps, + AccordionTriggerProps, +} from "./Accordion.tsx"; diff --git a/pkg/radix-ui-primitives/preact/alert-dialog/AlertDialog.tsx b/pkg/radix-ui-primitives/preact/alert-dialog/AlertDialog.tsx new file mode 100644 index 0000000..e85d1b8 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/alert-dialog/AlertDialog.tsx @@ -0,0 +1,405 @@ +import * as React from "preact/compat"; +import { createContextScope } from "../context/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import * as DialogPrimitive from "../dialog/mod.ts"; +import { createDialogScope } from "../dialog/mod.ts"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { Slottable } from "../slot/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialog + * -----------------------------------------------------------------------------------------------*/ + +const ROOT_NAME = "AlertDialog"; + +type ScopedProps

= P & { __scopeAlertDialog?: Scope }; +const [createAlertDialogContext, createAlertDialogScope] = createContextScope( + ROOT_NAME, + [ + createDialogScope, + ], +); +const useDialogScope = createDialogScope(); + +type DialogProps = Radix.ComponentPropsWithoutRef; +interface AlertDialogProps extends Omit {} + +const AlertDialog: React.FC = ( + props: ScopedProps, +) => { + const { __scopeAlertDialog, ...alertDialogProps } = props; + const dialogScope = useDialogScope(__scopeAlertDialog); + return ( + + ); +}; + +AlertDialog.displayName = ROOT_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialogTrigger + * -----------------------------------------------------------------------------------------------*/ +const TRIGGER_NAME = "AlertDialogTrigger"; + +type AlertDialogTriggerElement = React.ElementRef< + typeof DialogPrimitive.Trigger +>; +type DialogTriggerProps = Radix.ComponentPropsWithoutRef< + typeof DialogPrimitive.Trigger +>; +interface AlertDialogTriggerProps extends DialogTriggerProps {} + +const AlertDialogTrigger = React.forwardRef< + AlertDialogTriggerElement, + AlertDialogTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAlertDialog, ...triggerProps } = props; + const dialogScope = useDialogScope(__scopeAlertDialog); + return ( + + ); + }, +); + +AlertDialogTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialogPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "AlertDialogPortal"; + +type DialogPortalProps = Radix.ComponentPropsWithoutRef< + typeof DialogPrimitive.Portal +>; +interface AlertDialogPortalProps extends DialogPortalProps {} + +const AlertDialogPortal: React.FC = ( + props: ScopedProps, +) => { + const { __scopeAlertDialog, ...portalProps } = props; + const dialogScope = useDialogScope(__scopeAlertDialog); + return ; +}; + +AlertDialogPortal.displayName = PORTAL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialogOverlay + * -----------------------------------------------------------------------------------------------*/ + +const OVERLAY_NAME = "AlertDialogOverlay"; + +type AlertDialogOverlayElement = React.ElementRef< + typeof DialogPrimitive.Overlay +>; +type DialogOverlayProps = Radix.ComponentPropsWithoutRef< + typeof DialogPrimitive.Overlay +>; +interface AlertDialogOverlayProps extends DialogOverlayProps {} + +const AlertDialogOverlay = React.forwardRef< + AlertDialogOverlayElement, + AlertDialogOverlayProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAlertDialog, ...overlayProps } = props; + const dialogScope = useDialogScope(__scopeAlertDialog); + return ( + + ); + }, +); + +AlertDialogOverlay.displayName = OVERLAY_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialogContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "AlertDialogContent"; + +type AlertDialogContentContextValue = { + cancelRef: React.MutableRefObject; +}; + +const [AlertDialogContentProvider, useAlertDialogContentContext] = + createAlertDialogContext(CONTENT_NAME); + +type AlertDialogContentElement = React.ElementRef< + typeof DialogPrimitive.Content +>; +type DialogContentProps = Radix.ComponentPropsWithoutRef< + typeof DialogPrimitive.Content +>; +interface AlertDialogContentProps + extends + Omit {} + +const AlertDialogContent = React.forwardRef< + AlertDialogContentElement, + AlertDialogContentProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAlertDialog, children, ...contentProps } = props; + const dialogScope = useDialogScope(__scopeAlertDialog); + const contentRef = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, contentRef); + const cancelRef = React.useRef(null); + + return ( + + + { + event.preventDefault(); + cancelRef.current?.focus({ preventScroll: true }); + }, + )} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + { + /** + * We have to use `Slottable` here as we cannot wrap the `AlertDialogContentProvider` + * around everything, otherwise the `DescriptionWarning` would be rendered straight away. + * This is because we want the accessibility checks to run only once the content is actually + * open and that behaviour is already encapsulated in `DialogContent`. + */ + } + {children} + {process.env.NODE_ENV === "development" && ( + + )} + + + + ); + }, +); + +AlertDialogContent.displayName = CONTENT_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialogTitle + * -----------------------------------------------------------------------------------------------*/ + +const TITLE_NAME = "AlertDialogTitle"; + +type AlertDialogTitleElement = React.ElementRef; +type DialogTitleProps = Radix.ComponentPropsWithoutRef< + typeof DialogPrimitive.Title +>; +interface AlertDialogTitleProps extends DialogTitleProps {} + +const AlertDialogTitle = React.forwardRef< + AlertDialogTitleElement, + AlertDialogTitleProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAlertDialog, ...titleProps } = props; + const dialogScope = useDialogScope(__scopeAlertDialog); + return ( + + ); + }, +); + +AlertDialogTitle.displayName = TITLE_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialogDescription + * -----------------------------------------------------------------------------------------------*/ + +const DESCRIPTION_NAME = "AlertDialogDescription"; + +type AlertDialogDescriptionElement = React.ElementRef< + typeof DialogPrimitive.Description +>; +type DialogDescriptionProps = Radix.ComponentPropsWithoutRef< + typeof DialogPrimitive.Description +>; +interface AlertDialogDescriptionProps extends DialogDescriptionProps {} + +const AlertDialogDescription = React.forwardRef< + AlertDialogDescriptionElement, + AlertDialogDescriptionProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeAlertDialog, ...descriptionProps } = props; + const dialogScope = useDialogScope(__scopeAlertDialog); + return ( + + ); +}); + +AlertDialogDescription.displayName = DESCRIPTION_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialogAction + * -----------------------------------------------------------------------------------------------*/ + +const ACTION_NAME = "AlertDialogAction"; + +type AlertDialogActionElement = React.ElementRef; +type DialogCloseProps = Radix.ComponentPropsWithoutRef< + typeof DialogPrimitive.Close +>; +interface AlertDialogActionProps extends DialogCloseProps {} + +const AlertDialogAction = React.forwardRef< + AlertDialogActionElement, + AlertDialogActionProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAlertDialog, ...actionProps } = props; + const dialogScope = useDialogScope(__scopeAlertDialog); + return ( + + ); + }, +); + +AlertDialogAction.displayName = ACTION_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AlertDialogCancel + * -----------------------------------------------------------------------------------------------*/ + +const CANCEL_NAME = "AlertDialogCancel"; + +type AlertDialogCancelElement = React.ElementRef; +interface AlertDialogCancelProps extends DialogCloseProps {} + +const AlertDialogCancel = React.forwardRef< + AlertDialogCancelElement, + AlertDialogCancelProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAlertDialog, ...cancelProps } = props; + const { cancelRef } = useAlertDialogContentContext( + CANCEL_NAME, + __scopeAlertDialog, + ); + const dialogScope = useDialogScope(__scopeAlertDialog); + const ref = useComposedRefs(forwardedRef, cancelRef); + return ( + + ); + }, +); + +AlertDialogCancel.displayName = CANCEL_NAME; + +/* ---------------------------------------------------------------------------------------------- */ + +type DescriptionWarningProps = { + contentRef: React.RefObject; +}; + +const DescriptionWarning: React.FC = ( + { contentRef }, +) => { + const MESSAGE = + `\`${CONTENT_NAME}\` requires a description for the component to be accessible for screen reader users. + +You can add a description to the \`${CONTENT_NAME}\` by passing a \`${DESCRIPTION_NAME}\` component as a child, which also benefits sighted users by adding visible context to the dialog. + +Alternatively, you can use your own component as a description by assigning it an \`id\` and passing the same value to the \`aria-describedby\` prop in \`${CONTENT_NAME}\`. If the description is confusing or duplicative for sighted users, you can use the \`@radix-ui/react-visually-hidden\` primitive as a wrapper around your description component. + +For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`; + + React.useEffect(() => { + const hasDescription = document.getElementById( + contentRef.current?.getAttribute("aria-describedby")!, + ); + if (!hasDescription) console.warn(MESSAGE); + }, [MESSAGE, contentRef]); + + return null; +}; + +const Root = AlertDialog; +const Trigger = AlertDialogTrigger; +const Portal = AlertDialogPortal; +const Overlay = AlertDialogOverlay; +const Content = AlertDialogContent; +const Action = AlertDialogAction; +const Cancel = AlertDialogCancel; +const Title = AlertDialogTitle; +const Description = AlertDialogDescription; + +export { + Action, + // + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, + Cancel, + Content, + createAlertDialogScope, + Description, + Overlay, + Portal, + // + Root, + Title, + Trigger, +}; +export type { + AlertDialogActionProps, + AlertDialogCancelProps, + AlertDialogContentProps, + AlertDialogDescriptionProps, + AlertDialogOverlayProps, + AlertDialogPortalProps, + AlertDialogProps, + AlertDialogTitleProps, + AlertDialogTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/alert-dialog/mod.ts b/pkg/radix-ui-primitives/preact/alert-dialog/mod.ts new file mode 100644 index 0000000..cd92efc --- /dev/null +++ b/pkg/radix-ui-primitives/preact/alert-dialog/mod.ts @@ -0,0 +1,34 @@ +export { + Action, + // + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, + Cancel, + Content, + createAlertDialogScope, + Description, + Overlay, + Portal, + // + Root, + Title, + Trigger, +} from "./AlertDialog.tsx"; +export type { + AlertDialogActionProps, + AlertDialogCancelProps, + AlertDialogContentProps, + AlertDialogDescriptionProps, + AlertDialogOverlayProps, + AlertDialogPortalProps, + AlertDialogProps, + AlertDialogTitleProps, + AlertDialogTriggerProps, +} from "./AlertDialog.tsx"; diff --git a/pkg/radix-ui-primitives/preact/announce/Announce.tsx b/pkg/radix-ui-primitives/preact/announce/Announce.tsx new file mode 100644 index 0000000..71fe023 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/announce/Announce.tsx @@ -0,0 +1,265 @@ +import * as React from "preact/compat"; +import * as ReactDOM from "preact/compat"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { useLayoutEffect } from "../use-layout-effect/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; + +type RegionType = "polite" | "assertive" | "off"; +type RegionRole = "status" | "alert" | "log" | "none"; + +const ROLES: { [key in RegionType]: RegionRole } = { + polite: "status", + assertive: "alert", + off: "none", +}; + +const listenerMap = new Map(); + +/* ------------------------------------------------------------------------------------------------- + * Announce + * -----------------------------------------------------------------------------------------------*/ + +const NAME = "Announce"; + +type AnnounceElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface AnnounceProps extends PrimitiveDivProps { + /** + * Mirrors the `aria-atomic` DOM attribute for live regions. It is an optional attribute that + * indicates whether assistive technologies will present all, or only parts of, the changed region + * based on the change notifications defined by the `aria-relevant` attribute. + * + * @see WAI-ARIA https://www.w3.org/TR/wai-aria-1.2/#aria-atomic + * @see Demo http://pauljadam.com/demos/aria-atomic-relevant.html + */ + "aria-atomic"?: boolean; + /** + * Mirrors the `aria-relevant` DOM attribute for live regions. It is an optional attribute used to + * describe what types of changes have occurred to the region, and which changes are relevant and + * should be announced. Any change that is not relevant acts in the same manner it would if the + * `aria-live` attribute were set to off. + * + * Unfortunately, `aria-relevant` doesn't behave as expected across all device/screen reader + * combinations. It's important to test its implementation before relying on it to work for your + * users. The attribute is omitted by default. + * + * @see WAI-ARIA https://www.w3.org/TR/wai-aria-1.2/#aria-relevant + * @see MDN https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-relevant_attribute + * @see Opinion https://medium.com/dev-channel/why-authors-should-avoid-aria-relevant-5d3164fab1e3 + * @see Demo http://pauljadam.com/demos/aria-atomic-relevant.html + */ + "aria-relevant"?: PrimitiveDivProps["aria-relevant"]; + /** + * React children of your component. Children can be mirrored directly or modified to optimize for + * screen reader user experience. + */ + children: React.ComponentChildren; + /** + * An optional unique identifier for the live region. + * + * By default, `Announce` components create, at most, two unique `aria-live` regions in the + * document (one for all `polite` notifications, one for all `assertive` notifications). In some + * cases you may wish to append additional `aria-live` regions for distinct purposes (for example, + * simple status updates may need to be separated from a stack of toast-style notifications). By + * passing an id, you indicate that any content rendered by components with the same identifier + * should be mirrored in a separate `aria-live` region. + */ + regionIdentifier?: string; + /** + * Mirrors the `role` DOM attribute. This is optional and may be useful as an override in some + * cases. By default, the role is determined by the `type` prop. + * + * @see MDN https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions#Preferring_specialized_live_region_roles + */ + role?: RegionRole; + /** + * Mirrors the `aria-live` DOM attribute. The `aria-live=POLITENESS_SETTING` is used to set the + * priority with which screen reader should treat updates to live regions. Its possible settings + * are: off, polite or assertive. Defaults to `polite`. + * + * @see MDN https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions + */ + type?: RegionType; +} + +const Announce = React.forwardRef( + (props, forwardedRef) => { + const { + "aria-relevant": ariaRelevant, + children, + type = "polite", + role = ROLES[type], + regionIdentifier, + ...regionProps + } = props; + + const ariaAtomic = ["true", true].includes( + regionProps["aria-atomic"] as any, + ); + + // The region is appended to the root document node, which is usually the global `document` but in + // some contexts may be another node. After the Announce element ref is attached, we set the + // ownerDocumentRef to make sure we have the right root node. We should only need to do this once. + const ownerDocumentRef = React.useRef(document); + const setOwnerDocumentFromRef = React.useCallback( + (node: HTMLDivElement) => { + if (node) { + ownerDocumentRef.current = node.ownerDocument; + } + }, + [], + ); + const ownRef = React.useRef(null); + const ref = useComposedRefs(forwardedRef, ownRef, setOwnerDocumentFromRef); + + const [region, setRegion] = React.useState(); + const relevant = ariaRelevant + ? Array.isArray(ariaRelevant) ? ariaRelevant.join(" ") : ariaRelevant + : undefined; + + const getLiveRegionElement = React.useCallback(() => { + const ownerDocument = ownerDocumentRef.current; + const regionConfig = { + type, + role, + relevant, + id: regionIdentifier, + atomic: ariaAtomic, + }; + const regionSelector = buildSelector(regionConfig); + const element = ownerDocument.querySelector(regionSelector); + + return element || buildLiveRegionElement(ownerDocument, regionConfig); + }, [ariaAtomic, relevant, role, type, regionIdentifier]); + + useLayoutEffect(() => { + setRegion(getLiveRegionElement() as HTMLElement); + }, [getLiveRegionElement]); + + // In some screen reader/browser combinations, alerts coming from an inactive browser tab may be + // announced, which is a confusing experience for a user interacting with a completely different + // page. When the page visibility changes we'll update the `role` and `aria-live` attributes of + // our region element to prevent that. + // https://inclusive-components.design/notifications/#restrictingmessagestocontexts + React.useEffect(() => { + const ownerDocument = ownerDocumentRef.current; + function updateAttributesOnVisibilityChange() { + regionElement.setAttribute( + "role", + ownerDocument.hidden ? "none" : role, + ); + regionElement.setAttribute( + "aria-live", + ownerDocument.hidden ? "off" : type, + ); + } + + // Ok, so this might look a little weird and confusing, but here's what's going on: + // - We need to hide `aria-live` regions via a global event listener, as noted in the comment + // above. + // - We only need one listener per region. Keep in mind that each `Announce` does not + // necessarily generate a unique live region element. + // - We track whether or not a listener has already been attached for a given region in a map + // so we can skip these effects after `Announce` is used again with a shared live region. + const regionElement = getLiveRegionElement(); + + if (!listenerMap.get(regionElement)) { + ownerDocument.addEventListener( + "visibilitychange", + updateAttributesOnVisibilityChange, + ); + listenerMap.set(regionElement, 1); + } else { + const announceCount = listenerMap.get(regionElement)!; + listenerMap.set(regionElement, announceCount + 1); + } + + return function () { + const announceCount = listenerMap.get(regionElement)!; + listenerMap.set(regionElement, announceCount - 1); + if (announceCount === 1) { + ownerDocument.removeEventListener( + "visibilitychange", + updateAttributesOnVisibilityChange, + ); + } + }; + }, [getLiveRegionElement, role, type]); + + return ( + + + {children} + + + {/* portal into live region for screen reader announcements */} + {region && ReactDOM.createPortal(

{children}
, region)} + + ); + }, +); + +Announce.displayName = NAME; + +/* ---------------------------------------------------------------------------------------------- */ + +type LiveRegionOptions = { + type: string; + relevant?: string; + role: string; + atomic?: boolean; + id?: string; +}; + +function buildLiveRegionElement( + ownerDocument: Document, + { type, relevant, role, atomic, id }: LiveRegionOptions, +) { + const element = ownerDocument.createElement("div"); + element.setAttribute(getLiveRegionPartDataAttr(id), ""); + element.setAttribute( + "style", + "position: absolute; top: -1px; width: 1px; height: 1px; overflow: hidden;", + ); + ownerDocument.body.appendChild(element); + + element.setAttribute("aria-live", type); + element.setAttribute("aria-atomic", String(atomic || false)); + element.setAttribute("role", role); + if (relevant) { + element.setAttribute("aria-relevant", relevant); + } + + return element; +} + +function buildSelector( + { type, relevant, role, atomic, id }: LiveRegionOptions, +) { + return `[${getLiveRegionPartDataAttr(id)}]${ + [ + ["aria-live", type], + ["aria-atomic", atomic], + ["aria-relevant", relevant], + ["role", role], + ] + .filter(([, val]) => !!val) + .map(([attr, val]) => `[${attr}=${val}]`) + .join("") + }`; +} + +function getLiveRegionPartDataAttr(id?: string) { + return "data-radix-announce-region" + (id ? `-${id}` : ""); +} + +const Root = Announce; + +export { + Announce, + // + Root, +}; +export type { AnnounceProps }; diff --git a/pkg/radix-ui-primitives/preact/announce/mod.ts b/pkg/radix-ui-primitives/preact/announce/mod.ts new file mode 100644 index 0000000..df6fbd0 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/announce/mod.ts @@ -0,0 +1,6 @@ +export { + Announce, + // + Root, +} from "./Announce.tsx"; +export type { AnnounceProps } from "./Announce.tsx"; diff --git a/pkg/radix-ui-primitives/preact/arrow/Arrow.tsx b/pkg/radix-ui-primitives/preact/arrow/Arrow.tsx new file mode 100644 index 0000000..d9fec43 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/arrow/Arrow.tsx @@ -0,0 +1,46 @@ +import * as React from "preact/compat"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Arrow + * -----------------------------------------------------------------------------------------------*/ + +const NAME = "Arrow"; + +type ArrowElement = React.ElementRef; +type PrimitiveSvgProps = Radix.ComponentPropsWithoutRef; +interface ArrowProps extends PrimitiveSvgProps {} + +const Arrow = React.forwardRef( + (props, forwardedRef) => { + const { children, width = 10, height = 5, ...arrowProps } = props; + return ( + + {/* We use their children if they're slotting to replace the whole svg */} + {props.asChild ? children : } + + ); + }, +); + +Arrow.displayName = NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +const Root = Arrow; + +export { + Arrow, + // + Root, +}; +export type { ArrowProps }; diff --git a/pkg/radix-ui-primitives/preact/arrow/mod.ts b/pkg/radix-ui-primitives/preact/arrow/mod.ts new file mode 100644 index 0000000..66c3c5c --- /dev/null +++ b/pkg/radix-ui-primitives/preact/arrow/mod.ts @@ -0,0 +1,6 @@ +export { + Arrow, + // + Root, +} from "./Arrow.tsx"; +export type { ArrowProps } from "./Arrow.tsx"; diff --git a/pkg/radix-ui-primitives/preact/aspect-ratio/AspectRatio.tsx b/pkg/radix-ui-primitives/preact/aspect-ratio/AspectRatio.tsx new file mode 100644 index 0000000..d9bfd0a --- /dev/null +++ b/pkg/radix-ui-primitives/preact/aspect-ratio/AspectRatio.tsx @@ -0,0 +1,64 @@ +import * as React from "preact/compat"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * AspectRatio + * -----------------------------------------------------------------------------------------------*/ + +const NAME = "AspectRatio"; + +type AspectRatioElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface AspectRatioProps extends PrimitiveDivProps { + ratio?: number; +} + +const AspectRatio = React.forwardRef< + AspectRatioElement, + AspectRatioProps +>( + (props, forwardedRef) => { + const { ratio = 1 / 1, style, ...aspectRatioProps } = props; + return ( +
+ +
+ ); + }, +); + +AspectRatio.displayName = NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +const Root = AspectRatio; + +export { + AspectRatio, + // + Root, +}; +export type { AspectRatioProps }; diff --git a/pkg/radix-ui-primitives/preact/aspect-ratio/mod.ts b/pkg/radix-ui-primitives/preact/aspect-ratio/mod.ts new file mode 100644 index 0000000..b5eefce --- /dev/null +++ b/pkg/radix-ui-primitives/preact/aspect-ratio/mod.ts @@ -0,0 +1,6 @@ +export { + AspectRatio, + // + Root, +} from "./AspectRatio.tsx"; +export type { AspectRatioProps } from "./AspectRatio.tsx"; diff --git a/pkg/radix-ui-primitives/preact/avatar/Avatar.tsx b/pkg/radix-ui-primitives/preact/avatar/Avatar.tsx new file mode 100644 index 0000000..12e8c30 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/avatar/Avatar.tsx @@ -0,0 +1,189 @@ +import * as React from "preact/compat"; +import { createContextScope } from "../context/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; +import { useLayoutEffect } from "../use-layout-effect/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Avatar + * -----------------------------------------------------------------------------------------------*/ + +const AVATAR_NAME = "Avatar"; + +type ScopedProps

= P & { __scopeAvatar?: Scope }; +const [createAvatarContext, createAvatarScope] = createContextScope( + AVATAR_NAME, +); + +type ImageLoadingStatus = "idle" | "loading" | "loaded" | "error"; + +type AvatarContextValue = { + imageLoadingStatus: ImageLoadingStatus; + onImageLoadingStatusChange(status: ImageLoadingStatus): void; +}; + +const [AvatarProvider, useAvatarContext] = createAvatarContext< + AvatarContextValue +>(AVATAR_NAME); + +type AvatarElement = React.ElementRef; +type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef; +interface AvatarProps extends PrimitiveSpanProps {} + +const Avatar = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeAvatar, ...avatarProps } = props; + const [imageLoadingStatus, setImageLoadingStatus] = React.useState< + ImageLoadingStatus + >("idle"); + return ( + + + + ); + }, +); + +Avatar.displayName = AVATAR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AvatarImage + * -----------------------------------------------------------------------------------------------*/ + +const IMAGE_NAME = "AvatarImage"; + +type AvatarImageElement = React.ElementRef; +type PrimitiveImageProps = Radix.ComponentPropsWithoutRef; +interface AvatarImageProps extends PrimitiveImageProps { + onLoadingStatusChange?: (status: ImageLoadingStatus) => void; +} + +const AvatarImage = React.forwardRef< + AvatarImageElement, + AvatarImageProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopeAvatar, + src, + onLoadingStatusChange = () => {}, + ...imageProps + } = props; + const context = useAvatarContext(IMAGE_NAME, __scopeAvatar); + const imageLoadingStatus = useImageLoadingStatus(src); + const handleLoadingStatusChange = useCallbackRef( + (status: ImageLoadingStatus) => { + onLoadingStatusChange(status); + context.onImageLoadingStatusChange(status); + }, + ); + + useLayoutEffect(() => { + if (imageLoadingStatus !== "idle") { + handleLoadingStatusChange(imageLoadingStatus); + } + }, [imageLoadingStatus, handleLoadingStatusChange]); + + return imageLoadingStatus === "loaded" + ? + : null; + }, +); + +AvatarImage.displayName = IMAGE_NAME; + +/* ------------------------------------------------------------------------------------------------- + * AvatarFallback + * -----------------------------------------------------------------------------------------------*/ + +const FALLBACK_NAME = "AvatarFallback"; + +type AvatarFallbackElement = React.ElementRef; +interface AvatarFallbackProps extends PrimitiveSpanProps { + delayMs?: number; +} + +const AvatarFallback = React.forwardRef< + AvatarFallbackElement, + AvatarFallbackProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeAvatar, delayMs, ...fallbackProps } = props; + const context = useAvatarContext(FALLBACK_NAME, __scopeAvatar); + const [canRender, setCanRender] = React.useState( + delayMs === undefined, + ); + + React.useEffect(() => { + if (delayMs !== undefined) { + const timerId = window.setTimeout(() => setCanRender(true), delayMs); + return () => window.clearTimeout(timerId); + } + }, [delayMs]); + + return canRender && context.imageLoadingStatus !== "loaded" + ? + : null; + }, +); + +AvatarFallback.displayName = FALLBACK_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +function useImageLoadingStatus(src?: string) { + const [loadingStatus, setLoadingStatus] = React.useState< + ImageLoadingStatus + >( + "idle", + ); + + useLayoutEffect(() => { + if (!src) { + setLoadingStatus("error"); + return; + } + + let isMounted = true; + const image = new window.Image(); + + const updateStatus = (status: ImageLoadingStatus) => () => { + if (!isMounted) return; + setLoadingStatus(status); + }; + + setLoadingStatus("loading"); + image.onload = updateStatus("loaded"); + image.onerror = updateStatus("error"); + image.src = src; + + return () => { + isMounted = false; + }; + }, [src]); + + return loadingStatus; +} +const Root = Avatar; +const Image = AvatarImage; +const Fallback = AvatarFallback; + +export { + // + Avatar, + AvatarFallback, + AvatarImage, + createAvatarScope, + Fallback, + Image, + // + Root, +}; +export type { AvatarFallbackProps, AvatarImageProps, AvatarProps }; diff --git a/pkg/radix-ui-primitives/preact/avatar/mod.ts b/pkg/radix-ui-primitives/preact/avatar/mod.ts new file mode 100644 index 0000000..be7fc4f --- /dev/null +++ b/pkg/radix-ui-primitives/preact/avatar/mod.ts @@ -0,0 +1,16 @@ +export { + // + Avatar, + AvatarFallback, + AvatarImage, + createAvatarScope, + Fallback, + Image, + // + Root, +} from "./Avatar.tsx"; +export type { + AvatarFallbackProps, + AvatarImageProps, + AvatarProps, +} from "./Avatar.tsx"; diff --git a/pkg/radix-ui-primitives/preact/checkbox/Checkbox.tsx b/pkg/radix-ui-primitives/preact/checkbox/Checkbox.tsx new file mode 100644 index 0000000..3643555 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/checkbox/Checkbox.tsx @@ -0,0 +1,264 @@ +import * as React from "preact/compat"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { usePrevious } from "../use-previous/mod.ts"; +import { useSize } from "../use-size/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Checkbox + * -----------------------------------------------------------------------------------------------*/ + +const CHECKBOX_NAME = "Checkbox"; + +type ScopedProps

= P & { __scopeCheckbox?: Scope }; +const [createCheckboxContext, createCheckboxScope] = createContextScope( + CHECKBOX_NAME, +); + +type CheckedState = boolean | "indeterminate"; + +type CheckboxContextValue = { + state: CheckedState; + disabled?: boolean; +}; + +const [CheckboxProvider, useCheckboxContext] = createCheckboxContext< + CheckboxContextValue +>(CHECKBOX_NAME); + +type CheckboxElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface CheckboxProps + extends Omit { + checked?: CheckedState; + defaultChecked?: CheckedState; + required?: boolean; + onCheckedChange?(checked: CheckedState): void; +} + +const Checkbox = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { + __scopeCheckbox, + name, + checked: checkedProp, + defaultChecked, + required, + disabled, + value = "on", + onCheckedChange, + ...checkboxProps + } = props; + const [button, setButton] = React.useState( + null, + ); + const composedRefs = useComposedRefs( + forwardedRef, + (node) => setButton(node), + ); + const hasConsumerStoppedPropagationRef = React.useRef(false); + // We set this to true by default so that events bubble to forms without JS (SSR) + const isFormControl = button ? Boolean(button.closest("form")) : true; + const [checked = false, setChecked] = useControllableState({ + prop: checkedProp, + defaultProp: defaultChecked, + onChange: onCheckedChange, + }); + const initialCheckedStateRef = React.useRef(checked); + React.useEffect(() => { + const form = button?.form; + if (form) { + const reset = () => setChecked(initialCheckedStateRef.current); + form.addEventListener("reset", reset); + return () => form.removeEventListener("reset", reset); + } + }, [button, setChecked]); + + return ( + + { + // According to WAI ARIA, Checkboxes don't activate on enter keypress + if (event.key === "Enter") event.preventDefault(); + })} + onClick={composeEventHandlers(props.onClick, (event) => { + setChecked(( + prevChecked, + ) => (isIndeterminate(prevChecked) ? true : !prevChecked)); + if (isFormControl) { + hasConsumerStoppedPropagationRef.current = event + .isPropagationStopped(); + // if checkbox is in a form, stop propagation from the button so that we only propagate + // one click event (from the input). We propagate changes from an input so that native + // form validation works and form events reflect checkbox updates. + if (!hasConsumerStoppedPropagationRef.current) { + event.stopPropagation(); + } + } + })} + /> + {isFormControl && ( + + )} + + ); + }, +); + +Checkbox.displayName = CHECKBOX_NAME; + +/* ------------------------------------------------------------------------------------------------- + * CheckboxIndicator + * -----------------------------------------------------------------------------------------------*/ + +const INDICATOR_NAME = "CheckboxIndicator"; + +type CheckboxIndicatorElement = React.ElementRef; +type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef; +interface CheckboxIndicatorProps extends PrimitiveSpanProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const CheckboxIndicator = React.forwardRef< + CheckboxIndicatorElement, + CheckboxIndicatorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeCheckbox, forceMount, ...indicatorProps } = props; + const context = useCheckboxContext(INDICATOR_NAME, __scopeCheckbox); + return ( + + + + ); + }, +); + +CheckboxIndicator.displayName = INDICATOR_NAME; + +/* ---------------------------------------------------------------------------------------------- */ + +type InputProps = Radix.ComponentPropsWithoutRef<"input">; +interface BubbleInputProps extends Omit { + checked: CheckedState; + control: HTMLElement | null; + bubbles: boolean; +} + +const BubbleInput = (props: BubbleInputProps) => { + const { control, checked, bubbles = true, ...inputProps } = props; + const ref = React.useRef(null); + const prevChecked = usePrevious(checked); + const controlSize = useSize(control); + + // Bubble checked change to parents (e.g form change event) + React.useEffect(() => { + const input = ref.current!; + const inputProto = window.HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor( + inputProto, + "checked", + ) as PropertyDescriptor; + const setChecked = descriptor.set; + + if (prevChecked !== checked && setChecked) { + const event = new Event("click", { bubbles }); + input.indeterminate = isIndeterminate(checked); + setChecked.call(input, isIndeterminate(checked) ? false : checked); + input.dispatchEvent(event); + } + }, [prevChecked, checked, bubbles]); + + return ( + + ); +}; + +function isIndeterminate(checked?: CheckedState): checked is "indeterminate" { + return checked === "indeterminate"; +} + +function getState(checked: CheckedState) { + return isIndeterminate(checked) + ? "indeterminate" + : checked + ? "checked" + : "unchecked"; +} + +const Root = Checkbox; +const Indicator = CheckboxIndicator; + +export { + // + Checkbox, + CheckboxIndicator, + createCheckboxScope, + Indicator, + // + Root, +}; +export type { CheckboxIndicatorProps, CheckboxProps }; diff --git a/pkg/radix-ui-primitives/preact/checkbox/mod.ts b/pkg/radix-ui-primitives/preact/checkbox/mod.ts new file mode 100644 index 0000000..d64ce0b --- /dev/null +++ b/pkg/radix-ui-primitives/preact/checkbox/mod.ts @@ -0,0 +1,10 @@ +export { + // + Checkbox, + CheckboxIndicator, + createCheckboxScope, + Indicator, + // + Root, +} from "./Checkbox.tsx"; +export type { CheckboxIndicatorProps, CheckboxProps } from "./Checkbox.tsx"; diff --git a/pkg/radix-ui-primitives/preact/collapsible/Collapsible.tsx b/pkg/radix-ui-primitives/preact/collapsible/Collapsible.tsx new file mode 100644 index 0000000..3d4ccf7 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/collapsible/Collapsible.tsx @@ -0,0 +1,282 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { useLayoutEffect } from "../use-layout-effect/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { useId } from "../id/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Collapsible + * -----------------------------------------------------------------------------------------------*/ + +const COLLAPSIBLE_NAME = "Collapsible"; + +type ScopedProps

= P & { __scopeCollapsible?: Scope }; +const [createCollapsibleContext, createCollapsibleScope] = createContextScope( + COLLAPSIBLE_NAME, +); + +type CollapsibleContextValue = { + contentId: string; + disabled?: boolean; + open: boolean; + onOpenToggle(): void; +}; + +const [CollapsibleProvider, useCollapsibleContext] = createCollapsibleContext< + CollapsibleContextValue +>(COLLAPSIBLE_NAME); + +type CollapsibleElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface CollapsibleProps extends PrimitiveDivProps { + defaultOpen?: boolean; + open?: boolean; + disabled?: boolean; + onOpenChange?(open: boolean): void; +} + +const Collapsible = React.forwardRef< + CollapsibleElement, + CollapsibleProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopeCollapsible, + open: openProp, + defaultOpen, + disabled, + onOpenChange, + ...collapsibleProps + } = props; + + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + return ( + setOpen((prevOpen) => !prevOpen), + [setOpen], + )} + > + + + ); + }, +); + +Collapsible.displayName = COLLAPSIBLE_NAME; + +/* ------------------------------------------------------------------------------------------------- + * CollapsibleTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "CollapsibleTrigger"; + +type CollapsibleTriggerElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface CollapsibleTriggerProps extends PrimitiveButtonProps {} + +const CollapsibleTrigger = React.forwardRef< + CollapsibleTriggerElement, + CollapsibleTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeCollapsible, ...triggerProps } = props; + const context = useCollapsibleContext(TRIGGER_NAME, __scopeCollapsible); + return ( + + ); + }, +); + +CollapsibleTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * CollapsibleContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "CollapsibleContent"; + +type CollapsibleContentElement = CollapsibleContentImplElement; +interface CollapsibleContentProps + extends Omit { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const CollapsibleContent = React.forwardRef< + CollapsibleContentElement, + CollapsibleContentProps +>( + (props: ScopedProps, forwardedRef) => { + const { forceMount, ...contentProps } = props; + const context = useCollapsibleContext( + CONTENT_NAME, + props.__scopeCollapsible, + ); + return ( + + {({ present }) => ( + + )} + + ); + }, +); + +CollapsibleContent.displayName = CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +type CollapsibleContentImplElement = React.ElementRef; +interface CollapsibleContentImplProps extends PrimitiveDivProps { + present: boolean; +} + +const CollapsibleContentImpl = React.forwardRef< + CollapsibleContentImplElement, + CollapsibleContentImplProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeCollapsible, present, children, ...contentProps } = props; + const context = useCollapsibleContext(CONTENT_NAME, __scopeCollapsible); + const [isPresent, setIsPresent] = React.useState(present); + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + const heightRef = React.useRef(0); + const height = heightRef.current; + const widthRef = React.useRef(0); + const width = widthRef.current; + // when opening we want it to immediately open to retrieve dimensions + // when closing we delay `present` to retrieve dimensions before closing + const isOpen = context.open || isPresent; + const isMountAnimationPreventedRef = React.useRef(isOpen); + const originalStylesRef = React.useRef>(); + + React.useEffect(() => { + const rAF = requestAnimationFrame( + () => (isMountAnimationPreventedRef.current = false), + ); + return () => cancelAnimationFrame(rAF); + }, []); + + useLayoutEffect(() => { + const node = ref.current; + if (node) { + originalStylesRef.current = originalStylesRef.current || { + transitionDuration: node.style.transitionDuration, + animationName: node.style.animationName, + }; + // block any animations/transitions so the element renders at its full dimensions + node.style.transitionDuration = "0s"; + node.style.animationName = "none"; + + // get width and height from full dimensions + const rect = node.getBoundingClientRect(); + heightRef.current = rect.height; + widthRef.current = rect.width; + + // kick off any animations/transitions that were originally set up if it isn't the initial mount + if (!isMountAnimationPreventedRef.current) { + node.style.transitionDuration = + originalStylesRef.current.transitionDuration; + node.style.animationName = originalStylesRef.current.animationName; + } + + setIsPresent(present); + } + /** + * depends on `context.open` because it will change to `false` + * when a close is triggered but `present` will be `false` on + * animation end (so when close finishes). This allows us to + * retrieve the dimensions *before* closing. + */ + }, [context.open, present]); + + return ( + + ); +}); + +/* -----------------------------------------------------------------------------------------------*/ + +function getState(open?: boolean) { + return open ? "open" : "closed"; +} + +const Root = Collapsible; +const Trigger = CollapsibleTrigger; +const Content = CollapsibleContent; + +export { + // + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + Content, + createCollapsibleScope, + // + Root, + Trigger, +}; +export type { + CollapsibleContentProps, + CollapsibleProps, + CollapsibleTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/collapsible/mod.ts b/pkg/radix-ui-primitives/preact/collapsible/mod.ts new file mode 100644 index 0000000..cd5b616 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/collapsible/mod.ts @@ -0,0 +1,16 @@ +export { + // + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + Content, + createCollapsibleScope, + // + Root, + Trigger, +} from "./Collapsible.tsx"; +export type { + CollapsibleContentProps, + CollapsibleProps, + CollapsibleTriggerProps, +} from "./Collapsible.tsx"; diff --git a/pkg/radix-ui-primitives/preact/collection/Collection.tsx b/pkg/radix-ui-primitives/preact/collection/Collection.tsx new file mode 100644 index 0000000..1c7cf35 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/collection/Collection.tsx @@ -0,0 +1,159 @@ +import * as React from "preact/compat"; +import { createContextScope } from "../context/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { Slot } from "../slot/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; + +type SlotProps = Radix.ComponentPropsWithoutRef; +type CollectionElement = HTMLElement; +interface CollectionProps extends SlotProps { + scope: any; +} + +// We have resorted to returning slots directly rather than exposing primitives that can then +// be slotted like ``. +// This is because we encountered issues with generic types that cannot be statically analysed +// due to creating them dynamically via createCollection. + +function createCollection( + name: string, +) { + /* ----------------------------------------------------------------------------------------------- + * CollectionProvider + * ---------------------------------------------------------------------------------------------*/ + + const PROVIDER_NAME = name + "CollectionProvider"; + const [createCollectionContext, createCollectionScope] = createContextScope( + PROVIDER_NAME, + ); + + type ContextValue = { + collectionRef: React.RefObject; + itemMap: Map< + React.RefObject, + { ref: React.RefObject } & ItemData + >; + }; + + const [CollectionProviderImpl, useCollectionContext] = + createCollectionContext( + PROVIDER_NAME, + { collectionRef: { current: null }, itemMap: new Map() }, + ); + + const CollectionProvider: React.FC< + { children?: React.ComponentChildren; scope: any } + > = (props) => { + const { scope, children } = props; + const ref = React.useRef(null); + const itemMap = + React.useRef(new Map()).current; + return ( + + {children} + + ); + }; + + CollectionProvider.displayName = PROVIDER_NAME; + + /* ----------------------------------------------------------------------------------------------- + * CollectionSlot + * ---------------------------------------------------------------------------------------------*/ + + const COLLECTION_SLOT_NAME = name + "CollectionSlot"; + + const CollectionSlot = React.forwardRef< + CollectionElement, + CollectionProps + >( + (props, forwardedRef) => { + const { scope, children } = props; + const context = useCollectionContext(COLLECTION_SLOT_NAME, scope); + const composedRefs = useComposedRefs(forwardedRef, context.collectionRef); + return {children}; + }, + ); + + CollectionSlot.displayName = COLLECTION_SLOT_NAME; + + /* ----------------------------------------------------------------------------------------------- + * CollectionItem + * ---------------------------------------------------------------------------------------------*/ + + const ITEM_SLOT_NAME = name + "CollectionItemSlot"; + const ITEM_DATA_ATTR = "data-radix-collection-item"; + + type CollectionItemSlotProps = ItemData & { + children: React.ComponentChildren; + scope: any; + }; + + const CollectionItemSlot = React.forwardRef< + ItemElement, + CollectionItemSlotProps + >( + (props, forwardedRef) => { + const { scope, children, ...itemData } = props; + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + const context = useCollectionContext(ITEM_SLOT_NAME, scope); + + React.useEffect(() => { + context.itemMap.set(ref, { ref, ...(itemData as unknown as ItemData) }); + return () => void context.itemMap.delete(ref); + }); + + return ( + + {children} + + ); + }, + ); + + CollectionItemSlot.displayName = ITEM_SLOT_NAME; + + /* ----------------------------------------------------------------------------------------------- + * useCollection + * ---------------------------------------------------------------------------------------------*/ + + function useCollection(scope: any) { + const context = useCollectionContext(name + "CollectionConsumer", scope); + + const getItems = React.useCallback(() => { + const collectionNode = context.collectionRef.current; + if (!collectionNode) return []; + const orderedNodes = Array.from( + collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`), + ); + const items = Array.from(context.itemMap.values()); + const orderedItems = items.sort( + (a, b) => + orderedNodes.indexOf(a.ref.current!) - + orderedNodes.indexOf(b.ref.current!), + ); + return orderedItems; + }, [context.collectionRef, context.itemMap]); + + return getItems; + } + + return [ + { + Provider: CollectionProvider, + Slot: CollectionSlot, + ItemSlot: CollectionItemSlot, + }, + useCollection, + createCollectionScope, + ] as const; +} + +export { createCollection }; +export type { CollectionProps }; diff --git a/pkg/radix-ui-primitives/preact/collection/mod.ts b/pkg/radix-ui-primitives/preact/collection/mod.ts new file mode 100644 index 0000000..ad1c7b7 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/collection/mod.ts @@ -0,0 +1,2 @@ +export { createCollection } from "./Collection.tsx"; +export type { CollectionProps } from "./Collection.tsx"; diff --git a/pkg/radix-ui-primitives/preact/compose-refs/composeRefs.tsx b/pkg/radix-ui-primitives/preact/compose-refs/composeRefs.tsx new file mode 100644 index 0000000..ae99699 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/compose-refs/composeRefs.tsx @@ -0,0 +1,34 @@ +import * as React from "preact/compat"; + +type PossibleRef = React.Ref | undefined; + +/** + * Set a given ref to a given value + * This utility takes care of different types of refs: callback refs and RefObject(s) + */ +function setRef(ref: PossibleRef, value: T) { + if (typeof ref === "function") { + ref(value); + } else if (ref !== null && ref !== undefined) { + (ref as React.MutableRefObject).current = value; + } +} + +/** + * A utility to compose multiple refs together + * Accepts callback refs and RefObject(s) + */ +function composeRefs(...refs: PossibleRef[]) { + return (node: T) => refs.forEach((ref) => setRef(ref, node)); +} + +/** + * A custom hook that composes multiple refs + * Accepts callback refs and RefObject(s) + */ +function useComposedRefs(...refs: PossibleRef[]) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return React.useCallback(composeRefs(...refs), refs); +} + +export { composeRefs, useComposedRefs }; diff --git a/pkg/radix-ui-primitives/preact/compose-refs/mod.ts b/pkg/radix-ui-primitives/preact/compose-refs/mod.ts new file mode 100644 index 0000000..cd0fa29 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/compose-refs/mod.ts @@ -0,0 +1 @@ +export { composeRefs, useComposedRefs } from "./composeRefs.tsx"; diff --git a/pkg/radix-ui-primitives/preact/context-menu/ContextMenu.tsx b/pkg/radix-ui-primitives/preact/context-menu/ContextMenu.tsx new file mode 100644 index 0000000..e915a34 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/context-menu/ContextMenu.tsx @@ -0,0 +1,733 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import * as MenuPrimitive from "../menu/mod.ts"; +import { createMenuScope } from "../menu/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +type Direction = "ltr" | "rtl"; +type Point = { x: number; y: number }; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenu + * -----------------------------------------------------------------------------------------------*/ + +const CONTEXT_MENU_NAME = "ContextMenu"; + +type ScopedProps

= P & { __scopeContextMenu?: Scope }; +const [createContextMenuContext, createContextMenuScope] = createContextScope( + CONTEXT_MENU_NAME, + [ + createMenuScope, + ], +); +const useMenuScope = createMenuScope(); + +type ContextMenuContextValue = { + open: boolean; + onOpenChange(open: boolean): void; + modal: boolean; +}; + +const [ContextMenuProvider, useContextMenuContext] = createContextMenuContext< + ContextMenuContextValue +>(CONTEXT_MENU_NAME); + +interface ContextMenuProps { + children?: React.ComponentChildren; + onOpenChange?(open: boolean): void; + dir?: Direction; + modal?: boolean; +} + +const ContextMenu: React.FC = ( + props: ScopedProps, +) => { + const { __scopeContextMenu, children, onOpenChange, dir, modal = true } = + props; + const [open, setOpen] = React.useState(false); + const menuScope = useMenuScope(__scopeContextMenu); + const handleOpenChangeProp = useCallbackRef(onOpenChange); + + const handleOpenChange = React.useCallback( + (open: boolean) => { + setOpen(open); + handleOpenChangeProp(open); + }, + [handleOpenChangeProp], + ); + + return ( + + + {children} + + + ); +}; + +ContextMenu.displayName = CONTEXT_MENU_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "ContextMenuTrigger"; + +type ContextMenuTriggerElement = React.ElementRef; +type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef; +interface ContextMenuTriggerProps extends PrimitiveSpanProps { + disabled?: boolean; +} + +const ContextMenuTrigger = React.forwardRef< + ContextMenuTriggerElement, + ContextMenuTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, disabled = false, ...triggerProps } = props; + const context = useContextMenuContext(TRIGGER_NAME, __scopeContextMenu); + const menuScope = useMenuScope(__scopeContextMenu); + const pointRef = React.useRef({ x: 0, y: 0 }); + const virtualRef = React.useRef({ + getBoundingClientRect: () => + DOMRect.fromRect({ width: 0, height: 0, ...pointRef.current }), + }); + const longPressTimerRef = React.useRef(0); + const clearLongPress = React.useCallback( + () => window.clearTimeout(longPressTimerRef.current), + [], + ); + const handleOpen = (event: React.MouseEvent | React.PointerEvent) => { + pointRef.current = { x: event.clientX, y: event.clientY }; + context.onOpenChange(true); + }; + + React.useEffect(() => clearLongPress, [clearLongPress]); + React.useEffect(() => void (disabled && clearLongPress()), [ + disabled, + clearLongPress, + ]); + + return ( + <> + + { + // clearing the long press here because some platforms already support + // long press to trigger a `contextmenu` event + clearLongPress(); + handleOpen(event); + event.preventDefault(); + })} + onPointerDown={disabled ? props.onPointerDown : composeEventHandlers( + props.onPointerDown, + whenTouchOrPen((event) => { + // clear the long press here in case there's multiple touch points + clearLongPress(); + longPressTimerRef.current = window.setTimeout( + () => handleOpen(event), + 700, + ); + }), + )} + onPointerMove={disabled ? props.onPointerMove : composeEventHandlers( + props.onPointerMove, + whenTouchOrPen(clearLongPress), + )} + onPointerCancel={disabled + ? props.onPointerCancel + : composeEventHandlers( + props.onPointerCancel, + whenTouchOrPen(clearLongPress), + )} + onPointerUp={disabled ? props.onPointerUp : composeEventHandlers( + props.onPointerUp, + whenTouchOrPen(clearLongPress), + )} + /> + + ); + }, +); + +ContextMenuTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "ContextMenuPortal"; + +type MenuPortalProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Portal +>; +interface ContextMenuPortalProps extends MenuPortalProps {} + +const ContextMenuPortal: React.FC = ( + props: ScopedProps, +) => { + const { __scopeContextMenu, ...portalProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ; +}; + +ContextMenuPortal.displayName = PORTAL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "ContextMenuContent"; + +type ContextMenuContentElement = React.ElementRef; +type MenuContentProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Content +>; +interface ContextMenuContentProps + extends + Omit {} + +const ContextMenuContent = React.forwardRef< + ContextMenuContentElement, + ContextMenuContentProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...contentProps } = props; + const context = useContextMenuContext(CONTENT_NAME, __scopeContextMenu); + const menuScope = useMenuScope(__scopeContextMenu); + const hasInteractedOutsideRef = React.useRef(false); + + return ( + { + props.onCloseAutoFocus?.(event); + + if (!event.defaultPrevented && hasInteractedOutsideRef.current) { + event.preventDefault(); + } + + hasInteractedOutsideRef.current = false; + }} + onInteractOutside={(event) => { + props.onInteractOutside?.(event); + + if ( + !event.defaultPrevented && !context.modal + ) hasInteractedOutsideRef.current = true; + }} + style={{ + ...props.style, + // re-namespace exposed content custom properties + ...{ + "--radix-context-menu-content-transform-origin": + "var(--radix-popper-transform-origin)", + "--radix-context-menu-content-available-width": + "var(--radix-popper-available-width)", + "--radix-context-menu-content-available-height": + "var(--radix-popper-available-height)", + "--radix-context-menu-trigger-width": + "var(--radix-popper-anchor-width)", + "--radix-context-menu-trigger-height": + "var(--radix-popper-anchor-height)", + }, + }} + /> + ); + }, +); + +ContextMenuContent.displayName = CONTENT_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuGroup + * -----------------------------------------------------------------------------------------------*/ + +const GROUP_NAME = "ContextMenuGroup"; + +type ContextMenuGroupElement = React.ElementRef; +type MenuGroupProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Group +>; +interface ContextMenuGroupProps extends MenuGroupProps {} + +const ContextMenuGroup = React.forwardRef< + ContextMenuGroupElement, + ContextMenuGroupProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...groupProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); + }, +); + +ContextMenuGroup.displayName = GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuLabel + * -----------------------------------------------------------------------------------------------*/ + +const LABEL_NAME = "ContextMenuLabel"; + +type ContextMenuLabelElement = React.ElementRef; +type MenuLabelProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Label +>; +interface ContextMenuLabelProps extends MenuLabelProps {} + +const ContextMenuLabel = React.forwardRef< + ContextMenuLabelElement, + ContextMenuLabelProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...labelProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); + }, +); + +ContextMenuLabel.displayName = LABEL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuItem + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_NAME = "ContextMenuItem"; + +type ContextMenuItemElement = React.ElementRef; +type MenuItemProps = Radix.ComponentPropsWithoutRef; +interface ContextMenuItemProps extends MenuItemProps {} + +const ContextMenuItem = React.forwardRef< + ContextMenuItemElement, + ContextMenuItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...itemProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); + }, +); + +ContextMenuItem.displayName = ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuCheckboxItem + * -----------------------------------------------------------------------------------------------*/ + +const CHECKBOX_ITEM_NAME = "ContextMenuCheckboxItem"; + +type ContextMenuCheckboxItemElement = React.ElementRef< + typeof MenuPrimitive.CheckboxItem +>; +type MenuCheckboxItemProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.CheckboxItem +>; +interface ContextMenuCheckboxItemProps extends MenuCheckboxItemProps {} + +const ContextMenuCheckboxItem = React.forwardRef< + ContextMenuCheckboxItemElement, + ContextMenuCheckboxItemProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...checkboxItemProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); +}); + +ContextMenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuRadioGroup + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_GROUP_NAME = "ContextMenuRadioGroup"; + +type ContextMenuRadioGroupElement = React.ElementRef< + typeof MenuPrimitive.RadioGroup +>; +type MenuRadioGroupProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.RadioGroup +>; +interface ContextMenuRadioGroupProps extends MenuRadioGroupProps {} + +const ContextMenuRadioGroup = React.forwardRef< + ContextMenuRadioGroupElement, + ContextMenuRadioGroupProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...radioGroupProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); +}); + +ContextMenuRadioGroup.displayName = RADIO_GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuRadioItem + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_ITEM_NAME = "ContextMenuRadioItem"; + +type ContextMenuRadioItemElement = React.ElementRef< + typeof MenuPrimitive.RadioItem +>; +type MenuRadioItemProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.RadioItem +>; +interface ContextMenuRadioItemProps extends MenuRadioItemProps {} + +const ContextMenuRadioItem = React.forwardRef< + ContextMenuRadioItemElement, + ContextMenuRadioItemProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...radioItemProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); +}); + +ContextMenuRadioItem.displayName = RADIO_ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuItemIndicator + * -----------------------------------------------------------------------------------------------*/ + +const INDICATOR_NAME = "ContextMenuItemIndicator"; + +type ContextMenuItemIndicatorElement = React.ElementRef< + typeof MenuPrimitive.ItemIndicator +>; +type MenuItemIndicatorProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.ItemIndicator +>; +interface ContextMenuItemIndicatorProps extends MenuItemIndicatorProps {} + +const ContextMenuItemIndicator = React.forwardRef< + ContextMenuItemIndicatorElement, + ContextMenuItemIndicatorProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...itemIndicatorProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); +}); + +ContextMenuItemIndicator.displayName = INDICATOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuSeparator + * -----------------------------------------------------------------------------------------------*/ + +const SEPARATOR_NAME = "ContextMenuSeparator"; + +type ContextMenuSeparatorElement = React.ElementRef< + typeof MenuPrimitive.Separator +>; +type MenuSeparatorProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Separator +>; +interface ContextMenuSeparatorProps extends MenuSeparatorProps {} + +const ContextMenuSeparator = React.forwardRef< + ContextMenuSeparatorElement, + ContextMenuSeparatorProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...separatorProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); +}); + +ContextMenuSeparator.displayName = SEPARATOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuArrow + * -----------------------------------------------------------------------------------------------*/ + +const ARROW_NAME = "ContextMenuArrow"; + +type ContextMenuArrowElement = React.ElementRef; +type MenuArrowProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Arrow +>; +interface ContextMenuArrowProps extends MenuArrowProps {} + +const ContextMenuArrow = React.forwardRef< + ContextMenuArrowElement, + ContextMenuArrowProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...arrowProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); + }, +); + +ContextMenuArrow.displayName = ARROW_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuSub + * -----------------------------------------------------------------------------------------------*/ + +const SUB_NAME = "ContextMenuSub"; + +interface ContextMenuSubProps { + children?: React.ComponentChildren; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?(open: boolean): void; +} + +const ContextMenuSub: React.FC = ( + props: ScopedProps, +) => { + const { + __scopeContextMenu, + children, + onOpenChange, + open: openProp, + defaultOpen, + } = props; + const menuScope = useMenuScope(__scopeContextMenu); + const [open, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + return ( + + {children} + + ); +}; + +ContextMenuSub.displayName = SUB_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuSubTrigger + * -----------------------------------------------------------------------------------------------*/ + +const SUB_TRIGGER_NAME = "ContextMenuSubTrigger"; + +type ContextMenuSubTriggerElement = React.ElementRef< + typeof MenuPrimitive.SubTrigger +>; +type MenuSubTriggerProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.SubTrigger +>; +interface ContextMenuSubTriggerProps extends MenuSubTriggerProps {} + +const ContextMenuSubTrigger = React.forwardRef< + ContextMenuSubTriggerElement, + ContextMenuSubTriggerProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...triggerItemProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + return ( + + ); +}); + +ContextMenuSubTrigger.displayName = SUB_TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ContextMenuSubContent + * -----------------------------------------------------------------------------------------------*/ + +const SUB_CONTENT_NAME = "ContextMenuSubContent"; + +type ContextMenuSubContentElement = React.ElementRef< + typeof MenuPrimitive.Content +>; +type MenuSubContentProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.SubContent +>; +interface ContextMenuSubContentProps extends MenuSubContentProps {} + +const ContextMenuSubContent = React.forwardRef< + ContextMenuSubContentElement, + ContextMenuSubContentProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeContextMenu, ...subContentProps } = props; + const menuScope = useMenuScope(__scopeContextMenu); + + return ( + + ); +}); + +ContextMenuSubContent.displayName = SUB_CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +function whenTouchOrPen( + handler: React.PointerEventHandler, +): React.PointerEventHandler { + return ( + event, + ) => (event.pointerType !== "mouse" ? handler(event) : undefined); +} + +const Root = ContextMenu; +const Trigger = ContextMenuTrigger; +const Portal = ContextMenuPortal; +const Content = ContextMenuContent; +const Group = ContextMenuGroup; +const Label = ContextMenuLabel; +const Item = ContextMenuItem; +const CheckboxItem = ContextMenuCheckboxItem; +const RadioGroup = ContextMenuRadioGroup; +const RadioItem = ContextMenuRadioItem; +const ItemIndicator = ContextMenuItemIndicator; +const Separator = ContextMenuSeparator; +const Arrow = ContextMenuArrow; +const Sub = ContextMenuSub; +const SubTrigger = ContextMenuSubTrigger; +const SubContent = ContextMenuSubContent; + +export { + Arrow, + CheckboxItem, + Content, + // + ContextMenu, + ContextMenuArrow, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuItemIndicator, + ContextMenuLabel, + ContextMenuPortal, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, + createContextMenuScope, + Group, + Item, + ItemIndicator, + Label, + Portal, + RadioGroup, + RadioItem, + // + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, +}; +export type { + ContextMenuArrowProps, + ContextMenuCheckboxItemProps, + ContextMenuContentProps, + ContextMenuGroupProps, + ContextMenuItemIndicatorProps, + ContextMenuItemProps, + ContextMenuLabelProps, + ContextMenuPortalProps, + ContextMenuProps, + ContextMenuRadioGroupProps, + ContextMenuRadioItemProps, + ContextMenuSeparatorProps, + ContextMenuSubContentProps, + ContextMenuSubProps, + ContextMenuSubTriggerProps, + ContextMenuTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/context-menu/mod.ts b/pkg/radix-ui-primitives/preact/context-menu/mod.ts new file mode 100644 index 0000000..70d203c --- /dev/null +++ b/pkg/radix-ui-primitives/preact/context-menu/mod.ts @@ -0,0 +1,55 @@ +export { + Arrow, + CheckboxItem, + Content, + // + ContextMenu, + ContextMenuArrow, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuItemIndicator, + ContextMenuLabel, + ContextMenuPortal, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, + createContextMenuScope, + Group, + Item, + ItemIndicator, + Label, + Portal, + RadioGroup, + RadioItem, + // + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, +} from "./ContextMenu.tsx"; +export type { + ContextMenuArrowProps, + ContextMenuCheckboxItemProps, + ContextMenuContentProps, + ContextMenuGroupProps, + ContextMenuItemIndicatorProps, + ContextMenuItemProps, + ContextMenuLabelProps, + ContextMenuPortalProps, + ContextMenuProps, + ContextMenuRadioGroupProps, + ContextMenuRadioItemProps, + ContextMenuSeparatorProps, + ContextMenuSubContentProps, + ContextMenuSubProps, + ContextMenuSubTriggerProps, + ContextMenuTriggerProps, +} from "./ContextMenu.tsx"; diff --git a/pkg/radix-ui-primitives/preact/context/createContext.tsx b/pkg/radix-ui-primitives/preact/context/createContext.tsx new file mode 100644 index 0000000..3e339e4 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/context/createContext.tsx @@ -0,0 +1,171 @@ +import * as React from "preact/compat"; + +function createContext( + rootComponentName: string, + defaultContext?: ContextValueType, +) { + const Context = React.createContext( + defaultContext, + ); + + function Provider( + props: ContextValueType & { children: React.ComponentChildren }, + ) { + const { children, ...context } = props; + // Only re-memoize when prop values change + // eslint-disable-next-line react-hooks/exhaustive-deps + const value = React.useMemo( + () => context, + Object.values(context), + ) as ContextValueType; + return {children}; + } + + function useContext(consumerName: string) { + const context = React.useContext(Context); + if (context) return context; + if (defaultContext !== undefined) return defaultContext; + // if a defaultContext wasn't specified, it's a required context. + throw new Error( + `\`${consumerName}\` must be used within \`${rootComponentName}\``, + ); + } + + Provider.displayName = rootComponentName + "Provider"; + return [Provider, useContext] as const; +} + +/* ------------------------------------------------------------------------------------------------- + * createContextScope + * -----------------------------------------------------------------------------------------------*/ + +type Scope = { [scopeName: string]: React.Context[] } | undefined; +type ScopeHook = (scope: Scope) => { [__scopeProp: string]: Scope }; +interface CreateScope { + scopeName: string; + (): ScopeHook; +} + +function createContextScope( + scopeName: string, + createContextScopeDeps: CreateScope[] = [], +) { + let defaultContexts: any[] = []; + + /* ----------------------------------------------------------------------------------------------- + * createContext + * ---------------------------------------------------------------------------------------------*/ + + function createContext( + rootComponentName: string, + defaultContext?: ContextValueType, + ) { + const BaseContext = React.createContext( + defaultContext, + ); + const index = defaultContexts.length; + defaultContexts = [...defaultContexts, defaultContext]; + + function Provider( + props: ContextValueType & { + scope: Scope; + children: React.ComponentChildren; + }, + ) { + const { scope, children, ...context } = props; + const Context = scope?.[scopeName][index] || BaseContext; + // Only re-memoize when prop values change + // eslint-disable-next-line react-hooks/exhaustive-deps + const value = React.useMemo( + () => context, + Object.values(context), + ) as ContextValueType; + return {children}; + } + + function useContext( + consumerName: string, + scope: Scope, + ) { + const Context = scope?.[scopeName][index] || BaseContext; + const context = React.useContext(Context); + if (context) return context; + if (defaultContext !== undefined) return defaultContext; + // if a defaultContext wasn't specified, it's a required context. + throw new Error( + `\`${consumerName}\` must be used within \`${rootComponentName}\``, + ); + } + + Provider.displayName = rootComponentName + "Provider"; + return [Provider, useContext] as const; + } + + /* ----------------------------------------------------------------------------------------------- + * createScope + * ---------------------------------------------------------------------------------------------*/ + + const createScope: CreateScope = () => { + const scopeContexts = defaultContexts.map((defaultContext) => { + return React.createContext(defaultContext); + }); + return function useScope(scope: Scope) { + const contexts = scope?.[scopeName] || scopeContexts; + return React.useMemo( + () => ({ + [`__scope${scopeName}`]: { ...scope, [scopeName]: contexts }, + }), + [scope, contexts], + ); + }; + }; + + createScope.scopeName = scopeName; + return [ + createContext, + composeContextScopes(createScope, ...createContextScopeDeps), + ] as const; +} + +/* ------------------------------------------------------------------------------------------------- + * composeContextScopes + * -----------------------------------------------------------------------------------------------*/ + +function composeContextScopes(...scopes: CreateScope[]) { + const baseScope = scopes[0]; + if (scopes.length === 1) return baseScope; + + const createScope: CreateScope = () => { + const scopeHooks = scopes.map((createScope) => ({ + useScope: createScope(), + scopeName: createScope.scopeName, + })); + + return function useComposedScopes(overrideScopes) { + const nextScopes = scopeHooks.reduce( + (nextScopes, { useScope, scopeName }) => { + // We are calling a hook inside a callback which React warns against to avoid inconsistent + // renders, however, scoping doesn't have render side effects so we ignore the rule. + // eslint-disable-next-line react-hooks/rules-of-hooks + const scopeProps = useScope(overrideScopes); + const currentScope = scopeProps[`__scope${scopeName}`]; + return { ...nextScopes, ...currentScope }; + }, + {}, + ); + + return React.useMemo( + () => ({ [`__scope${baseScope.scopeName}`]: nextScopes }), + [nextScopes], + ); + }; + }; + + createScope.scopeName = baseScope.scopeName; + return createScope; +} + +/* -----------------------------------------------------------------------------------------------*/ + +export { createContext, createContextScope }; +export type { CreateScope, Scope }; diff --git a/pkg/radix-ui-primitives/preact/context/mod.ts b/pkg/radix-ui-primitives/preact/context/mod.ts new file mode 100644 index 0000000..e850b00 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/context/mod.ts @@ -0,0 +1,2 @@ +export { createContext, createContextScope } from "./createContext.tsx"; +export type { CreateScope, Scope } from "./createContext.tsx"; diff --git a/pkg/radix-ui-primitives/preact/dialog/Dialog.tsx b/pkg/radix-ui-primitives/preact/dialog/Dialog.tsx new file mode 100644 index 0000000..b89e691 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/dialog/Dialog.tsx @@ -0,0 +1,688 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContext, createContextScope } from "../context/mod.ts"; +import { useId } from "../id/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { DismissableLayer } from "../dismissable-layer/mod.ts"; +import { FocusScope } from "../focus-scope/mod.ts"; +import { Portal as PortalPrimitive } from "../portal/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { useFocusGuards } from "../focus-guards/mod.ts"; +import { RemoveScroll } from "react-remove-scroll"; +import { hideOthers } from "aria-hidden"; +import { Slot } from "../slot/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Dialog + * -----------------------------------------------------------------------------------------------*/ + +const DIALOG_NAME = "Dialog"; + +type ScopedProps

= P & { __scopeDialog?: Scope }; +const [createDialogContext, createDialogScope] = createContextScope( + DIALOG_NAME, +); + +type DialogContextValue = { + triggerRef: React.RefObject; + contentRef: React.RefObject; + contentId: string; + titleId: string; + descriptionId: string; + open: boolean; + onOpenChange(open: boolean): void; + onOpenToggle(): void; + modal: boolean; +}; + +const [DialogProvider, useDialogContext] = createDialogContext< + DialogContextValue +>(DIALOG_NAME); + +interface DialogProps { + children?: React.ComponentChildren; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?(open: boolean): void; + modal?: boolean; +} + +const Dialog: React.FC = ( + props: ScopedProps, +) => { + const { + __scopeDialog, + children, + open: openProp, + defaultOpen, + onOpenChange, + modal = true, + } = props; + const triggerRef = React.useRef(null); + const contentRef = React.useRef(null); + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + return ( + setOpen((prevOpen) => !prevOpen), + [ + setOpen, + ], + )} + modal={modal} + > + {children} + + ); +}; + +Dialog.displayName = DIALOG_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DialogTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "DialogTrigger"; + +type DialogTriggerElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface DialogTriggerProps extends PrimitiveButtonProps {} + +const DialogTrigger = React.forwardRef< + DialogTriggerElement, + DialogTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDialog, ...triggerProps } = props; + const context = useDialogContext(TRIGGER_NAME, __scopeDialog); + const composedTriggerRef = useComposedRefs( + forwardedRef, + context.triggerRef, + ); + return ( + + ); + }, +); + +DialogTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DialogPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "DialogPortal"; + +type PortalContextValue = { forceMount?: true }; +const [PortalProvider, usePortalContext] = createDialogContext< + PortalContextValue +>(PORTAL_NAME, { + forceMount: undefined, +}); + +type PortalProps = Radix.ComponentPropsWithoutRef; +interface DialogPortalProps { + children?: React.ComponentChildren; + /** + * Specify a container element to portal the content into. + */ + container?: PortalProps["container"]; + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const DialogPortal: React.FC = ( + props: ScopedProps, +) => { + const { __scopeDialog, forceMount, children, container } = props; + const context = useDialogContext(PORTAL_NAME, __scopeDialog); + return ( + + {React.Children.map( + children, + (child) => ( + + + {child} + + + ), + )} + + ); +}; + +DialogPortal.displayName = PORTAL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DialogOverlay + * -----------------------------------------------------------------------------------------------*/ + +const OVERLAY_NAME = "DialogOverlay"; + +type DialogOverlayElement = DialogOverlayImplElement; +interface DialogOverlayProps extends DialogOverlayImplProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const DialogOverlay = React.forwardRef< + DialogOverlayElement, + DialogOverlayProps +>( + (props: ScopedProps, forwardedRef) => { + const portalContext = usePortalContext(OVERLAY_NAME, props.__scopeDialog); + const { forceMount = portalContext.forceMount, ...overlayProps } = props; + const context = useDialogContext(OVERLAY_NAME, props.__scopeDialog); + return context.modal + ? ( + + + + ) + : null; + }, +); + +DialogOverlay.displayName = OVERLAY_NAME; + +type DialogOverlayImplElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface DialogOverlayImplProps extends PrimitiveDivProps {} + +const DialogOverlayImpl = React.forwardRef< + DialogOverlayImplElement, + DialogOverlayImplProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDialog, ...overlayProps } = props; + const context = useDialogContext(OVERLAY_NAME, __scopeDialog); + return ( + // Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll` + // ie. when `Overlay` and `Content` are siblings + + + + ); + }, +); + +/* ------------------------------------------------------------------------------------------------- + * DialogContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "DialogContent"; + +type DialogContentElement = DialogContentTypeElement; +interface DialogContentProps extends DialogContentTypeProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const DialogContent = React.forwardRef< + DialogContentElement, + DialogContentProps +>( + (props: ScopedProps, forwardedRef) => { + const portalContext = usePortalContext(CONTENT_NAME, props.__scopeDialog); + const { forceMount = portalContext.forceMount, ...contentProps } = props; + const context = useDialogContext(CONTENT_NAME, props.__scopeDialog); + return ( + + {context.modal + ? + : } + + ); + }, +); + +DialogContent.displayName = CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +type DialogContentTypeElement = DialogContentImplElement; +interface DialogContentTypeProps + extends + Omit {} + +const DialogContentModal = React.forwardRef< + DialogContentTypeElement, + DialogContentTypeProps +>( + (props: ScopedProps, forwardedRef) => { + const context = useDialogContext(CONTENT_NAME, props.__scopeDialog); + const contentRef = React.useRef(null); + const composedRefs = useComposedRefs( + forwardedRef, + context.contentRef, + contentRef, + ); + + // aria-hide everything except the content (better supported equivalent to setting aria-modal) + React.useEffect(() => { + const content = contentRef.current; + if (content) return hideOthers(content); + }, []); + + return ( + { + event.preventDefault(); + context.triggerRef.current?.focus(); + }, + )} + onPointerDownOutside={composeEventHandlers( + props.onPointerDownOutside, + (event) => { + const originalEvent = event.detail.originalEvent; + const ctrlLeftClick = originalEvent.button === 0 && + originalEvent.ctrlKey === true; + const isRightClick = originalEvent.button === 2 || ctrlLeftClick; + + // If the event is a right-click, we shouldn't close because + // it is effectively as if we right-clicked the `Overlay`. + if (isRightClick) event.preventDefault(); + }, + )} + // When focus is trapped, a `focusout` event may still happen. + // We make sure we don't trigger our `onDismiss` in such case. + onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => + event.preventDefault())} + /> + ); + }, +); + +/* -----------------------------------------------------------------------------------------------*/ + +const DialogContentNonModal = React.forwardRef< + DialogContentTypeElement, + DialogContentTypeProps +>( + (props: ScopedProps, forwardedRef) => { + const context = useDialogContext(CONTENT_NAME, props.__scopeDialog); + const hasInteractedOutsideRef = React.useRef(false); + const hasPointerDownOutsideRef = React.useRef(false); + + return ( + { + props.onCloseAutoFocus?.(event); + + if (!event.defaultPrevented) { + if (!hasInteractedOutsideRef.current) { + context.triggerRef.current + ?.focus(); + } + // Always prevent auto focus because we either focus manually or want user agent focus + event.preventDefault(); + } + + hasInteractedOutsideRef.current = false; + hasPointerDownOutsideRef.current = false; + }} + onInteractOutside={(event) => { + props.onInteractOutside?.(event); + + if (!event.defaultPrevented) { + hasInteractedOutsideRef.current = true; + if (event.detail.originalEvent.type === "pointerdown") { + hasPointerDownOutsideRef.current = true; + } + } + + // Prevent dismissing when clicking the trigger. + // As the trigger is already setup to close, without doing so would + // cause it to close and immediately open. + const target = event.target as HTMLElement; + const targetIsTrigger = context.triggerRef.current?.contains(target); + if (targetIsTrigger) event.preventDefault(); + + // On Safari if the trigger is inside a container with tabIndex={0}, when clicked + // we will get the pointer down outside event on the trigger, but then a subsequent + // focus outside event on the container, we ignore any focus outside event when we've + // already had a pointer down outside event. + if ( + event.detail.originalEvent.type === "focusin" && + hasPointerDownOutsideRef.current + ) { + event.preventDefault(); + } + }} + /> + ); + }, +); + +/* -----------------------------------------------------------------------------------------------*/ + +type DialogContentImplElement = React.ElementRef; +type DismissableLayerProps = Radix.ComponentPropsWithoutRef< + typeof DismissableLayer +>; +type FocusScopeProps = Radix.ComponentPropsWithoutRef; +interface DialogContentImplProps + extends Omit { + /** + * When `true`, focus cannot escape the `Content` via keyboard, + * pointer, or a programmatic focus. + * @defaultValue false + */ + trapFocus?: FocusScopeProps["trapped"]; + + /** + * Event handler called when auto-focusing on open. + * Can be prevented. + */ + onOpenAutoFocus?: FocusScopeProps["onMountAutoFocus"]; + + /** + * Event handler called when auto-focusing on close. + * Can be prevented. + */ + onCloseAutoFocus?: FocusScopeProps["onUnmountAutoFocus"]; +} + +const DialogContentImpl = React.forwardRef< + DialogContentImplElement, + DialogContentImplProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopeDialog, + trapFocus, + onOpenAutoFocus, + onCloseAutoFocus, + ...contentProps + } = props; + const context = useDialogContext(CONTENT_NAME, __scopeDialog); + const contentRef = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, contentRef); + + // Make sure the whole tree has focus guards as our `Dialog` will be + // the last element in the DOM (beacuse of the `Portal`) + useFocusGuards(); + + return ( + <> + + context.onOpenChange(false)} + /> + + {process.env.NODE_ENV !== "production" && ( + <> + + + + )} + + ); + }, +); + +/* ------------------------------------------------------------------------------------------------- + * DialogTitle + * -----------------------------------------------------------------------------------------------*/ + +const TITLE_NAME = "DialogTitle"; + +type DialogTitleElement = React.ElementRef; +type PrimitiveHeading2Props = Radix.ComponentPropsWithoutRef< + typeof Primitive.h2 +>; +interface DialogTitleProps extends PrimitiveHeading2Props {} + +const DialogTitle = React.forwardRef< + DialogTitleElement, + DialogTitleProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDialog, ...titleProps } = props; + const context = useDialogContext(TITLE_NAME, __scopeDialog); + return ( + + ); + }, +); + +DialogTitle.displayName = TITLE_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DialogDescription + * -----------------------------------------------------------------------------------------------*/ + +const DESCRIPTION_NAME = "DialogDescription"; + +type DialogDescriptionElement = React.ElementRef; +type PrimitiveParagraphProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.p +>; +interface DialogDescriptionProps extends PrimitiveParagraphProps {} + +const DialogDescription = React.forwardRef< + DialogDescriptionElement, + DialogDescriptionProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDialog, ...descriptionProps } = props; + const context = useDialogContext(DESCRIPTION_NAME, __scopeDialog); + return ( + + ); + }, +); + +DialogDescription.displayName = DESCRIPTION_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DialogClose + * -----------------------------------------------------------------------------------------------*/ + +const CLOSE_NAME = "DialogClose"; + +type DialogCloseElement = React.ElementRef; +interface DialogCloseProps extends PrimitiveButtonProps {} + +const DialogClose = React.forwardRef< + DialogCloseElement, + DialogCloseProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDialog, ...closeProps } = props; + const context = useDialogContext(CLOSE_NAME, __scopeDialog); + return ( + + context.onOpenChange(false))} + /> + ); + }, +); + +DialogClose.displayName = CLOSE_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +function getState(open: boolean) { + return open ? "open" : "closed"; +} + +const TITLE_WARNING_NAME = "DialogTitleWarning"; + +const [WarningProvider, useWarningContext] = createContext(TITLE_WARNING_NAME, { + contentName: CONTENT_NAME, + titleName: TITLE_NAME, + docsSlug: "dialog", +}); + +type TitleWarningProps = { titleId?: string }; + +const TitleWarning: React.FC = ({ titleId }) => { + const titleWarningContext = useWarningContext(TITLE_WARNING_NAME); + + const MESSAGE = + `\`${titleWarningContext.contentName}\` requires a \`${titleWarningContext.titleName}\` for the component to be accessible for screen reader users. + +If you want to hide the \`${titleWarningContext.titleName}\`, you can wrap it with our VisuallyHidden component. + +For more information, see https://radix-ui.com/primitives/docs/components/${titleWarningContext.docsSlug}`; + + React.useEffect(() => { + if (titleId) { + const hasTitle = document.getElementById(titleId); + if (!hasTitle) throw new Error(MESSAGE); + } + }, [MESSAGE, titleId]); + + return null; +}; + +const DESCRIPTION_WARNING_NAME = "DialogDescriptionWarning"; + +type DescriptionWarningProps = { + contentRef: React.RefObject; + descriptionId?: string; +}; + +const DescriptionWarning: React.FC = ( + { contentRef, descriptionId }, +) => { + const descriptionWarningContext = useWarningContext(DESCRIPTION_WARNING_NAME); + const MESSAGE = + `Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${descriptionWarningContext.contentName}}.`; + + React.useEffect(() => { + const describedById = contentRef.current?.getAttribute("aria-describedby"); + // if we have an id and the user hasn't set aria-describedby={undefined} + if (descriptionId && describedById) { + const hasDescription = document.getElementById(descriptionId); + if (!hasDescription) console.warn(MESSAGE); + } + }, [MESSAGE, contentRef, descriptionId]); + + return null; +}; + +const Root = Dialog; +const Trigger = DialogTrigger; +const Portal = DialogPortal; +const Overlay = DialogOverlay; +const Content = DialogContent; +const Title = DialogTitle; +const Description = DialogDescription; +const Close = DialogClose; + +export { + Close, + Content, + createDialogScope, + Description, + // + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, + Overlay, + Portal, + // + Root, + Title, + Trigger, + // + WarningProvider, +}; +export type { + DialogCloseProps, + DialogContentProps, + DialogDescriptionProps, + DialogOverlayProps, + DialogPortalProps, + DialogProps, + DialogTitleProps, + DialogTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/dialog/mod.ts b/pkg/radix-ui-primitives/preact/dialog/mod.ts new file mode 100644 index 0000000..85e3747 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/dialog/mod.ts @@ -0,0 +1,33 @@ +export { + Close, + Content, + createDialogScope, + Description, + // + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, + Overlay, + Portal, + // + Root, + Title, + Trigger, + // + WarningProvider, +} from "./Dialog.tsx"; +export type { + DialogCloseProps, + DialogContentProps, + DialogDescriptionProps, + DialogOverlayProps, + DialogPortalProps, + DialogProps, + DialogTitleProps, + DialogTriggerProps, +} from "./Dialog.tsx"; diff --git a/pkg/radix-ui-primitives/preact/direction/Direction.tsx b/pkg/radix-ui-primitives/preact/direction/Direction.tsx new file mode 100644 index 0000000..825bb9d --- /dev/null +++ b/pkg/radix-ui-primitives/preact/direction/Direction.tsx @@ -0,0 +1,38 @@ +import * as React from "preact/compat"; + +type Direction = "ltr" | "rtl"; +const DirectionContext = React.createContext(undefined); + +/* ------------------------------------------------------------------------------------------------- + * Direction + * -----------------------------------------------------------------------------------------------*/ + +interface DirectionProviderProps { + children?: React.ComponentChildren; + dir: Direction; +} +const DirectionProvider: React.FC = (props) => { + const { dir, children } = props; + return ( + + {children} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +function useDirection(localDir?: Direction) { + const globalDir = React.useContext(DirectionContext); + return localDir || globalDir || "ltr"; +} + +const Provider = DirectionProvider; + +export { + // + DirectionProvider, + // + Provider, + useDirection, +}; diff --git a/pkg/radix-ui-primitives/preact/direction/mod.ts b/pkg/radix-ui-primitives/preact/direction/mod.ts new file mode 100644 index 0000000..3e072ef --- /dev/null +++ b/pkg/radix-ui-primitives/preact/direction/mod.ts @@ -0,0 +1,7 @@ +export { + // + DirectionProvider, + // + Provider, + useDirection, +} from "./Direction.tsx"; diff --git a/pkg/radix-ui-primitives/preact/dismissable-layer/DismissableLayer.tsx b/pkg/radix-ui-primitives/preact/dismissable-layer/DismissableLayer.tsx new file mode 100644 index 0000000..709ff44 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/dismissable-layer/DismissableLayer.tsx @@ -0,0 +1,403 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { dispatchDiscreteCustomEvent, Primitive } from "../primitive/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; +import { useEscapeKeydown } from "../use-escape-keydown/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * DismissableLayer + * -----------------------------------------------------------------------------------------------*/ + +const DISMISSABLE_LAYER_NAME = "DismissableLayer"; +const CONTEXT_UPDATE = "dismissableLayer.update"; +const POINTER_DOWN_OUTSIDE = "dismissableLayer.pointerDownOutside"; +const FOCUS_OUTSIDE = "dismissableLayer.focusOutside"; + +let originalBodyPointerEvents: string; + +const DismissableLayerContext = React.createContext({ + layers: new Set(), + layersWithOutsidePointerEventsDisabled: new Set(), + branches: new Set(), +}); + +type DismissableLayerElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface DismissableLayerProps extends PrimitiveDivProps { + /** + * When `true`, hover/focus/click interactions will be disabled on elements outside + * the `DismissableLayer`. Users will need to click twice on outside elements to + * interact with them: once to close the `DismissableLayer`, and again to trigger the element. + */ + disableOutsidePointerEvents?: boolean; + /** + * Event handler called when the escape key is down. + * Can be prevented. + */ + onEscapeKeyDown?: (event: KeyboardEvent) => void; + /** + * Event handler called when the a `pointerdown` event happens outside of the `DismissableLayer`. + * Can be prevented. + */ + onPointerDownOutside?: (event: PointerDownOutsideEvent) => void; + /** + * Event handler called when the focus moves outside of the `DismissableLayer`. + * Can be prevented. + */ + onFocusOutside?: (event: FocusOutsideEvent) => void; + /** + * Event handler called when an interaction happens outside the `DismissableLayer`. + * Specifically, when a `pointerdown` event happens outside or focus moves outside of it. + * Can be prevented. + */ + onInteractOutside?: ( + event: PointerDownOutsideEvent | FocusOutsideEvent, + ) => void; + /** + * Handler called when the `DismissableLayer` should be dismissed + */ + onDismiss?: () => void; +} + +const DismissableLayer = React.forwardRef< + DismissableLayerElement, + DismissableLayerProps +>( + (props, forwardedRef) => { + const { + disableOutsidePointerEvents = false, + onEscapeKeyDown, + onPointerDownOutside, + onFocusOutside, + onInteractOutside, + onDismiss, + ...layerProps + } = props; + const context = React.useContext(DismissableLayerContext); + const [node, setNode] = React.useState( + null, + ); + const ownerDocument = node?.ownerDocument ?? globalThis?.document; + const [, force] = React.useState({}); + const composedRefs = useComposedRefs(forwardedRef, (node) => setNode(node)); + const layers = Array.from(context.layers); + const [highestLayerWithOutsidePointerEventsDisabled] = [ + ...context.layersWithOutsidePointerEventsDisabled, + ].slice(-1); // prettier-ignore + const highestLayerWithOutsidePointerEventsDisabledIndex = layers.indexOf( + highestLayerWithOutsidePointerEventsDisabled, + ); // prettier-ignore + const index = node ? layers.indexOf(node) : -1; + const isBodyPointerEventsDisabled = + context.layersWithOutsidePointerEventsDisabled.size > 0; + const isPointerEventsEnabled = + index >= highestLayerWithOutsidePointerEventsDisabledIndex; + + const pointerDownOutside = usePointerDownOutside((event) => { + const target = event.target as HTMLElement; + const isPointerDownOnBranch = [...context.branches].some((branch) => + branch.contains(target) + ); + if (!isPointerEventsEnabled || isPointerDownOnBranch) return; + onPointerDownOutside?.(event); + onInteractOutside?.(event); + if (!event.defaultPrevented) onDismiss?.(); + }, ownerDocument); + + const focusOutside = useFocusOutside((event) => { + const target = event.target as HTMLElement; + const isFocusInBranch = [...context.branches].some((branch) => + branch.contains(target) + ); + if (isFocusInBranch) return; + onFocusOutside?.(event); + onInteractOutside?.(event); + if (!event.defaultPrevented) onDismiss?.(); + }, ownerDocument); + + useEscapeKeydown((event) => { + const isHighestLayer = index === context.layers.size - 1; + if (!isHighestLayer) return; + onEscapeKeyDown?.(event); + if (!event.defaultPrevented && onDismiss) { + event.preventDefault(); + onDismiss(); + } + }, ownerDocument); + + React.useEffect(() => { + if (!node) return; + if (disableOutsidePointerEvents) { + if (context.layersWithOutsidePointerEventsDisabled.size === 0) { + originalBodyPointerEvents = ownerDocument.body.style.pointerEvents; + ownerDocument.body.style.pointerEvents = "none"; + } + context.layersWithOutsidePointerEventsDisabled.add(node); + } + context.layers.add(node); + dispatchUpdate(); + return () => { + if ( + disableOutsidePointerEvents && + context.layersWithOutsidePointerEventsDisabled.size === 1 + ) { + ownerDocument.body.style.pointerEvents = originalBodyPointerEvents; + } + }; + }, [node, ownerDocument, disableOutsidePointerEvents, context]); + + /** + * We purposefully prevent combining this effect with the `disableOutsidePointerEvents` effect + * because a change to `disableOutsidePointerEvents` would remove this layer from the stack + * and add it to the end again so the layering order wouldn't be _creation order_. + * We only want them to be removed from context stacks when unmounted. + */ + React.useEffect(() => { + return () => { + if (!node) return; + context.layers.delete(node); + context.layersWithOutsidePointerEventsDisabled.delete(node); + dispatchUpdate(); + }; + }, [node, context]); + + React.useEffect(() => { + const handleUpdate = () => force({}); + document.addEventListener(CONTEXT_UPDATE, handleUpdate); + return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate); + }, []); + + return ( + + ); + }, +); + +DismissableLayer.displayName = DISMISSABLE_LAYER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DismissableLayerBranch + * -----------------------------------------------------------------------------------------------*/ + +const BRANCH_NAME = "DismissableLayerBranch"; + +type DismissableLayerBranchElement = React.ElementRef; +interface DismissableLayerBranchProps extends PrimitiveDivProps {} + +const DismissableLayerBranch = React.forwardRef< + DismissableLayerBranchElement, + DismissableLayerBranchProps +>((props, forwardedRef) => { + const context = React.useContext(DismissableLayerContext); + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + + React.useEffect(() => { + const node = ref.current; + if (node) { + context.branches.add(node); + return () => { + context.branches.delete(node); + }; + } + }, [context.branches]); + + return ; +}); + +DismissableLayerBranch.displayName = BRANCH_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>; +type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>; + +/** + * Listens for `pointerdown` outside a react subtree. We use `pointerdown` rather than `pointerup` + * to mimic layer dismissing behaviour present in OS. + * Returns props to pass to the node we want to check for outside events. + */ +function usePointerDownOutside( + onPointerDownOutside?: (event: PointerDownOutsideEvent) => void, + ownerDocument: Document = globalThis?.document, +) { + const handlePointerDownOutside = useCallbackRef( + onPointerDownOutside, + ) as EventListener; + const isPointerInsideReactTreeRef = React.useRef(false); + const handleClickRef = React.useRef(() => {}); + + React.useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (event.target && !isPointerInsideReactTreeRef.current) { + const eventDetail = { originalEvent: event }; + + function handleAndDispatchPointerDownOutsideEvent() { + handleAndDispatchCustomEvent( + POINTER_DOWN_OUTSIDE, + handlePointerDownOutside, + eventDetail, + { discrete: true }, + ); + } + + /** + * On touch devices, we need to wait for a click event because browsers implement + * a ~350ms delay between the time the user stops touching the display and when the + * browser executres events. We need to ensure we don't reactivate pointer-events within + * this timeframe otherwise the browser may execute events that should have been prevented. + * + * Additionally, this also lets us deal automatically with cancellations when a click event + * isn't raised because the page was considered scrolled/drag-scrolled, long-pressed, etc. + * + * This is why we also continuously remove the previous listener, because we cannot be + * certain that it was raised, and therefore cleaned-up. + */ + if (event.pointerType === "touch") { + ownerDocument.removeEventListener("click", handleClickRef.current); + handleClickRef.current = handleAndDispatchPointerDownOutsideEvent; + ownerDocument.addEventListener("click", handleClickRef.current, { + once: true, + }); + } else { + handleAndDispatchPointerDownOutsideEvent(); + } + } else { + // We need to remove the event listener in case the outside click has been canceled. + // See: https://github.com/radix-ui/primitives/issues/2171 + ownerDocument.removeEventListener("click", handleClickRef.current); + } + isPointerInsideReactTreeRef.current = false; + }; + /** + * if this hook executes in a component that mounts via a `pointerdown` event, the event + * would bubble up to the document and trigger a `pointerDownOutside` event. We avoid + * this by delaying the event listener registration on the document. + * This is not React specific, but rather how the DOM works, ie: + * ``` + * button.addEventListener('pointerdown', () => { + * console.log('I will log'); + * document.addEventListener('pointerdown', () => { + * console.log('I will also log'); + * }) + * }); + */ + const timerId = window.setTimeout(() => { + ownerDocument.addEventListener("pointerdown", handlePointerDown); + }, 0); + return () => { + window.clearTimeout(timerId); + ownerDocument.removeEventListener("pointerdown", handlePointerDown); + ownerDocument.removeEventListener("click", handleClickRef.current); + }; + }, [ownerDocument, handlePointerDownOutside]); + + return { + // ensures we check React component tree (not just DOM tree) + onPointerDownCapture: () => (isPointerInsideReactTreeRef.current = true), + }; +} + +/** + * Listens for when focus happens outside a react subtree. + * Returns props to pass to the root (node) of the subtree we want to check. + */ +function useFocusOutside( + onFocusOutside?: (event: FocusOutsideEvent) => void, + ownerDocument: Document = globalThis?.document, +) { + const handleFocusOutside = useCallbackRef(onFocusOutside) as EventListener; + const isFocusInsideReactTreeRef = React.useRef(false); + + React.useEffect(() => { + const handleFocus = (event: FocusEvent) => { + if (event.target && !isFocusInsideReactTreeRef.current) { + const eventDetail = { originalEvent: event }; + handleAndDispatchCustomEvent( + FOCUS_OUTSIDE, + handleFocusOutside, + eventDetail, + { + discrete: false, + }, + ); + } + }; + ownerDocument.addEventListener("focusin", handleFocus); + return () => ownerDocument.removeEventListener("focusin", handleFocus); + }, [ownerDocument, handleFocusOutside]); + + return { + onFocusCapture: () => (isFocusInsideReactTreeRef.current = true), + onBlurCapture: () => (isFocusInsideReactTreeRef.current = false), + }; +} + +function dispatchUpdate() { + const event = new CustomEvent(CONTEXT_UPDATE); + document.dispatchEvent(event); +} + +function handleAndDispatchCustomEvent< + E extends CustomEvent, + OriginalEvent extends Event, +>( + name: string, + handler: ((event: E) => void) | undefined, + detail: + & { originalEvent: OriginalEvent } + & (E extends CustomEvent ? D : never), + { discrete }: { discrete: boolean }, +) { + const target = detail.originalEvent.target; + const event = new CustomEvent(name, { + bubbles: false, + cancelable: true, + detail, + }); + if (handler) { + target.addEventListener(name, handler as EventListener, { once: true }); + } + + if (discrete) { + dispatchDiscreteCustomEvent(target, event); + } else { + target.dispatchEvent(event); + } +} + +const Root = DismissableLayer; +const Branch = DismissableLayerBranch; + +export { + Branch, + DismissableLayer, + DismissableLayerBranch, + // + Root, +}; +export type { DismissableLayerProps }; diff --git a/pkg/radix-ui-primitives/preact/dismissable-layer/mod.ts b/pkg/radix-ui-primitives/preact/dismissable-layer/mod.ts new file mode 100644 index 0000000..1c16990 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/dismissable-layer/mod.ts @@ -0,0 +1,8 @@ +export { + Branch, + DismissableLayer, + DismissableLayerBranch, + // + Root, +} from "./DismissableLayer.tsx"; +export type { DismissableLayerProps } from "./DismissableLayer.tsx"; diff --git a/pkg/radix-ui-primitives/preact/dropdown-menu/DropdownMenu.tsx b/pkg/radix-ui-primitives/preact/dropdown-menu/DropdownMenu.tsx new file mode 100644 index 0000000..4233d97 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/dropdown-menu/DropdownMenu.tsx @@ -0,0 +1,712 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { composeRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import * as MenuPrimitive from "../menu/mod.ts"; +import { createMenuScope } from "../menu/mod.ts"; +import { useId } from "../id/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +type Direction = "ltr" | "rtl"; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenu + * -----------------------------------------------------------------------------------------------*/ + +const DROPDOWN_MENU_NAME = "DropdownMenu"; + +type ScopedProps

= P & { __scopeDropdownMenu?: Scope }; +const [createDropdownMenuContext, createDropdownMenuScope] = createContextScope( + DROPDOWN_MENU_NAME, + [createMenuScope], +); +const useMenuScope = createMenuScope(); + +type DropdownMenuContextValue = { + triggerId: string; + triggerRef: React.RefObject; + contentId: string; + open: boolean; + onOpenChange(open: boolean): void; + onOpenToggle(): void; + modal: boolean; +}; + +const [DropdownMenuProvider, useDropdownMenuContext] = + createDropdownMenuContext(DROPDOWN_MENU_NAME); + +interface DropdownMenuProps { + children?: React.ComponentChildren; + dir?: Direction; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?(open: boolean): void; + modal?: boolean; +} + +const DropdownMenu: React.FC = ( + props: ScopedProps, +) => { + const { + __scopeDropdownMenu, + children, + dir, + open: openProp, + defaultOpen, + onOpenChange, + modal = true, + } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + const triggerRef = React.useRef(null); + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + return ( + setOpen((prevOpen) => !prevOpen), + [ + setOpen, + ], + )} + modal={modal} + > + + {children} + + + ); +}; + +DropdownMenu.displayName = DROPDOWN_MENU_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "DropdownMenuTrigger"; + +type DropdownMenuTriggerElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface DropdownMenuTriggerProps extends PrimitiveButtonProps {} + +const DropdownMenuTrigger = React.forwardRef< + DropdownMenuTriggerElement, + DropdownMenuTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, disabled = false, ...triggerProps } = props; + const context = useDropdownMenuContext(TRIGGER_NAME, __scopeDropdownMenu); + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + { + // only call handler if it's the left button (mousedown gets triggered by all mouse buttons) + // but not when the control key is pressed (avoiding MacOS right click) + if (!disabled && event.button === 0 && event.ctrlKey === false) { + context.onOpenToggle(); + // prevent trigger focusing when opening + // this allows the content to be given focus without competition + if (!context.open) event.preventDefault(); + } + })} + onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + if (disabled) return; + if (["Enter", " "].includes(event.key)) context.onOpenToggle(); + if (event.key === "ArrowDown") context.onOpenChange(true); + // prevent keydown from scrolling window / first focused item to execute + // that keydown (inadvertently closing the menu) + if (["Enter", " ", "ArrowDown"].includes(event.key)) { + event.preventDefault(); + } + })} + /> + + ); + }, +); + +DropdownMenuTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "DropdownMenuPortal"; + +type MenuPortalProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Portal +>; +interface DropdownMenuPortalProps extends MenuPortalProps {} + +const DropdownMenuPortal: React.FC = ( + props: ScopedProps, +) => { + const { __scopeDropdownMenu, ...portalProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ; +}; + +DropdownMenuPortal.displayName = PORTAL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "DropdownMenuContent"; + +type DropdownMenuContentElement = React.ElementRef< + typeof MenuPrimitive.Content +>; +type MenuContentProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Content +>; +interface DropdownMenuContentProps + extends Omit {} + +const DropdownMenuContent = React.forwardRef< + DropdownMenuContentElement, + DropdownMenuContentProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...contentProps } = props; + const context = useDropdownMenuContext(CONTENT_NAME, __scopeDropdownMenu); + const menuScope = useMenuScope(__scopeDropdownMenu); + const hasInteractedOutsideRef = React.useRef(false); + + return ( + { + if (!hasInteractedOutsideRef.current) { + context.triggerRef.current + ?.focus(); + } + hasInteractedOutsideRef.current = false; + // Always prevent auto focus because we either focus manually or want user agent focus + event.preventDefault(); + }, + )} + onInteractOutside={composeEventHandlers( + props.onInteractOutside, + (event) => { + const originalEvent = event.detail.originalEvent as PointerEvent; + const ctrlLeftClick = originalEvent.button === 0 && + originalEvent.ctrlKey === true; + const isRightClick = originalEvent.button === 2 || ctrlLeftClick; + if (!context.modal || isRightClick) { + hasInteractedOutsideRef + .current = true; + } + }, + )} + style={{ + ...props.style, + // re-namespace exposed content custom properties + ...{ + "--radix-dropdown-menu-content-transform-origin": + "var(--radix-popper-transform-origin)", + "--radix-dropdown-menu-content-available-width": + "var(--radix-popper-available-width)", + "--radix-dropdown-menu-content-available-height": + "var(--radix-popper-available-height)", + "--radix-dropdown-menu-trigger-width": + "var(--radix-popper-anchor-width)", + "--radix-dropdown-menu-trigger-height": + "var(--radix-popper-anchor-height)", + }, + }} + /> + ); + }, +); + +DropdownMenuContent.displayName = CONTENT_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuGroup + * -----------------------------------------------------------------------------------------------*/ + +const GROUP_NAME = "DropdownMenuGroup"; + +type DropdownMenuGroupElement = React.ElementRef; +type MenuGroupProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Group +>; +interface DropdownMenuGroupProps extends MenuGroupProps {} + +const DropdownMenuGroup = React.forwardRef< + DropdownMenuGroupElement, + DropdownMenuGroupProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...groupProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); + }, +); + +DropdownMenuGroup.displayName = GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuLabel + * -----------------------------------------------------------------------------------------------*/ + +const LABEL_NAME = "DropdownMenuLabel"; + +type DropdownMenuLabelElement = React.ElementRef; +type MenuLabelProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Label +>; +interface DropdownMenuLabelProps extends MenuLabelProps {} + +const DropdownMenuLabel = React.forwardRef< + DropdownMenuLabelElement, + DropdownMenuLabelProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...labelProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); + }, +); + +DropdownMenuLabel.displayName = LABEL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuItem + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_NAME = "DropdownMenuItem"; + +type DropdownMenuItemElement = React.ElementRef; +type MenuItemProps = Radix.ComponentPropsWithoutRef; +interface DropdownMenuItemProps extends MenuItemProps {} + +const DropdownMenuItem = React.forwardRef< + DropdownMenuItemElement, + DropdownMenuItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...itemProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); + }, +); + +DropdownMenuItem.displayName = ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuCheckboxItem + * -----------------------------------------------------------------------------------------------*/ + +const CHECKBOX_ITEM_NAME = "DropdownMenuCheckboxItem"; + +type DropdownMenuCheckboxItemElement = React.ElementRef< + typeof MenuPrimitive.CheckboxItem +>; +type MenuCheckboxItemProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.CheckboxItem +>; +interface DropdownMenuCheckboxItemProps extends MenuCheckboxItemProps {} + +const DropdownMenuCheckboxItem = React.forwardRef< + DropdownMenuCheckboxItemElement, + DropdownMenuCheckboxItemProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...checkboxItemProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); +}); + +DropdownMenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuRadioGroup + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_GROUP_NAME = "DropdownMenuRadioGroup"; + +type DropdownMenuRadioGroupElement = React.ElementRef< + typeof MenuPrimitive.RadioGroup +>; +type MenuRadioGroupProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.RadioGroup +>; +interface DropdownMenuRadioGroupProps extends MenuRadioGroupProps {} + +const DropdownMenuRadioGroup = React.forwardRef< + DropdownMenuRadioGroupElement, + DropdownMenuRadioGroupProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...radioGroupProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); +}); + +DropdownMenuRadioGroup.displayName = RADIO_GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuRadioItem + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_ITEM_NAME = "DropdownMenuRadioItem"; + +type DropdownMenuRadioItemElement = React.ElementRef< + typeof MenuPrimitive.RadioItem +>; +type MenuRadioItemProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.RadioItem +>; +interface DropdownMenuRadioItemProps extends MenuRadioItemProps {} + +const DropdownMenuRadioItem = React.forwardRef< + DropdownMenuRadioItemElement, + DropdownMenuRadioItemProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...radioItemProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); +}); + +DropdownMenuRadioItem.displayName = RADIO_ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuItemIndicator + * -----------------------------------------------------------------------------------------------*/ + +const INDICATOR_NAME = "DropdownMenuItemIndicator"; + +type DropdownMenuItemIndicatorElement = React.ElementRef< + typeof MenuPrimitive.ItemIndicator +>; +type MenuItemIndicatorProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.ItemIndicator +>; +interface DropdownMenuItemIndicatorProps extends MenuItemIndicatorProps {} + +const DropdownMenuItemIndicator = React.forwardRef< + DropdownMenuItemIndicatorElement, + DropdownMenuItemIndicatorProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...itemIndicatorProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); +}); + +DropdownMenuItemIndicator.displayName = INDICATOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuSeparator + * -----------------------------------------------------------------------------------------------*/ + +const SEPARATOR_NAME = "DropdownMenuSeparator"; + +type DropdownMenuSeparatorElement = React.ElementRef< + typeof MenuPrimitive.Separator +>; +type MenuSeparatorProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Separator +>; +interface DropdownMenuSeparatorProps extends MenuSeparatorProps {} + +const DropdownMenuSeparator = React.forwardRef< + DropdownMenuSeparatorElement, + DropdownMenuSeparatorProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...separatorProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); +}); + +DropdownMenuSeparator.displayName = SEPARATOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuArrow + * -----------------------------------------------------------------------------------------------*/ + +const ARROW_NAME = "DropdownMenuArrow"; + +type DropdownMenuArrowElement = React.ElementRef; +type MenuArrowProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Arrow +>; +interface DropdownMenuArrowProps extends MenuArrowProps {} + +const DropdownMenuArrow = React.forwardRef< + DropdownMenuArrowElement, + DropdownMenuArrowProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...arrowProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); + }, +); + +DropdownMenuArrow.displayName = ARROW_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuSub + * -----------------------------------------------------------------------------------------------*/ + +interface DropdownMenuSubProps { + children?: React.ComponentChildren; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?(open: boolean): void; +} + +const DropdownMenuSub: React.FC = ( + props: ScopedProps, +) => { + const { + __scopeDropdownMenu, + children, + open: openProp, + onOpenChange, + defaultOpen, + } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + return ( + + {children} + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuSubTrigger + * -----------------------------------------------------------------------------------------------*/ + +const SUB_TRIGGER_NAME = "DropdownMenuSubTrigger"; + +type DropdownMenuSubTriggerElement = React.ElementRef< + typeof MenuPrimitive.SubTrigger +>; +type MenuSubTriggerProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.SubTrigger +>; +interface DropdownMenuSubTriggerProps extends MenuSubTriggerProps {} + +const DropdownMenuSubTrigger = React.forwardRef< + DropdownMenuSubTriggerElement, + DropdownMenuSubTriggerProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...subTriggerProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + return ( + + ); +}); + +DropdownMenuSubTrigger.displayName = SUB_TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * DropdownMenuSubContent + * -----------------------------------------------------------------------------------------------*/ + +const SUB_CONTENT_NAME = "DropdownMenuSubContent"; + +type DropdownMenuSubContentElement = React.ElementRef< + typeof MenuPrimitive.Content +>; +type MenuSubContentProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.SubContent +>; +interface DropdownMenuSubContentProps extends MenuSubContentProps {} + +const DropdownMenuSubContent = React.forwardRef< + DropdownMenuSubContentElement, + DropdownMenuSubContentProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeDropdownMenu, ...subContentProps } = props; + const menuScope = useMenuScope(__scopeDropdownMenu); + + return ( + + ); +}); + +DropdownMenuSubContent.displayName = SUB_CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +const Root = DropdownMenu; +const Trigger = DropdownMenuTrigger; +const Portal = DropdownMenuPortal; +const Content = DropdownMenuContent; +const Group = DropdownMenuGroup; +const Label = DropdownMenuLabel; +const Item = DropdownMenuItem; +const CheckboxItem = DropdownMenuCheckboxItem; +const RadioGroup = DropdownMenuRadioGroup; +const RadioItem = DropdownMenuRadioItem; +const ItemIndicator = DropdownMenuItemIndicator; +const Separator = DropdownMenuSeparator; +const Arrow = DropdownMenuArrow; +const Sub = DropdownMenuSub; +const SubTrigger = DropdownMenuSubTrigger; +const SubContent = DropdownMenuSubContent; + +export { + Arrow, + CheckboxItem, + Content, + createDropdownMenuScope, + // + DropdownMenu, + DropdownMenuArrow, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuItemIndicator, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Group, + Item, + ItemIndicator, + Label, + Portal, + RadioGroup, + RadioItem, + // + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, +}; +export type { + DropdownMenuArrowProps, + DropdownMenuCheckboxItemProps, + DropdownMenuContentProps, + DropdownMenuGroupProps, + DropdownMenuItemIndicatorProps, + DropdownMenuItemProps, + DropdownMenuLabelProps, + DropdownMenuPortalProps, + DropdownMenuProps, + DropdownMenuRadioGroupProps, + DropdownMenuRadioItemProps, + DropdownMenuSeparatorProps, + DropdownMenuSubContentProps, + DropdownMenuSubProps, + DropdownMenuSubTriggerProps, + DropdownMenuTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/dropdown-menu/mod.ts b/pkg/radix-ui-primitives/preact/dropdown-menu/mod.ts new file mode 100644 index 0000000..b89a240 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/dropdown-menu/mod.ts @@ -0,0 +1,55 @@ +export { + Arrow, + CheckboxItem, + Content, + createDropdownMenuScope, + // + DropdownMenu, + DropdownMenuArrow, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuItemIndicator, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Group, + Item, + ItemIndicator, + Label, + Portal, + RadioGroup, + RadioItem, + // + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, +} from "./DropdownMenu.tsx"; +export type { + DropdownMenuArrowProps, + DropdownMenuCheckboxItemProps, + DropdownMenuContentProps, + DropdownMenuGroupProps, + DropdownMenuItemIndicatorProps, + DropdownMenuItemProps, + DropdownMenuLabelProps, + DropdownMenuPortalProps, + DropdownMenuProps, + DropdownMenuRadioGroupProps, + DropdownMenuRadioItemProps, + DropdownMenuSeparatorProps, + DropdownMenuSubContentProps, + DropdownMenuSubProps, + DropdownMenuSubTriggerProps, + DropdownMenuTriggerProps, +} from "./DropdownMenu.tsx"; diff --git a/pkg/radix-ui-primitives/preact/focus-guards/FocusGuards.tsx b/pkg/radix-ui-primitives/preact/focus-guards/FocusGuards.tsx new file mode 100644 index 0000000..1f202cf --- /dev/null +++ b/pkg/radix-ui-primitives/preact/focus-guards/FocusGuards.tsx @@ -0,0 +1,56 @@ +import * as React from "preact/compat"; + +/** Number of components which have requested interest to have focus guards */ +let count = 0; + +function FocusGuards(props: any) { + useFocusGuards(); + return props.children; +} + +/** + * Injects a pair of focus guards at the edges of the whole DOM tree + * to ensure `focusin` & `focusout` events can be caught consistently. + */ +function useFocusGuards() { + React.useEffect(() => { + const edgeGuards = document.querySelectorAll("[data-radix-focus-guard]"); + document.body.insertAdjacentElement( + "afterbegin", + edgeGuards[0] ?? createFocusGuard(), + ); + document.body.insertAdjacentElement( + "beforeend", + edgeGuards[1] ?? createFocusGuard(), + ); + count++; + + return () => { + if (count === 1) { + document.querySelectorAll("[data-radix-focus-guard]").forEach((node) => + node.remove() + ); + } + count--; + }; + }, []); +} + +function createFocusGuard() { + const element = document.createElement("span"); + element.setAttribute("data-radix-focus-guard", ""); + element.tabIndex = 0; + element.style.cssText = + "outline: none; opacity: 0; position: fixed; pointer-events: none"; + return element; +} + +const Root = FocusGuards; + +export { + FocusGuards, + // + Root, + // + useFocusGuards, +}; diff --git a/pkg/radix-ui-primitives/preact/focus-guards/mod.ts b/pkg/radix-ui-primitives/preact/focus-guards/mod.ts new file mode 100644 index 0000000..dc9311f --- /dev/null +++ b/pkg/radix-ui-primitives/preact/focus-guards/mod.ts @@ -0,0 +1,7 @@ +export { + FocusGuards, + // + Root, + // + useFocusGuards, +} from "./FocusGuards.tsx"; diff --git a/pkg/radix-ui-primitives/preact/focus-scope/FocusScope.tsx b/pkg/radix-ui-primitives/preact/focus-scope/FocusScope.tsx new file mode 100644 index 0000000..db69cfd --- /dev/null +++ b/pkg/radix-ui-primitives/preact/focus-scope/FocusScope.tsx @@ -0,0 +1,401 @@ +import * as React from "preact/compat"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; + +const AUTOFOCUS_ON_MOUNT = "focusScope.autoFocusOnMount"; +const AUTOFOCUS_ON_UNMOUNT = "focusScope.autoFocusOnUnmount"; +const EVENT_OPTIONS = { bubbles: false, cancelable: true }; + +type FocusableTarget = HTMLElement | { focus(): void }; + +/* ------------------------------------------------------------------------------------------------- + * FocusScope + * -----------------------------------------------------------------------------------------------*/ + +const FOCUS_SCOPE_NAME = "FocusScope"; + +type FocusScopeElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface FocusScopeProps extends PrimitiveDivProps { + /** + * When `true`, tabbing from last item will focus first tabbable + * and shift+tab from first item will focus last tababble. + * @defaultValue false + */ + loop?: boolean; + + /** + * When `true`, focus cannot escape the focus scope via keyboard, + * pointer, or a programmatic focus. + * @defaultValue false + */ + trapped?: boolean; + + /** + * Event handler called when auto-focusing on mount. + * Can be prevented. + */ + onMountAutoFocus?: (event: Event) => void; + + /** + * Event handler called when auto-focusing on unmount. + * Can be prevented. + */ + onUnmountAutoFocus?: (event: Event) => void; +} + +const FocusScope = React.forwardRef( + (props, forwardedRef) => { + const { + loop = false, + trapped = false, + onMountAutoFocus: onMountAutoFocusProp, + onUnmountAutoFocus: onUnmountAutoFocusProp, + ...scopeProps + } = props; + const [container, setContainer] = React.useState( + null, + ); + const onMountAutoFocus = useCallbackRef(onMountAutoFocusProp); + const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp); + const lastFocusedElementRef = React.useRef(null); + const composedRefs = useComposedRefs( + forwardedRef, + (node) => setContainer(node), + ); + + const focusScope = React.useRef({ + paused: false, + pause() { + this.paused = true; + }, + resume() { + this.paused = false; + }, + }).current; + + // Takes care of trapping focus if focus is moved outside programmatically for example + React.useEffect(() => { + if (trapped) { + function handleFocusIn(event: FocusEvent) { + if (focusScope.paused || !container) { + return; + } + const target = event.target as HTMLElement | null; + if (container.contains(target)) { + lastFocusedElementRef.current = target; + } else { + focus(lastFocusedElementRef.current, { select: true }); + } + } + + function handleFocusOut(event: FocusEvent) { + if (focusScope.paused || !container) return; + const relatedTarget = event.relatedTarget as HTMLElement | null; + + // A `focusout` event with a `null` `relatedTarget` will happen in at least two cases: + // + // 1. When the user switches app/tabs/windows/the browser itself loses focus. + // 2. In Google Chrome, when the focused element is removed from the DOM. + // + // We let the browser do its thing here because: + // + // 1. The browser already keeps a memory of what's focused for when the page gets refocused. + // 2. In Google Chrome, if we try to focus the deleted focused element (as per below), it + // throws the CPU to 100%, so we avoid doing anything for this reason here too. + if (relatedTarget === null) return; + + // If the focus has moved to an actual legitimate element (`relatedTarget !== null`) + // that is outside the container, we move focus to the last valid focused element inside. + if (!container.contains(relatedTarget)) { + focus(lastFocusedElementRef.current, { select: true }); + } + } + + // When the focused element gets removed from the DOM, browsers move focus + // back to the document.body. In this case, we move focus to the container + // to keep focus trapped correctly. + function handleMutations(mutations: MutationRecord[]) { + const focusedElement = document.activeElement as HTMLElement | null; + if (focusedElement !== document.body) return; + for (const mutation of mutations) { + if (mutation.removedNodes.length > 0) focus(container); + } + } + + document.addEventListener("focusin", handleFocusIn); + document.addEventListener("focusout", handleFocusOut); + const mutationObserver = new MutationObserver(handleMutations); + if (container) { + mutationObserver.observe(container, { + childList: true, + subtree: true, + }); + } + + return () => { + document.removeEventListener("focusin", handleFocusIn); + document.removeEventListener("focusout", handleFocusOut); + mutationObserver.disconnect(); + }; + } + }, [trapped, container, focusScope.paused]); + + React.useEffect(() => { + if (container) { + focusScopesStack.add(focusScope); + const previouslyFocusedElement = document.activeElement as + | HTMLElement + | null; + const hasFocusedCandidate = container.contains( + previouslyFocusedElement, + ); + + if (!hasFocusedCandidate) { + const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS); + container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus); + container.dispatchEvent(mountEvent); + if (!mountEvent.defaultPrevented) { + focusFirst(removeLinks(getTabbableCandidates(container)), { + select: true, + }); + if (document.activeElement === previouslyFocusedElement) { + focus(container); + } + } + } + + return () => { + container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus); + + // We hit a react bug (fixed in v17) with focusing in unmount. + // We need to delay the focus a little to get around it for now. + // See: https://github.com/facebook/react/issues/17894 + setTimeout(() => { + const unmountEvent = new CustomEvent( + AUTOFOCUS_ON_UNMOUNT, + EVENT_OPTIONS, + ); + container.addEventListener( + AUTOFOCUS_ON_UNMOUNT, + onUnmountAutoFocus, + ); + container.dispatchEvent(unmountEvent); + if (!unmountEvent.defaultPrevented) { + focus(previouslyFocusedElement ?? document.body, { + select: true, + }); + } + // we need to remove the listener after we `dispatchEvent` + container.removeEventListener( + AUTOFOCUS_ON_UNMOUNT, + onUnmountAutoFocus, + ); + + focusScopesStack.remove(focusScope); + }, 0); + }; + } + }, [container, onMountAutoFocus, onUnmountAutoFocus, focusScope]); + + // Takes care of looping focus (when tabbing whilst at the edges) + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (!loop && !trapped) return; + if (focusScope.paused) return; + + const isTabKey = event.key === "Tab" && !event.altKey && + !event.ctrlKey && !event.metaKey; + const focusedElement = document.activeElement as HTMLElement | null; + + if (isTabKey && focusedElement) { + const container = event.currentTarget as HTMLElement; + const [first, last] = getTabbableEdges(container); + const hasTabbableElementsInside = first && last; + + // we can only wrap focus if we have tabbable edges + if (!hasTabbableElementsInside) { + if (focusedElement === container) event.preventDefault(); + } else { + if (!event.shiftKey && focusedElement === last) { + event.preventDefault(); + if (loop) focus(first, { select: true }); + } else if (event.shiftKey && focusedElement === first) { + event.preventDefault(); + if (loop) focus(last, { select: true }); + } + } + } + }, + [loop, trapped, focusScope.paused], + ); + + return ( + + ); + }, +); + +FocusScope.displayName = FOCUS_SCOPE_NAME; + +/* ------------------------------------------------------------------------------------------------- + * Utils + * -----------------------------------------------------------------------------------------------*/ + +/** + * Attempts focusing the first element in a list of candidates. + * Stops when focus has actually moved. + */ +function focusFirst(candidates: HTMLElement[], { select = false } = {}) { + const previouslyFocusedElement = document.activeElement; + for (const candidate of candidates) { + focus(candidate, { select }); + if (document.activeElement !== previouslyFocusedElement) return; + } +} + +/** + * Returns the first and last tabbable elements inside a container. + */ +function getTabbableEdges(container: HTMLElement) { + const candidates = getTabbableCandidates(container); + const first = findVisible(candidates, container); + const last = findVisible(candidates.reverse(), container); + return [first, last] as const; +} + +/** + * Returns a list of potential tabbable candidates. + * + * NOTE: This is only a close approximation. For example it doesn't take into account cases like when + * elements are not visible. This cannot be worked out easily by just reading a property, but rather + * necessitate runtime knowledge (computed styles, etc). We deal with these cases separately. + * + * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker + * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1 + */ +function getTabbableCandidates(container: HTMLElement) { + const nodes: HTMLElement[] = []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node: any) => { + const isHiddenInput = node.tagName === "INPUT" && node.type === "hidden"; + if (node.disabled || node.hidden || isHiddenInput) { + return NodeFilter.FILTER_SKIP; + } + // `.tabIndex` is not the same as the `tabindex` attribute. It works on the + // runtime's understanding of tabbability, so this automatically accounts + // for any kind of element that could be tabbed to. + return node.tabIndex >= 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP; + }, + }); + while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement); + // we do not take into account the order of nodes with positive `tabIndex` as it + // hinders accessibility to have tab order different from visual order. + return nodes; +} + +/** + * Returns the first visible element in a list. + * NOTE: Only checks visibility up to the `container`. + */ +function findVisible(elements: HTMLElement[], container: HTMLElement) { + for (const element of elements) { + // we stop checking if it's hidden at the `container` level (excluding) + if (!isHidden(element, { upTo: container })) return element; + } +} + +function isHidden(node: HTMLElement, { upTo }: { upTo?: HTMLElement }) { + if (getComputedStyle(node).visibility === "hidden") return true; + while (node) { + // we stop at `upTo` (excluding it) + if (upTo !== undefined && node === upTo) return false; + if (getComputedStyle(node).display === "none") return true; + node = node.parentElement as HTMLElement; + } + return false; +} + +function isSelectableInput( + element: any, +): element is FocusableTarget & { select: () => void } { + return element instanceof HTMLInputElement && "select" in element; +} + +function focus(element?: FocusableTarget | null, { select = false } = {}) { + // only focus if that element is focusable + if (element && element.focus) { + const previouslyFocusedElement = document.activeElement; + // NOTE: we prevent scrolling on focus, to minimize jarring transitions for users + element.focus({ preventScroll: true }); + // only select if its not the same element, it supports selection and we need to select + if ( + element !== previouslyFocusedElement && isSelectableInput(element) && + select + ) { + element.select(); + } + } +} + +/* ------------------------------------------------------------------------------------------------- + * FocusScope stack + * -----------------------------------------------------------------------------------------------*/ + +type FocusScopeAPI = { paused: boolean; pause(): void; resume(): void }; +const focusScopesStack = createFocusScopesStack(); + +function createFocusScopesStack() { + /** A stack of focus scopes, with the active one at the top */ + let stack: FocusScopeAPI[] = []; + + return { + add(focusScope: FocusScopeAPI) { + // pause the currently active focus scope (at the top of the stack) + const activeFocusScope = stack[0]; + if (focusScope !== activeFocusScope) { + activeFocusScope?.pause(); + } + // remove in case it already exists (because we'll re-add it at the top of the stack) + stack = arrayRemove(stack, focusScope); + stack.unshift(focusScope); + }, + + remove(focusScope: FocusScopeAPI) { + stack = arrayRemove(stack, focusScope); + stack[0]?.resume(); + }, + }; +} + +function arrayRemove(array: T[], item: T) { + const updatedArray = [...array]; + const index = updatedArray.indexOf(item); + if (index !== -1) { + updatedArray.splice(index, 1); + } + return updatedArray; +} + +function removeLinks(items: HTMLElement[]) { + return items.filter((item) => item.tagName !== "A"); +} + +const Root = FocusScope; + +export { + FocusScope, + // + Root, +}; +export type { FocusScopeProps }; diff --git a/pkg/radix-ui-primitives/preact/focus-scope/mod.ts b/pkg/radix-ui-primitives/preact/focus-scope/mod.ts new file mode 100644 index 0000000..7b63992 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/focus-scope/mod.ts @@ -0,0 +1,6 @@ +export { + FocusScope, + // + Root, +} from "./FocusScope.tsx"; +export type { FocusScopeProps } from "./FocusScope.tsx"; diff --git a/pkg/radix-ui-primitives/preact/form/Form.tsx b/pkg/radix-ui-primitives/preact/form/Form.tsx new file mode 100644 index 0000000..e66a8fd --- /dev/null +++ b/pkg/radix-ui-primitives/preact/form/Form.tsx @@ -0,0 +1,905 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { useId } from "../id/mod.ts"; +import { Label as LabelPrimitive } from "../label/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +type ScopedProps

= P & { __scopeForm?: Scope }; +const [createFormContext, createFormScope] = createContextScope("Form"); + +/* ------------------------------------------------------------------------------------------------- + * Form + * -----------------------------------------------------------------------------------------------*/ + +const FORM_NAME = "Form"; + +type ValidityMap = { [fieldName: string]: ValidityState | undefined }; +type CustomMatcherEntriesMap = { [fieldName: string]: CustomMatcherEntry[] }; +type CustomErrorsMap = { [fieldName: string]: Record }; + +type ValidationContextValue = { + getFieldValidity(fieldName: string): ValidityState | undefined; + onFieldValidityChange(fieldName: string, validity: ValidityState): void; + + getFieldCustomMatcherEntries(fieldName: string): CustomMatcherEntry[]; + onFieldCustomMatcherEntryAdd( + fieldName: string, + matcherEntry: CustomMatcherEntry, + ): void; + onFieldCustomMatcherEntryRemove( + fieldName: string, + matcherEntryId: string, + ): void; + + getFieldCustomErrors(fieldName: string): Record; + onFieldCustomErrorsChange( + fieldName: string, + errors: Record, + ): void; + + onFieldValiditionClear(fieldName: string): void; +}; +const [ValidationProvider, useValidationContext] = createFormContext< + ValidationContextValue +>(FORM_NAME); + +type MessageIdsMap = { [fieldName: string]: Set }; + +type AriaDescriptionContextValue = { + onFieldMessageIdAdd(fieldName: string, id: string): void; + onFieldMessageIdRemove(fieldName: string, id: string): void; + getFieldDescription(fieldName: string): string | undefined; +}; +const [AriaDescriptionProvider, useAriaDescriptionContext] = createFormContext< + AriaDescriptionContextValue +>(FORM_NAME); + +type FormElement = React.ElementRef; +type PrimitiveFormProps = Radix.ComponentPropsWithoutRef; +interface FormProps extends PrimitiveFormProps { + onClearServerErrors?(): void; +} + +const Form = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeForm, onClearServerErrors = () => {}, ...rootProps } = props; + const formRef = React.useRef(null); + const composedFormRef = useComposedRefs(forwardedRef, formRef); + + // native validity per field + const [validityMap, setValidityMap] = React.useState({}); + const getFieldValidity: ValidationContextValue["getFieldValidity"] = React + .useCallback( + (fieldName) => validityMap[fieldName], + [validityMap], + ); + const handleFieldValidityChange: + ValidationContextValue["onFieldValidityChange"] = React.useCallback( + (fieldName, validity) => + setValidityMap((prevValidityMap) => ({ + ...prevValidityMap, + [fieldName]: { ...(prevValidityMap[fieldName] ?? {}), ...validity }, + })), + [], + ); + const handleFieldValiditionClear: + ValidationContextValue["onFieldValiditionClear"] = React.useCallback( + (fieldName) => { + setValidityMap((prevValidityMap) => ({ + ...prevValidityMap, + [fieldName]: undefined, + })); + setCustomErrorsMap((prevCustomErrorsMap) => ({ + ...prevCustomErrorsMap, + [fieldName]: {}, + })); + }, + [], + ); + + // custom matcher entries per field + const [customMatcherEntriesMap, setCustomMatcherEntriesMap] = React + .useState({}); + const getFieldCustomMatcherEntries: + ValidationContextValue["getFieldCustomMatcherEntries"] = React + .useCallback( + (fieldName) => customMatcherEntriesMap[fieldName] ?? [], + [customMatcherEntriesMap], + ); + const handleFieldCustomMatcherAdd: + ValidationContextValue["onFieldCustomMatcherEntryAdd"] = React + .useCallback((fieldName, matcherEntry) => { + setCustomMatcherEntriesMap((prevCustomMatcherEntriesMap) => ({ + ...prevCustomMatcherEntriesMap, + [fieldName]: [ + ...(prevCustomMatcherEntriesMap[fieldName] ?? []), + matcherEntry, + ], + })); + }, []); + const handleFieldCustomMatcherRemove: + ValidationContextValue["onFieldCustomMatcherEntryRemove"] = React + .useCallback((fieldName, matcherEntryId) => { + setCustomMatcherEntriesMap((prevCustomMatcherEntriesMap) => ({ + ...prevCustomMatcherEntriesMap, + [fieldName]: (prevCustomMatcherEntriesMap[fieldName] ?? []).filter( + (matcherEntry) => matcherEntry.id !== matcherEntryId, + ), + })); + }, []); + + // custom errors per field + const [customErrorsMap, setCustomErrorsMap] = React.useState< + CustomErrorsMap + >({}); + const getFieldCustomErrors: ValidationContextValue["getFieldCustomErrors"] = + React.useCallback( + (fieldName) => customErrorsMap[fieldName] ?? {}, + [customErrorsMap], + ); + const handleFieldCustomErrorsChange: + ValidationContextValue["onFieldCustomErrorsChange"] = React + .useCallback( + (fieldName, customErrors) => { + setCustomErrorsMap((prevCustomErrorsMap) => ({ + ...prevCustomErrorsMap, + [fieldName]: { + ...(prevCustomErrorsMap[fieldName] ?? {}), + ...customErrors, + }, + })); + }, + [], + ); + + // messageIds per field + const [messageIdsMap, setMessageIdsMap] = React.useState< + MessageIdsMap + >({}); + const handleFieldMessageIdAdd: + AriaDescriptionContextValue["onFieldMessageIdAdd"] = React + .useCallback( + (fieldName, id) => { + setMessageIdsMap((prevMessageIdsMap) => { + const fieldDescriptionIds = new Set(prevMessageIdsMap[fieldName]) + .add(id); + return { ...prevMessageIdsMap, [fieldName]: fieldDescriptionIds }; + }); + }, + [], + ); + const handleFieldMessageIdRemove: + AriaDescriptionContextValue["onFieldMessageIdRemove"] = React + .useCallback( + (fieldName, id) => { + setMessageIdsMap((prevMessageIdsMap) => { + const fieldDescriptionIds = new Set(prevMessageIdsMap[fieldName]); + fieldDescriptionIds.delete(id); + return { ...prevMessageIdsMap, [fieldName]: fieldDescriptionIds }; + }); + }, + [], + ); + const getFieldDescription: + AriaDescriptionContextValue["getFieldDescription"] = React + .useCallback( + (fieldName) => + Array.from(messageIdsMap[fieldName] ?? []).join(" ") || undefined, + [messageIdsMap], + ); + + return ( + + + { + const firstInvalidControl = getFirstInvalidControl( + event.currentTarget, + ); + if (firstInvalidControl === event.target) { + firstInvalidControl.focus(); + } + + // prevent default browser UI for form validation + event.preventDefault(); + })} + // clear server errors when the form is re-submitted + onSubmit={composeEventHandlers( + props.onSubmit, + onClearServerErrors, + { + checkForDefaultPrevented: false, + }, + )} + // clear server errors when the form is reset + onReset={composeEventHandlers(props.onReset, onClearServerErrors)} + /> + + + ); + }, +); + +Form.displayName = FORM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * FormField + * -----------------------------------------------------------------------------------------------*/ + +const FIELD_NAME = "FormField"; + +type FormFieldContextValue = { + id: string; + name: string; + serverInvalid: boolean; +}; +const [FormFieldProvider, useFormFieldContext] = createFormContext< + FormFieldContextValue +>(FIELD_NAME); + +type FormFieldElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface FormFieldProps extends PrimitiveDivProps { + name: string; + serverInvalid?: boolean; +} + +const FormField = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeForm, name, serverInvalid = false, ...fieldProps } = props; + const validationContext = useValidationContext(FIELD_NAME, __scopeForm); + const validity = validationContext.getFieldValidity(name); + const id = useId(); + + return ( + + + + ); + }, +); + +FormField.displayName = FIELD_NAME; + +/* ------------------------------------------------------------------------------------------------- + * FormLabel + * -----------------------------------------------------------------------------------------------*/ + +const LABEL_NAME = "FormLabel"; + +type FormLabelElement = React.ElementRef; +type LabelProps = Radix.ComponentPropsWithoutRef; +interface FormLabelProps extends LabelProps {} + +const FormLabel = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeForm, ...labelProps } = props; + const validationContext = useValidationContext(LABEL_NAME, __scopeForm); + const fieldContext = useFormFieldContext(LABEL_NAME, __scopeForm); + const htmlFor = labelProps.htmlFor || fieldContext.id; + const validity = validationContext.getFieldValidity(fieldContext.name); + + return ( + + ); + }, +); + +FormLabel.displayName = LABEL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * FormControl + * -----------------------------------------------------------------------------------------------*/ + +const CONTROL_NAME = "FormControl"; + +type FormControlElement = React.ElementRef; +type PrimitiveInputProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.input +>; +interface FormControlProps extends PrimitiveInputProps {} + +const FormControl = React.forwardRef< + FormControlElement, + FormControlProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeForm, ...controlProps } = props; + + const validationContext = useValidationContext(CONTROL_NAME, __scopeForm); + const fieldContext = useFormFieldContext(CONTROL_NAME, __scopeForm); + const ariaDescriptionContext = useAriaDescriptionContext( + CONTROL_NAME, + __scopeForm, + ); + + const ref = React.useRef(null); + const composedRef = useComposedRefs(forwardedRef, ref); + const name = controlProps.name || fieldContext.name; + const id = controlProps.id || fieldContext.id; + const customMatcherEntries = validationContext.getFieldCustomMatcherEntries( + name, + ); + + const { + onFieldValidityChange, + onFieldCustomErrorsChange, + onFieldValiditionClear, + } = validationContext; + const updateControlValidity = React.useCallback( + async (control: FormControlElement) => { + //------------------------------------------------------------------------------------------ + // 1. first, if we have built-in errors we stop here + + if (hasBuiltInError(control.validity)) { + const controlValidity = validityStateToObject(control.validity); + onFieldValidityChange(name, controlValidity); + return; + } + + //------------------------------------------------------------------------------------------ + // 2. then gather the form data to give to custom matchers for cross-comparisons + + const formData = control.form + ? new FormData(control.form) + : new FormData(); + const matcherArgs: CustomMatcherArgs = [control.value, formData]; + + //------------------------------------------------------------------------------------------ + // 3. split sync and async custom matcher entries + + const syncCustomMatcherEntries: Array = []; + const ayncCustomMatcherEntries: Array = []; + customMatcherEntries.forEach((customMatcherEntry) => { + if (isAsyncCustomMatcherEntry(customMatcherEntry, matcherArgs)) { + ayncCustomMatcherEntries.push(customMatcherEntry); + } else if (isSyncCustomMatcherEntry(customMatcherEntry)) { + syncCustomMatcherEntries.push(customMatcherEntry); + } + }); + + //------------------------------------------------------------------------------------------ + // 4. run sync custom matchers and update control validity / internal validity + errors + + const syncCustomErrors = syncCustomMatcherEntries.map( + ({ id, match }) => { + return [id, match(...matcherArgs)] as const; + }, + ); + const syncCustomErrorsById = Object.fromEntries(syncCustomErrors); + const hasSyncCustomErrors = Object.values(syncCustomErrorsById).some( + Boolean, + ); + const hasCustomError = hasSyncCustomErrors; + control.setCustomValidity( + hasCustomError ? DEFAULT_INVALID_MESSAGE : "", + ); + const controlValidity = validityStateToObject(control.validity); + onFieldValidityChange(name, controlValidity); + onFieldCustomErrorsChange(name, syncCustomErrorsById); + + //------------------------------------------------------------------------------------------ + // 5. run async custom matchers and update control validity / internal validity + errors + + if (!hasSyncCustomErrors && ayncCustomMatcherEntries.length > 0) { + const promisedCustomErrors = ayncCustomMatcherEntries.map(( + { id, match }, + ) => match(...matcherArgs).then((matches) => [id, matches] as const)); + const asyncCustomErrors = await Promise.all(promisedCustomErrors); + const asyncCustomErrorsById = Object.fromEntries(asyncCustomErrors); + const hasAsyncCustomErrors = Object.values(asyncCustomErrorsById) + .some(Boolean); + const hasCustomError = hasAsyncCustomErrors; + control.setCustomValidity( + hasCustomError ? DEFAULT_INVALID_MESSAGE : "", + ); + const controlValidity = validityStateToObject(control.validity); + onFieldValidityChange(name, controlValidity); + onFieldCustomErrorsChange(name, asyncCustomErrorsById); + } + }, + [ + customMatcherEntries, + name, + onFieldCustomErrorsChange, + onFieldValidityChange, + ], + ); + + React.useEffect(() => { + const control = ref.current; + if (control) { + // We only want validate on change (native `change` event, not React's `onChange`). This is primarily + // a UX decision, we don't want to validate on every keystroke and React's `onChange` is the `input` event. + const handleChange = () => updateControlValidity(control); + control.addEventListener("change", handleChange); + return () => control.removeEventListener("change", handleChange); + } + }, [updateControlValidity]); + + const resetControlValidity = React.useCallback(() => { + const control = ref.current; + if (control) { + control.setCustomValidity(""); + onFieldValiditionClear(name); + } + }, [name, onFieldValiditionClear]); + + // reset validity and errors when the form is reset + React.useEffect(() => { + const form = ref.current?.form; + if (form) { + form.addEventListener("reset", resetControlValidity); + return () => form.removeEventListener("reset", resetControlValidity); + } + }, [resetControlValidity]); + + // focus first invalid control when fields are set as invalid by server + React.useEffect(() => { + const control = ref.current; + const form = control?.closest("form"); + if (form && fieldContext.serverInvalid) { + const firstInvalidControl = getFirstInvalidControl(form); + if (firstInvalidControl === control) firstInvalidControl.focus(); + } + }, [fieldContext.serverInvalid]); + + const validity = validationContext.getFieldValidity(name); + + return ( + { + const control = event.currentTarget; + updateControlValidity(control); + })} + onChange={composeEventHandlers(props.onChange, (event) => { + // reset validity when user changes value + resetControlValidity(); + })} + /> + ); + }, +); + +FormControl.displayName = CONTROL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * FormMessage + * -----------------------------------------------------------------------------------------------*/ + +const validityMatchers = [ + "badInput", + "patternMismatch", + "rangeOverflow", + "rangeUnderflow", + "stepMismatch", + "tooLong", + "tooShort", + "typeMismatch", + "valid", + "valueMissing", +] as const; +type ValidityMatcher = typeof validityMatchers[number]; + +const DEFAULT_INVALID_MESSAGE = "This value is not valid"; +const DEFAULT_BUILT_IN_MESSAGES: Record = { + badInput: DEFAULT_INVALID_MESSAGE, + patternMismatch: "This value does not match the required pattern", + rangeOverflow: "This value is too large", + rangeUnderflow: "This value is too small", + stepMismatch: "This value does not match the required step", + tooLong: "This value is too long", + tooShort: "This value is too short", + typeMismatch: "This value does not match the required type", + valid: undefined, + valueMissing: "This value is missing", +}; + +const MESSAGE_NAME = "FormMessage"; + +type FormMessageElement = FormMessageImplElement; +interface FormMessageProps extends Omit { + match?: ValidityMatcher | CustomMatcher; + forceMatch?: boolean; + name?: string; +} + +const FormMessage = React.forwardRef< + FormMessageElement, + FormMessageProps +>( + (props: ScopedProps, forwardedRef) => { + const { match, name: nameProp, ...messageProps } = props; + const fieldContext = useFormFieldContext(MESSAGE_NAME, props.__scopeForm); + const name = nameProp ?? fieldContext.name; + + if (match === undefined) { + return ( + + {props.children || DEFAULT_INVALID_MESSAGE} + + ); + } else if (typeof match === "function") { + return ( + + ); + } else { + return ( + + ); + } + }, +); + +FormMessage.displayName = MESSAGE_NAME; + +type FormBuiltInMessageElement = FormMessageImplElement; +interface FormBuiltInMessageProps extends FormMessageImplProps { + match: ValidityMatcher; + forceMatch?: boolean; + name: string; +} + +const FormBuiltInMessage = React.forwardRef< + FormBuiltInMessageElement, + FormBuiltInMessageProps +>( + (props: ScopedProps, forwardedRef) => { + const { match, forceMatch = false, name, children, ...messageProps } = + props; + const validationContext = useValidationContext( + MESSAGE_NAME, + messageProps.__scopeForm, + ); + const validity = validationContext.getFieldValidity(name); + const matches = forceMatch || validity?.[match]; + + if (matches) { + return ( + + {children ?? DEFAULT_BUILT_IN_MESSAGES[match]} + + ); + } + + return null; + }, +); + +type FormCustomMessageElement = React.ElementRef; +interface FormCustomMessageProps + extends Radix.ComponentPropsWithoutRef { + match: CustomMatcher; + forceMatch?: boolean; + name: string; +} + +const FormCustomMessage = React.forwardRef< + FormCustomMessageElement, + FormCustomMessageProps +>( + (props: ScopedProps, forwardedRef) => { + const { + match, + forceMatch = false, + name, + id: idProp, + children, + ...messageProps + } = props; + const validationContext = useValidationContext( + MESSAGE_NAME, + messageProps.__scopeForm, + ); + const ref = React.useRef(null); + const composedRef = useComposedRefs(forwardedRef, ref); + const _id = useId(); + const id = idProp ?? _id; + + const customMatcherEntry = React.useMemo(() => ({ id, match }), [ + id, + match, + ]); + const { onFieldCustomMatcherEntryAdd, onFieldCustomMatcherEntryRemove } = + validationContext; + React.useEffect(() => { + onFieldCustomMatcherEntryAdd(name, customMatcherEntry); + return () => onFieldCustomMatcherEntryRemove(name, customMatcherEntry.id); + }, [ + customMatcherEntry, + name, + onFieldCustomMatcherEntryAdd, + onFieldCustomMatcherEntryRemove, + ]); + + const validity = validationContext.getFieldValidity(name); + const customErrors = validationContext.getFieldCustomErrors(name); + const hasMatchingCustomError = customErrors[id]; + const matches = forceMatch || + (validity && !hasBuiltInError(validity) && hasMatchingCustomError); + + if (matches) { + return ( + + {children ?? DEFAULT_INVALID_MESSAGE} + + ); + } + + return null; + }, +); + +type FormMessageImplElement = React.ElementRef; +type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef; +interface FormMessageImplProps extends PrimitiveSpanProps { + name: string; +} + +const FormMessageImpl = React.forwardRef< + FormMessageImplElement, + FormMessageImplProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeForm, id: idProp, name, ...messageProps } = props; + const ariaDescriptionContext = useAriaDescriptionContext( + MESSAGE_NAME, + __scopeForm, + ); + const _id = useId(); + const id = idProp ?? _id; + + const { onFieldMessageIdAdd, onFieldMessageIdRemove } = + ariaDescriptionContext; + React.useEffect(() => { + onFieldMessageIdAdd(name, id); + return () => onFieldMessageIdRemove(name, id); + }, [name, id, onFieldMessageIdAdd, onFieldMessageIdRemove]); + + return ; + }, +); + +/* ------------------------------------------------------------------------------------------------- + * FormValidityState + * -----------------------------------------------------------------------------------------------*/ + +const VALIDITY_STATE_NAME = "FormValidityState"; + +interface FormValidityStateProps { + children(validity: ValidityState | undefined): React.ComponentChildren; + name?: string; +} + +const FormValidityState = (props: ScopedProps) => { + const { __scopeForm, name: nameProp, children } = props; + const validationContext = useValidationContext( + VALIDITY_STATE_NAME, + __scopeForm, + ); + const fieldContext = useFormFieldContext(VALIDITY_STATE_NAME, __scopeForm); + const name = nameProp ?? fieldContext.name; + const validity = validationContext.getFieldValidity(name); + return <>{children(validity)}; +}; + +FormValidityState.displayName = VALIDITY_STATE_NAME; + +/* ------------------------------------------------------------------------------------------------- + * FormSubmit + * -----------------------------------------------------------------------------------------------*/ + +const SUBMIT_NAME = "FormSubmit"; + +type FormSubmitElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface FormSubmitProps extends PrimitiveButtonProps {} + +const FormSubmit = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeForm, ...submitProps } = props; + return ( + + ); + }, +); + +FormSubmit.displayName = SUBMIT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +type ValidityStateKey = keyof ValidityState; +type SyncCustomMatcher = (value: string, formData: FormData) => boolean; +type AsyncCustomMatcher = ( + value: string, + formData: FormData, +) => Promise; +type CustomMatcher = SyncCustomMatcher | AsyncCustomMatcher; +type CustomMatcherEntry = { id: string; match: CustomMatcher }; +type SyncCustomMatcherEntry = { id: string; match: SyncCustomMatcher }; +type AsyncCustomMatcherEntry = { id: string; match: AsyncCustomMatcher }; +type CustomMatcherArgs = [string, FormData]; + +function validityStateToObject(validity: ValidityState) { + const object: any = {}; + for (const key in validity) { + object[key] = validity[key as ValidityStateKey]; + } + return object as Record; +} + +function isHTMLElement(element: any): element is HTMLElement { + return element instanceof HTMLElement; +} + +function isFormControl(element: any): element is { validity: ValidityState } { + return "validity" in element; +} + +function isInvalid(control: HTMLElement) { + return ( + isFormControl(control) && + (control.validity.valid === false || + control.getAttribute("aria-invalid") === "true") + ); +} + +function getFirstInvalidControl( + form: HTMLFormElement, +): HTMLElement | undefined { + const elements = form.elements; + const [firstInvalidControl] = Array.from(elements).filter(isHTMLElement) + .filter(isInvalid); + return firstInvalidControl; +} + +function isAsyncCustomMatcherEntry( + entry: CustomMatcherEntry, + args: CustomMatcherArgs, +): entry is AsyncCustomMatcherEntry { + return entry.match.constructor.name === "AsyncFunction" || + returnsPromise(entry.match, args); +} + +function isSyncCustomMatcherEntry( + entry: CustomMatcherEntry, +): entry is SyncCustomMatcherEntry { + return entry.match.constructor.name === "Function"; +} + +function returnsPromise(func: Function, args: Array) { + return func(...args) instanceof Promise; +} + +function hasBuiltInError(validity: ValidityState) { + let error = false; + for (const validityKey in validity) { + const key = validityKey as ValidityStateKey; + if (key !== "valid" && key !== "customError" && validity[key]) { + error = true; + break; + } + } + return error; +} + +function getValidAttribute( + validity: ValidityState | undefined, + serverInvalid: boolean, +) { + if (validity?.valid === true && !serverInvalid) return true; + return undefined; +} +function getInvalidAttribute( + validity: ValidityState | undefined, + serverInvalid: boolean, +) { + if (validity?.valid === false || serverInvalid) return true; + return undefined; +} + +/* -----------------------------------------------------------------------------------------------*/ + +const Root = Form; +const Field = FormField; +const Label = FormLabel; +const Control = FormControl; +const Message = FormMessage; +const ValidityState = FormValidityState; +const Submit = FormSubmit; + +export { + Control, + createFormScope, + Field, + // + Form, + FormControl, + FormField, + FormLabel, + FormMessage, + FormSubmit, + FormValidityState, + Label, + Message, + // + Root, + Submit, + ValidityState, +}; + +export type { + FormControlProps, + FormFieldProps, + FormLabelProps, + FormMessageProps, + FormProps, + FormSubmitProps, + FormValidityStateProps, +}; diff --git a/pkg/radix-ui-primitives/preact/form/mod.ts b/pkg/radix-ui-primitives/preact/form/mod.ts new file mode 100644 index 0000000..7991fc2 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/form/mod.ts @@ -0,0 +1,29 @@ +export { + Control, + createFormScope, + Field, + // + Form, + FormControl, + FormField, + FormLabel, + FormMessage, + FormSubmit, + FormValidityState, + Label, + Message, + // + Root, + Submit, + ValidityState, +} from "./Form.tsx"; + +export type { + FormControlProps, + FormFieldProps, + FormLabelProps, + FormMessageProps, + FormProps, + FormSubmitProps, + FormValidityStateProps, +} from "./Form.tsx"; diff --git a/pkg/radix-ui-primitives/preact/hover-card/HoverCard.tsx b/pkg/radix-ui-primitives/preact/hover-card/HoverCard.tsx new file mode 100644 index 0000000..b9b95bd --- /dev/null +++ b/pkg/radix-ui-primitives/preact/hover-card/HoverCard.tsx @@ -0,0 +1,497 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "@radix-ui/primitive"; +import { createContextScope } from "../context/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import * as PopperPrimitive from "../popper/mod.ts"; +import { createPopperScope } from "../popper/mod.ts"; +import { Portal as PortalPrimitive } from "../portal/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { DismissableLayer } from "../dismissable-layer/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * HoverCard + * -----------------------------------------------------------------------------------------------*/ + +let originalBodyUserSelect: string; + +const HOVERCARD_NAME = "HoverCard"; + +type ScopedProps

= P & { __scopeHoverCard?: Scope }; +const [createHoverCardContext, createHoverCardScope] = createContextScope( + HOVERCARD_NAME, + [ + createPopperScope, + ], +); +const usePopperScope = createPopperScope(); + +type HoverCardContextValue = { + open: boolean; + onOpenChange(open: boolean): void; + onOpen(): void; + onClose(): void; + onDismiss(): void; + hasSelectionRef: React.MutableRefObject; + isPointerDownOnContentRef: React.MutableRefObject; +}; + +const [HoverCardProvider, useHoverCardContext] = createHoverCardContext< + HoverCardContextValue +>(HOVERCARD_NAME); + +interface HoverCardProps { + children?: React.ComponentChildren; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + openDelay?: number; + closeDelay?: number; +} + +const HoverCard: React.FC = ( + props: ScopedProps, +) => { + const { + __scopeHoverCard, + children, + open: openProp, + defaultOpen, + onOpenChange, + openDelay = 700, + closeDelay = 300, + } = props; + const popperScope = usePopperScope(__scopeHoverCard); + const openTimerRef = React.useRef(0); + const closeTimerRef = React.useRef(0); + const hasSelectionRef = React.useRef(false); + const isPointerDownOnContentRef = React.useRef(false); + + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const handleOpen = React.useCallback(() => { + clearTimeout(closeTimerRef.current); + openTimerRef.current = window.setTimeout(() => setOpen(true), openDelay); + }, [openDelay, setOpen]); + + const handleClose = React.useCallback(() => { + clearTimeout(openTimerRef.current); + if (!hasSelectionRef.current && !isPointerDownOnContentRef.current) { + closeTimerRef.current = window.setTimeout( + () => setOpen(false), + closeDelay, + ); + } + }, [closeDelay, setOpen]); + + const handleDismiss = React.useCallback(() => setOpen(false), [setOpen]); + + // cleanup any queued state updates on unmount + React.useEffect(() => { + return () => { + clearTimeout(openTimerRef.current); + clearTimeout(closeTimerRef.current); + }; + }, []); + + return ( + + {children} + + ); +}; + +HoverCard.displayName = HOVERCARD_NAME; + +/* ------------------------------------------------------------------------------------------------- + * HoverCardTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "HoverCardTrigger"; + +type HoverCardTriggerElement = React.ElementRef; +type PrimitiveLinkProps = Radix.ComponentPropsWithoutRef; +interface HoverCardTriggerProps extends PrimitiveLinkProps {} + +const HoverCardTrigger = React.forwardRef< + HoverCardTriggerElement, + HoverCardTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeHoverCard, ...triggerProps } = props; + const context = useHoverCardContext(TRIGGER_NAME, __scopeHoverCard); + const popperScope = usePopperScope(__scopeHoverCard); + return ( + + + event.preventDefault())} + /> + + ); + }, +); + +HoverCardTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * HoverCardPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "HoverCardPortal"; + +type PortalContextValue = { forceMount?: true }; +const [PortalProvider, usePortalContext] = createHoverCardContext< + PortalContextValue +>(PORTAL_NAME, { + forceMount: undefined, +}); + +type PortalProps = Radix.ComponentPropsWithoutRef; +interface HoverCardPortalProps { + children?: React.ComponentChildren; + /** + * Specify a container element to portal the content into. + */ + container?: PortalProps["container"]; + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const HoverCardPortal: React.FC = ( + props: ScopedProps, +) => { + const { __scopeHoverCard, forceMount, children, container } = props; + const context = useHoverCardContext(PORTAL_NAME, __scopeHoverCard); + return ( + + + + {children} + + + + ); +}; + +HoverCardPortal.displayName = PORTAL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * HoverCardContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "HoverCardContent"; + +type HoverCardContentElement = HoverCardContentImplElement; +interface HoverCardContentProps extends HoverCardContentImplProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const HoverCardContent = React.forwardRef< + HoverCardContentElement, + HoverCardContentProps +>( + (props: ScopedProps, forwardedRef) => { + const portalContext = usePortalContext( + CONTENT_NAME, + props.__scopeHoverCard, + ); + const { forceMount = portalContext.forceMount, ...contentProps } = props; + const context = useHoverCardContext(CONTENT_NAME, props.__scopeHoverCard); + return ( + + + + ); + }, +); + +HoverCardContent.displayName = CONTENT_NAME; + +/* ---------------------------------------------------------------------------------------------- */ + +type HoverCardContentImplElement = React.ElementRef< + typeof PopperPrimitive.Content +>; +type DismissableLayerProps = Radix.ComponentPropsWithoutRef< + typeof DismissableLayer +>; +type PopperContentProps = Radix.ComponentPropsWithoutRef< + typeof PopperPrimitive.Content +>; +interface HoverCardContentImplProps + extends Omit { + /** + * Event handler called when the escape key is down. + * Can be prevented. + */ + onEscapeKeyDown?: DismissableLayerProps["onEscapeKeyDown"]; + /** + * Event handler called when the a `pointerdown` event happens outside of the `HoverCard`. + * Can be prevented. + */ + onPointerDownOutside?: DismissableLayerProps["onPointerDownOutside"]; + /** + * Event handler called when the focus moves outside of the `HoverCard`. + * Can be prevented. + */ + onFocusOutside?: DismissableLayerProps["onFocusOutside"]; + /** + * Event handler called when an interaction happens outside the `HoverCard`. + * Specifically, when a `pointerdown` event happens outside or focus moves outside of it. + * Can be prevented. + */ + onInteractOutside?: DismissableLayerProps["onInteractOutside"]; +} + +const HoverCardContentImpl = React.forwardRef< + HoverCardContentImplElement, + HoverCardContentImplProps +>((props: ScopedProps, forwardedRef) => { + const { + __scopeHoverCard, + onEscapeKeyDown, + onPointerDownOutside, + onFocusOutside, + onInteractOutside, + ...contentProps + } = props; + const context = useHoverCardContext(CONTENT_NAME, __scopeHoverCard); + const popperScope = usePopperScope(__scopeHoverCard); + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + const [containSelection, setContainSelection] = React.useState(false); + + React.useEffect(() => { + if (containSelection) { + const body = document.body; + + // Safari requires prefix + originalBodyUserSelect = body.style.userSelect || + body.style.webkitUserSelect; + + body.style.userSelect = "none"; + body.style.webkitUserSelect = "none"; + return () => { + body.style.userSelect = originalBodyUserSelect; + body.style.webkitUserSelect = originalBodyUserSelect; + }; + } + }, [containSelection]); + + React.useEffect(() => { + if (ref.current) { + const handlePointerUp = () => { + setContainSelection(false); + context.isPointerDownOnContentRef.current = false; + + // Delay a frame to ensure we always access the latest selection + setTimeout(() => { + const hasSelection = document.getSelection()?.toString() !== ""; + if (hasSelection) context.hasSelectionRef.current = true; + }); + }; + + document.addEventListener("pointerup", handlePointerUp); + return () => { + document.removeEventListener("pointerup", handlePointerUp); + context.hasSelectionRef.current = false; + context.isPointerDownOnContentRef.current = false; + }; + } + }, [context.isPointerDownOnContentRef, context.hasSelectionRef]); + + React.useEffect(() => { + if (ref.current) { + const tabbables = getTabbableNodes(ref.current); + tabbables.forEach((tabbable) => tabbable.setAttribute("tabindex", "-1")); + } + }); + + return ( + { + event.preventDefault(); + })} + onDismiss={context.onDismiss} + > + { + // Contain selection to current layer + if (event.currentTarget.contains(event.target as HTMLElement)) { + setContainSelection(true); + } + context.hasSelectionRef.current = false; + context.isPointerDownOnContentRef.current = true; + }, + )} + ref={composedRefs} + style={{ + ...contentProps.style, + userSelect: containSelection ? "text" : undefined, + // Safari requires prefix + WebkitUserSelect: containSelection ? "text" : undefined, + // re-namespace exposed content custom properties + ...{ + "--radix-hover-card-content-transform-origin": + "var(--radix-popper-transform-origin)", + "--radix-hover-card-content-available-width": + "var(--radix-popper-available-width)", + "--radix-hover-card-content-available-height": + "var(--radix-popper-available-height)", + "--radix-hover-card-trigger-width": + "var(--radix-popper-anchor-width)", + "--radix-hover-card-trigger-height": + "var(--radix-popper-anchor-height)", + }, + }} + /> + + ); +}); + +/* ------------------------------------------------------------------------------------------------- + * HoverCardArrow + * -----------------------------------------------------------------------------------------------*/ + +const ARROW_NAME = "HoverCardArrow"; + +type HoverCardArrowElement = React.ElementRef; +type PopperArrowProps = Radix.ComponentPropsWithoutRef< + typeof PopperPrimitive.Arrow +>; +interface HoverCardArrowProps extends PopperArrowProps {} + +const HoverCardArrow = React.forwardRef< + HoverCardArrowElement, + HoverCardArrowProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeHoverCard, ...arrowProps } = props; + const popperScope = usePopperScope(__scopeHoverCard); + return ( + + ); + }, +); + +HoverCardArrow.displayName = ARROW_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +function excludeTouch(eventHandler: () => void) { + return (event: React.PointerEvent) => + event.pointerType === "touch" ? undefined : eventHandler(); +} + +/** + * Returns a list of nodes that can be in the tab sequence. + * @see: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker + */ +function getTabbableNodes(container: HTMLElement) { + const nodes: HTMLElement[] = []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node: any) => { + // `.tabIndex` is not the same as the `tabindex` attribute. It works on the + // runtime's understanding of tabbability, so this automatically accounts + // for any kind of element that could be tabbed to. + return node.tabIndex >= 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP; + }, + }); + while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement); + return nodes; +} + +const Root = HoverCard; +const Trigger = HoverCardTrigger; +const Portal = HoverCardPortal; +const Content = HoverCardContent; +const Arrow = HoverCardArrow; + +export { + Arrow, + Content, + createHoverCardScope, + // + HoverCard, + HoverCardArrow, + HoverCardContent, + HoverCardPortal, + HoverCardTrigger, + Portal, + // + Root, + Trigger, +}; +export type { + HoverCardArrowProps, + HoverCardContentProps, + HoverCardPortalProps, + HoverCardProps, + HoverCardTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/hover-card/mod.ts b/pkg/radix-ui-primitives/preact/hover-card/mod.ts new file mode 100644 index 0000000..727529a --- /dev/null +++ b/pkg/radix-ui-primitives/preact/hover-card/mod.ts @@ -0,0 +1,22 @@ +export { + Arrow, + Content, + createHoverCardScope, + // + HoverCard, + HoverCardArrow, + HoverCardContent, + HoverCardPortal, + HoverCardTrigger, + Portal, + // + Root, + Trigger, +} from "./HoverCard.tsx"; +export type { + HoverCardArrowProps, + HoverCardContentProps, + HoverCardPortalProps, + HoverCardProps, + HoverCardTriggerProps, +} from "./HoverCard.tsx"; diff --git a/pkg/radix-ui-primitives/preact/id/id.tsx b/pkg/radix-ui-primitives/preact/id/id.tsx new file mode 100644 index 0000000..89a2bb5 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/id/id.tsx @@ -0,0 +1,17 @@ +import * as React from "preact/compat"; +import { useLayoutEffect } from "../use-layout-effect/mod.ts"; + +// We `toString()` to prevent bundlers from trying to `import { useId } from 'react';` +const useReactId = (React as any)["useId".toString()] || (() => undefined); +let count = 0; + +function useId(deterministicId?: string): string { + const [id, setId] = React.useState(useReactId()); + // React versions older than 18 will have client-side ids only. + useLayoutEffect(() => { + if (!deterministicId) setId((reactId) => reactId ?? String(count++)); + }, [deterministicId]); + return deterministicId || (id ? `radix-${id}` : ""); +} + +export { useId }; diff --git a/pkg/radix-ui-primitives/preact/id/mod.ts b/pkg/radix-ui-primitives/preact/id/mod.ts new file mode 100644 index 0000000..fef6bf0 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/id/mod.ts @@ -0,0 +1 @@ +export { useId } from "./id.tsx"; diff --git a/pkg/radix-ui-primitives/preact/label/Label.tsx b/pkg/radix-ui-primitives/preact/label/Label.tsx new file mode 100644 index 0000000..b410d04 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/label/Label.tsx @@ -0,0 +1,48 @@ +import * as React from "preact/compat"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Label + * -----------------------------------------------------------------------------------------------*/ + +const NAME = "Label"; + +type LabelElement = React.ElementRef; +type PrimitiveLabelProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.label +>; +interface LabelProps extends PrimitiveLabelProps {} + +const Label = React.forwardRef( + (props, forwardedRef) => { + return ( + { + props.onMouseDown?.(event); + // prevent text selection when double clicking label + if (!event.defaultPrevented && event.detail > 1) { + event + .preventDefault(); + } + }} + /> + ); + }, +); + +Label.displayName = NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +const Root = Label; + +export { + Label, + // + Root, +}; +export type { LabelProps }; diff --git a/pkg/radix-ui-primitives/preact/label/mod.ts b/pkg/radix-ui-primitives/preact/label/mod.ts new file mode 100644 index 0000000..ea451b1 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/label/mod.ts @@ -0,0 +1,6 @@ +export { + Label, + // + Root, +} from "./Label.tsx"; +export type { LabelProps } from "./Label.tsx"; diff --git a/pkg/radix-ui-primitives/preact/menu/Menu.tsx b/pkg/radix-ui-primitives/preact/menu/Menu.tsx new file mode 100644 index 0000000..39f5371 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/menu/Menu.tsx @@ -0,0 +1,1587 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { createCollection } from "../collection/mod.ts"; +import { composeRefs, useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { useDirection } from "../direction/mod.ts"; +import { DismissableLayer } from "../dismissable-layer/mod.ts"; +import { useFocusGuards } from "../focus-guards/mod.ts"; +import { FocusScope } from "../focus-scope/mod.ts"; +import { useId } from "../id/mod.ts"; +import * as PopperPrimitive from "../popper/mod.ts"; +import { createPopperScope } from "../popper/mod.ts"; +import { Portal as PortalPrimitive } from "../portal/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { dispatchDiscreteCustomEvent, Primitive } from "../primitive/mod.ts"; +import * as RovingFocusGroup from "../roving-focus/mod.ts"; +import { createRovingFocusGroupScope } from "../roving-focus/mod.ts"; +import { Slot } from "../slot/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; +import { hideOthers } from "aria-hidden"; +import { RemoveScroll } from "react-remove-scroll"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +type Direction = "ltr" | "rtl"; + +const SELECTION_KEYS = ["Enter", " "]; +const FIRST_KEYS = ["ArrowDown", "PageUp", "Home"]; +const LAST_KEYS = ["ArrowUp", "PageDown", "End"]; +const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]; +const SUB_OPEN_KEYS: Record = { + ltr: [...SELECTION_KEYS, "ArrowRight"], + rtl: [...SELECTION_KEYS, "ArrowLeft"], +}; +const SUB_CLOSE_KEYS: Record = { + ltr: ["ArrowLeft"], + rtl: ["ArrowRight"], +}; + +/* ------------------------------------------------------------------------------------------------- + * Menu + * -----------------------------------------------------------------------------------------------*/ + +const MENU_NAME = "Menu"; + +type ItemData = { disabled: boolean; textValue: string }; +const [Collection, useCollection, createCollectionScope] = createCollection< + MenuItemElement, + ItemData +>(MENU_NAME); + +type ScopedProps

= P & { __scopeMenu?: Scope }; +const [createMenuContext, createMenuScope] = createContextScope(MENU_NAME, [ + createCollectionScope, + createPopperScope, + createRovingFocusGroupScope, +]); +const usePopperScope = createPopperScope(); +const useRovingFocusGroupScope = createRovingFocusGroupScope(); + +type MenuContextValue = { + open: boolean; + onOpenChange(open: boolean): void; + content: MenuContentElement | null; + onContentChange(content: MenuContentElement | null): void; +}; + +const [MenuProvider, useMenuContext] = createMenuContext( + MENU_NAME, +); + +type MenuRootContextValue = { + onClose(): void; + isUsingKeyboardRef: React.RefObject; + dir: Direction; + modal: boolean; +}; + +const [MenuRootProvider, useMenuRootContext] = createMenuContext< + MenuRootContextValue +>(MENU_NAME); + +interface MenuProps { + children?: React.ComponentChildren; + open?: boolean; + onOpenChange?(open: boolean): void; + dir?: Direction; + modal?: boolean; +} + +const Menu: React.FC = (props: ScopedProps) => { + const { + __scopeMenu, + open = false, + children, + dir, + onOpenChange, + modal = true, + } = props; + const popperScope = usePopperScope(__scopeMenu); + const [content, setContent] = React.useState( + null, + ); + const isUsingKeyboardRef = React.useRef(false); + const handleOpenChange = useCallbackRef(onOpenChange); + const direction = useDirection(dir); + + React.useEffect(() => { + // Capture phase ensures we set the boolean before any side effects execute + // in response to the key or pointer event as they might depend on this value. + const handleKeyDown = () => { + isUsingKeyboardRef.current = true; + document.addEventListener("pointerdown", handlePointer, { + capture: true, + once: true, + }); + document.addEventListener("pointermove", handlePointer, { + capture: true, + once: true, + }); + }; + const handlePointer = () => (isUsingKeyboardRef.current = false); + document.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => { + document.removeEventListener("keydown", handleKeyDown, { capture: true }); + document.removeEventListener("pointerdown", handlePointer, { + capture: true, + }); + document.removeEventListener("pointermove", handlePointer, { + capture: true, + }); + }; + }, []); + + return ( + + + handleOpenChange(false), [ + handleOpenChange, + ])} + isUsingKeyboardRef={isUsingKeyboardRef} + dir={direction} + modal={modal} + > + {children} + + + + ); +}; + +Menu.displayName = MENU_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuAnchor + * -----------------------------------------------------------------------------------------------*/ + +const ANCHOR_NAME = "MenuAnchor"; + +type MenuAnchorElement = React.ElementRef; +type PopperAnchorProps = Radix.ComponentPropsWithoutRef< + typeof PopperPrimitive.Anchor +>; +interface MenuAnchorProps extends PopperAnchorProps {} + +const MenuAnchor = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenu, ...anchorProps } = props; + const popperScope = usePopperScope(__scopeMenu); + return ( + + ); + }, +); + +MenuAnchor.displayName = ANCHOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "MenuPortal"; + +type PortalContextValue = { forceMount?: true }; +const [PortalProvider, usePortalContext] = createMenuContext< + PortalContextValue +>(PORTAL_NAME, { + forceMount: undefined, +}); + +type PortalProps = Radix.ComponentPropsWithoutRef; +interface MenuPortalProps { + children?: React.ComponentChildren; + /** + * Specify a container element to portal the content into. + */ + container?: PortalProps["container"]; + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const MenuPortal: React.FC = ( + props: ScopedProps, +) => { + const { __scopeMenu, forceMount, children, container } = props; + const context = useMenuContext(PORTAL_NAME, __scopeMenu); + return ( + + + + {children} + + + + ); +}; + +MenuPortal.displayName = PORTAL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "MenuContent"; + +type MenuContentContextValue = { + onItemEnter(event: React.PointerEvent): void; + onItemLeave(event: React.PointerEvent): void; + onTriggerLeave(event: React.PointerEvent): void; + searchRef: React.RefObject; + pointerGraceTimerRef: React.MutableRefObject; + onPointerGraceIntentChange(intent: GraceIntent | null): void; +}; +const [MenuContentProvider, useMenuContentContext] = createMenuContext< + MenuContentContextValue +>(CONTENT_NAME); + +type MenuContentElement = MenuRootContentTypeElement; +/** + * We purposefully don't union MenuRootContent and MenuSubContent props here because + * they have conflicting prop types. We agreed that we would allow MenuSubContent to + * accept props that it would just ignore. + */ +interface MenuContentProps extends MenuRootContentTypeProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const MenuContent = React.forwardRef< + MenuContentElement, + MenuContentProps +>( + (props: ScopedProps, forwardedRef) => { + const portalContext = usePortalContext(CONTENT_NAME, props.__scopeMenu); + const { forceMount = portalContext.forceMount, ...contentProps } = props; + const context = useMenuContext(CONTENT_NAME, props.__scopeMenu); + const rootContext = useMenuRootContext(CONTENT_NAME, props.__scopeMenu); + + return ( + + + + {rootContext.modal + ? + : ( + + )} + + + + ); + }, +); + +/* ---------------------------------------------------------------------------------------------- */ + +type MenuRootContentTypeElement = MenuContentImplElement; +interface MenuRootContentTypeProps + extends Omit {} + +const MenuRootContentModal = React.forwardRef< + MenuRootContentTypeElement, + MenuRootContentTypeProps +>( + (props: ScopedProps, forwardedRef) => { + const context = useMenuContext(CONTENT_NAME, props.__scopeMenu); + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + + // Hide everything from ARIA except the `MenuContent` + React.useEffect(() => { + const content = ref.current; + if (content) return hideOthers(content); + }, []); + + return ( + event.preventDefault(), + { checkForDefaultPrevented: false }, + )} + onDismiss={() => context.onOpenChange(false)} + /> + ); + }, +); + +const MenuRootContentNonModal = React.forwardRef< + MenuRootContentTypeElement, + MenuRootContentTypeProps +>((props: ScopedProps, forwardedRef) => { + const context = useMenuContext(CONTENT_NAME, props.__scopeMenu); + return ( + context.onOpenChange(false)} + /> + ); +}); + +/* ---------------------------------------------------------------------------------------------- */ + +type MenuContentImplElement = React.ElementRef; +type FocusScopeProps = Radix.ComponentPropsWithoutRef; +type DismissableLayerProps = Radix.ComponentPropsWithoutRef< + typeof DismissableLayer +>; +type RovingFocusGroupProps = Radix.ComponentPropsWithoutRef< + typeof RovingFocusGroup.Root +>; +type PopperContentProps = Radix.ComponentPropsWithoutRef< + typeof PopperPrimitive.Content +>; +type MenuContentImplPrivateProps = { + onOpenAutoFocus?: FocusScopeProps["onMountAutoFocus"]; + onDismiss?: DismissableLayerProps["onDismiss"]; + disableOutsidePointerEvents?: + DismissableLayerProps["disableOutsidePointerEvents"]; + + /** + * Whether scrolling outside the `MenuContent` should be prevented + * (default: `false`) + */ + disableOutsideScroll?: boolean; + + /** + * Whether focus should be trapped within the `MenuContent` + * (default: false) + */ + trapFocus?: FocusScopeProps["trapped"]; +}; +interface MenuContentImplProps + extends + MenuContentImplPrivateProps, + Omit { + /** + * Event handler called when auto-focusing on close. + * Can be prevented. + */ + onCloseAutoFocus?: FocusScopeProps["onUnmountAutoFocus"]; + + /** + * Whether keyboard navigation should loop around + * @defaultValue false + */ + loop?: RovingFocusGroupProps["loop"]; + + onEntryFocus?: RovingFocusGroupProps["onEntryFocus"]; + onEscapeKeyDown?: DismissableLayerProps["onEscapeKeyDown"]; + onPointerDownOutside?: DismissableLayerProps["onPointerDownOutside"]; + onFocusOutside?: DismissableLayerProps["onFocusOutside"]; + onInteractOutside?: DismissableLayerProps["onInteractOutside"]; +} + +const MenuContentImpl = React.forwardRef< + MenuContentImplElement, + MenuContentImplProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopeMenu, + loop = false, + trapFocus, + onOpenAutoFocus, + onCloseAutoFocus, + disableOutsidePointerEvents, + onEntryFocus, + onEscapeKeyDown, + onPointerDownOutside, + onFocusOutside, + onInteractOutside, + onDismiss, + disableOutsideScroll, + ...contentProps + } = props; + const context = useMenuContext(CONTENT_NAME, __scopeMenu); + const rootContext = useMenuRootContext(CONTENT_NAME, __scopeMenu); + const popperScope = usePopperScope(__scopeMenu); + const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeMenu); + const getItems = useCollection(__scopeMenu); + const [currentItemId, setCurrentItemId] = React.useState< + string | null + >( + null, + ); + const contentRef = React.useRef(null); + const composedRefs = useComposedRefs( + forwardedRef, + contentRef, + context.onContentChange, + ); + const timerRef = React.useRef(0); + const searchRef = React.useRef(""); + const pointerGraceTimerRef = React.useRef(0); + const pointerGraceIntentRef = React.useRef(null); + const pointerDirRef = React.useRef("right"); + const lastPointerXRef = React.useRef(0); + + const ScrollLockWrapper = disableOutsideScroll + ? RemoveScroll + : React.Fragment; + const scrollLockWrapperProps = disableOutsideScroll + ? { as: Slot, allowPinchZoom: true } + : undefined; + + const handleTypeaheadSearch = (key: string) => { + const search = searchRef.current + key; + const items = getItems().filter((item) => !item.disabled); + const currentItem = document.activeElement; + const currentMatch = items.find((item) => + item.ref.current === currentItem + )?.textValue; + const values = items.map((item) => item.textValue); + const nextMatch = getNextMatch(values, search, currentMatch); + const newItem = items.find((item) => item.textValue === nextMatch)?.ref + .current; + + // Reset `searchRef` 1 second after it was last updated + (function updateSearch(value: string) { + searchRef.current = value; + window.clearTimeout(timerRef.current); + if (value !== "") { + timerRef.current = window.setTimeout(() => updateSearch(""), 1000); + } + })(search); + + if (newItem) { + /** + * Imperative focus during keydown is risky so we prevent React's batching updates + * to avoid potential bugs. See: https://github.com/facebook/react/issues/20332 + */ + setTimeout(() => (newItem as HTMLElement).focus()); + } + }; + + React.useEffect(() => { + return () => window.clearTimeout(timerRef.current); + }, []); + + // Make sure the whole tree has focus guards as our `MenuContent` may be + // the last element in the DOM (beacuse of the `Portal`) + useFocusGuards(); + + const isPointerMovingToSubmenu = React.useCallback( + (event: React.PointerEvent) => { + const isMovingTowards = + pointerDirRef.current === pointerGraceIntentRef.current?.side; + return isMovingTowards && + isPointerInGraceArea(event, pointerGraceIntentRef.current?.area); + }, + [], + ); + + return ( + { + if (isPointerMovingToSubmenu(event)) event.preventDefault(); + }, + [isPointerMovingToSubmenu], + )} + onItemLeave={React.useCallback( + (event) => { + if (isPointerMovingToSubmenu(event)) return; + contentRef.current?.focus(); + setCurrentItemId(null); + }, + [isPointerMovingToSubmenu], + )} + onTriggerLeave={React.useCallback( + (event) => { + if (isPointerMovingToSubmenu(event)) event.preventDefault(); + }, + [isPointerMovingToSubmenu], + )} + pointerGraceTimerRef={pointerGraceTimerRef} + onPointerGraceIntentChange={React.useCallback((intent) => { + pointerGraceIntentRef.current = intent; + }, [])} + > + + { + // when opening, explicitly focus the content area only and leave + // `onEntryFocus` in control of focusing first item + event.preventDefault(); + contentRef.current?.focus(); + })} + onUnmountAutoFocus={onCloseAutoFocus} + > + + { + // only focus first item when using keyboard + if (!rootContext.isUsingKeyboardRef.current) { + event.preventDefault(); + } + })} + > + { + // submenu key events bubble through portals. We only care about keys in this menu. + const target = event.target as HTMLElement; + const isKeyDownInside = + target.closest("[data-radix-menu-content]") === + event.currentTarget; + const isModifierKey = event.ctrlKey || event.altKey || + event.metaKey; + const isCharacterKey = event.key.length === 1; + if (isKeyDownInside) { + // menus should not be navigated using tab key so we prevent it + if (event.key === "Tab") event.preventDefault(); + if ( + !isModifierKey && isCharacterKey + ) handleTypeaheadSearch(event.key); + } + // focus first/last item based on key pressed + const content = contentRef.current; + if (event.target !== content) return; + if (!FIRST_LAST_KEYS.includes(event.key)) return; + event.preventDefault(); + const items = getItems().filter((item) => !item.disabled); + const candidateNodes = items.map((item) => + item.ref.current! + ); + if (LAST_KEYS.includes(event.key)) { + candidateNodes + .reverse(); + } + focusFirst(candidateNodes); + }, + )} + onBlur={composeEventHandlers(props.onBlur, (event) => { + // clear search buffer when leaving the menu + if (!event.currentTarget.contains(event.target)) { + window.clearTimeout(timerRef.current); + searchRef.current = ""; + } + })} + onPointerMove={composeEventHandlers( + props.onPointerMove, + whenMouse((event) => { + const target = event.target as HTMLElement; + const pointerXHasChanged = + lastPointerXRef.current !== event.clientX; + + // We don't use `event.movementX` for this check because Safari will + // always return `0` on a pointer event. + if ( + event.currentTarget.contains(target) && + pointerXHasChanged + ) { + const newDir = event.clientX > lastPointerXRef.current + ? "right" + : "left"; + pointerDirRef.current = newDir; + lastPointerXRef.current = event.clientX; + } + }), + )} + /> + + + + + + ); + }, +); + +MenuContent.displayName = CONTENT_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuGroup + * -----------------------------------------------------------------------------------------------*/ + +const GROUP_NAME = "MenuGroup"; + +type MenuGroupElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface MenuGroupProps extends PrimitiveDivProps {} + +const MenuGroup = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenu, ...groupProps } = props; + return ; + }, +); + +MenuGroup.displayName = GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuLabel + * -----------------------------------------------------------------------------------------------*/ + +const LABEL_NAME = "MenuLabel"; + +type MenuLabelElement = React.ElementRef; +interface MenuLabelProps extends PrimitiveDivProps {} + +const MenuLabel = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenu, ...labelProps } = props; + return ; + }, +); + +MenuLabel.displayName = LABEL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuItem + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_NAME = "MenuItem"; +const ITEM_SELECT = "menu.itemSelect"; + +type MenuItemElement = MenuItemImplElement; +interface MenuItemProps extends Omit { + onSelect?: (event: Event) => void; +} + +const MenuItem = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { disabled = false, onSelect, ...itemProps } = props; + const ref = React.useRef(null); + const rootContext = useMenuRootContext(ITEM_NAME, props.__scopeMenu); + const contentContext = useMenuContentContext(ITEM_NAME, props.__scopeMenu); + const composedRefs = useComposedRefs(forwardedRef, ref); + const isPointerDownRef = React.useRef(false); + + const handleSelect = () => { + const menuItem = ref.current; + if (!disabled && menuItem) { + const itemSelectEvent = new CustomEvent(ITEM_SELECT, { + bubbles: true, + cancelable: true, + }); + menuItem.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), { + once: true, + }); + dispatchDiscreteCustomEvent(menuItem, itemSelectEvent); + if (itemSelectEvent.defaultPrevented) { + isPointerDownRef.current = false; + } else { + rootContext.onClose(); + } + } + }; + + return ( + { + props.onPointerDown?.(event); + isPointerDownRef.current = true; + }} + onPointerUp={composeEventHandlers(props.onPointerUp, (event) => { + // Pointer down can move to a different menu item which should activate it on pointer up. + // We dispatch a click for selection to allow composition with click based triggers and to + // prevent Firefox from getting stuck in text selection mode when the menu closes. + if (!isPointerDownRef.current) event.currentTarget?.click(); + })} + onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + const isTypingAhead = contentContext.searchRef.current !== ""; + if (disabled || (isTypingAhead && event.key === " ")) return; + if (SELECTION_KEYS.includes(event.key)) { + event.currentTarget.click(); + /** + * We prevent default browser behaviour for selection keys as they should trigger + * a selection only: + * - prevents space from scrolling the page. + * - if keydown causes focus to move, prevents keydown from firing on the new target. + */ + event.preventDefault(); + } + })} + /> + ); + }, +); + +MenuItem.displayName = ITEM_NAME; + +/* ---------------------------------------------------------------------------------------------- */ + +type MenuItemImplElement = React.ElementRef; +interface MenuItemImplProps extends PrimitiveDivProps { + disabled?: boolean; + textValue?: string; +} + +const MenuItemImpl = React.forwardRef< + MenuItemImplElement, + MenuItemImplProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenu, disabled = false, textValue, ...itemProps } = props; + const contentContext = useMenuContentContext(ITEM_NAME, __scopeMenu); + const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeMenu); + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + const [isFocused, setIsFocused] = React.useState(false); + + // get the item's `.textContent` as default strategy for typeahead `textValue` + const [textContent, setTextContent] = React.useState(""); + React.useEffect(() => { + const menuItem = ref.current; + if (menuItem) { + setTextContent((menuItem.textContent ?? "").trim()); + } + }, [itemProps.children]); + + return ( + + + { + if (disabled) { + contentContext.onItemLeave(event); + } else { + contentContext.onItemEnter(event); + if (!event.defaultPrevented) { + const item = event.currentTarget; + item.focus(); + } + } + }), + )} + onPointerLeave={composeEventHandlers( + props.onPointerLeave, + whenMouse((event) => contentContext.onItemLeave(event)), + )} + onFocus={composeEventHandlers(props.onFocus, () => + setIsFocused(true))} + onBlur={composeEventHandlers(props.onBlur, () => + setIsFocused(false))} + /> + + + ); + }, +); + +/* ------------------------------------------------------------------------------------------------- + * MenuCheckboxItem + * -----------------------------------------------------------------------------------------------*/ + +const CHECKBOX_ITEM_NAME = "MenuCheckboxItem"; + +type MenuCheckboxItemElement = MenuItemElement; + +type CheckedState = boolean | "indeterminate"; + +interface MenuCheckboxItemProps extends MenuItemProps { + checked?: CheckedState; + // `onCheckedChange` can never be called with `"indeterminate"` from the inside + onCheckedChange?: (checked: boolean) => void; +} + +const MenuCheckboxItem = React.forwardRef< + MenuCheckboxItemElement, + MenuCheckboxItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { checked = false, onCheckedChange, ...checkboxItemProps } = props; + return ( + + onCheckedChange?.(isIndeterminate(checked) ? true : !checked), + { checkForDefaultPrevented: false }, + )} + /> + + ); + }, +); + +MenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuRadioGroup + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_GROUP_NAME = "MenuRadioGroup"; + +const [RadioGroupProvider, useRadioGroupContext] = createMenuContext< + MenuRadioGroupProps +>( + RADIO_GROUP_NAME, + { value: undefined, onValueChange: () => {} }, +); + +type MenuRadioGroupElement = React.ElementRef; +interface MenuRadioGroupProps extends MenuGroupProps { + value?: string; + onValueChange?: (value: string) => void; +} + +const MenuRadioGroup = React.forwardRef< + MenuRadioGroupElement, + MenuRadioGroupProps +>( + (props: ScopedProps, forwardedRef) => { + const { value, onValueChange, ...groupProps } = props; + const handleValueChange = useCallbackRef(onValueChange); + return ( + + + + ); + }, +); + +MenuRadioGroup.displayName = RADIO_GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuRadioItem + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_ITEM_NAME = "MenuRadioItem"; + +type MenuRadioItemElement = React.ElementRef; +interface MenuRadioItemProps extends MenuItemProps { + value: string; +} + +const MenuRadioItem = React.forwardRef< + MenuRadioItemElement, + MenuRadioItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { value, ...radioItemProps } = props; + const context = useRadioGroupContext(RADIO_ITEM_NAME, props.__scopeMenu); + const checked = value === context.value; + return ( + + context.onValueChange?.(value), + { checkForDefaultPrevented: false }, + )} + /> + + ); + }, +); + +MenuRadioItem.displayName = RADIO_ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuItemIndicator + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_INDICATOR_NAME = "MenuItemIndicator"; + +type CheckboxContextValue = { checked: CheckedState }; + +const [ItemIndicatorProvider, useItemIndicatorContext] = createMenuContext< + CheckboxContextValue +>( + ITEM_INDICATOR_NAME, + { checked: false }, +); + +type MenuItemIndicatorElement = React.ElementRef; +type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef; +interface MenuItemIndicatorProps extends PrimitiveSpanProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const MenuItemIndicator = React.forwardRef< + MenuItemIndicatorElement, + MenuItemIndicatorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenu, forceMount, ...itemIndicatorProps } = props; + const indicatorContext = useItemIndicatorContext( + ITEM_INDICATOR_NAME, + __scopeMenu, + ); + return ( + + + + ); + }, +); + +MenuItemIndicator.displayName = ITEM_INDICATOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuSeparator + * -----------------------------------------------------------------------------------------------*/ + +const SEPARATOR_NAME = "MenuSeparator"; + +type MenuSeparatorElement = React.ElementRef; +interface MenuSeparatorProps extends PrimitiveDivProps {} + +const MenuSeparator = React.forwardRef< + MenuSeparatorElement, + MenuSeparatorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenu, ...separatorProps } = props; + return ( + + ); + }, +); + +MenuSeparator.displayName = SEPARATOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuArrow + * -----------------------------------------------------------------------------------------------*/ + +const ARROW_NAME = "MenuArrow"; + +type MenuArrowElement = React.ElementRef; +type PopperArrowProps = Radix.ComponentPropsWithoutRef< + typeof PopperPrimitive.Arrow +>; +interface MenuArrowProps extends PopperArrowProps {} + +const MenuArrow = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenu, ...arrowProps } = props; + const popperScope = usePopperScope(__scopeMenu); + return ( + + ); + }, +); + +MenuArrow.displayName = ARROW_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuSub + * -----------------------------------------------------------------------------------------------*/ + +const SUB_NAME = "MenuSub"; + +type MenuSubContextValue = { + contentId: string; + triggerId: string; + trigger: MenuSubTriggerElement | null; + onTriggerChange(trigger: MenuSubTriggerElement | null): void; +}; + +const [MenuSubProvider, useMenuSubContext] = createMenuContext< + MenuSubContextValue +>(SUB_NAME); + +interface MenuSubProps { + children?: React.ComponentChildren; + open?: boolean; + onOpenChange?(open: boolean): void; +} + +const MenuSub: React.FC = ( + props: ScopedProps, +) => { + const { __scopeMenu, children, open = false, onOpenChange } = props; + const parentMenuContext = useMenuContext(SUB_NAME, __scopeMenu); + const popperScope = usePopperScope(__scopeMenu); + const [trigger, setTrigger] = React.useState< + MenuSubTriggerElement | null + >( + null, + ); + const [content, setContent] = React.useState( + null, + ); + const handleOpenChange = useCallbackRef(onOpenChange); + + // Prevent the parent menu from reopening with open submenus. + React.useEffect(() => { + if (parentMenuContext.open === false) handleOpenChange(false); + return () => handleOpenChange(false); + }, [parentMenuContext.open, handleOpenChange]); + + return ( + + + + {children} + + + + ); +}; + +MenuSub.displayName = SUB_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuSubTrigger + * -----------------------------------------------------------------------------------------------*/ + +const SUB_TRIGGER_NAME = "MenuSubTrigger"; + +type MenuSubTriggerElement = MenuItemImplElement; +interface MenuSubTriggerProps extends MenuItemImplProps {} + +const MenuSubTrigger = React.forwardRef< + MenuSubTriggerElement, + MenuSubTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const context = useMenuContext(SUB_TRIGGER_NAME, props.__scopeMenu); + const rootContext = useMenuRootContext(SUB_TRIGGER_NAME, props.__scopeMenu); + const subContext = useMenuSubContext(SUB_TRIGGER_NAME, props.__scopeMenu); + const contentContext = useMenuContentContext( + SUB_TRIGGER_NAME, + props.__scopeMenu, + ); + const openTimerRef = React.useRef(null); + const { pointerGraceTimerRef, onPointerGraceIntentChange } = contentContext; + const scope = { __scopeMenu: props.__scopeMenu }; + + const clearOpenTimer = React.useCallback(() => { + if (openTimerRef.current) window.clearTimeout(openTimerRef.current); + openTimerRef.current = null; + }, []); + + React.useEffect(() => clearOpenTimer, [clearOpenTimer]); + + React.useEffect(() => { + const pointerGraceTimer = pointerGraceTimerRef.current; + return () => { + window.clearTimeout(pointerGraceTimer); + onPointerGraceIntentChange(null); + }; + }, [pointerGraceTimerRef, onPointerGraceIntentChange]); + + return ( + + { + props.onClick?.(event); + if (props.disabled || event.defaultPrevented) return; + /** + * We manually focus because iOS Safari doesn't always focus on click (e.g. buttons) + * and we rely heavily on `onFocusOutside` for submenus to close when switching + * between separate submenus. + */ + event.currentTarget.focus(); + if (!context.open) context.onOpenChange(true); + }} + onPointerMove={composeEventHandlers( + props.onPointerMove, + whenMouse((event) => { + contentContext.onItemEnter(event); + if (event.defaultPrevented) return; + if (!props.disabled && !context.open && !openTimerRef.current) { + contentContext.onPointerGraceIntentChange(null); + openTimerRef.current = window.setTimeout(() => { + context.onOpenChange(true); + clearOpenTimer(); + }, 100); + } + }), + )} + onPointerLeave={composeEventHandlers( + props.onPointerLeave, + whenMouse((event) => { + clearOpenTimer(); + + const contentRect = context.content?.getBoundingClientRect(); + if (contentRect) { + // TODO: make sure to update this when we change positioning logic + const side = context.content?.dataset.side as Side; + const rightSide = side === "right"; + const bleed = rightSide ? -5 : +5; + const contentNearEdge = + contentRect[rightSide ? "left" : "right"]; + const contentFarEdge = + contentRect[rightSide ? "right" : "left"]; + + contentContext.onPointerGraceIntentChange({ + area: [ + // Apply a bleed on clientX to ensure that our exit point is + // consistently within polygon bounds + { x: event.clientX + bleed, y: event.clientY }, + { x: contentNearEdge, y: contentRect.top }, + { x: contentFarEdge, y: contentRect.top }, + { x: contentFarEdge, y: contentRect.bottom }, + { x: contentNearEdge, y: contentRect.bottom }, + ], + side, + }); + + window.clearTimeout(pointerGraceTimerRef.current); + pointerGraceTimerRef.current = window.setTimeout( + () => contentContext.onPointerGraceIntentChange(null), + 300, + ); + } else { + contentContext.onTriggerLeave(event); + if (event.defaultPrevented) return; + + // There's 100ms where the user may leave an item before the submenu was opened. + contentContext.onPointerGraceIntentChange(null); + } + }), + )} + onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + const isTypingAhead = contentContext.searchRef.current !== ""; + if (props.disabled || (isTypingAhead && event.key === " ")) return; + if (SUB_OPEN_KEYS[rootContext.dir].includes(event.key)) { + context.onOpenChange(true); + // The trigger may hold focus if opened via pointer interaction + // so we ensure content is given focus again when switching to keyboard. + context.content?.focus(); + // prevent window from scrolling + event.preventDefault(); + } + })} + /> + + ); + }, +); + +MenuSubTrigger.displayName = SUB_TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenuSubContent + * -----------------------------------------------------------------------------------------------*/ + +const SUB_CONTENT_NAME = "MenuSubContent"; + +type MenuSubContentElement = MenuContentImplElement; +interface MenuSubContentProps extends + Omit< + MenuContentImplProps, + | keyof MenuContentImplPrivateProps + | "onCloseAutoFocus" + | "onEntryFocus" + | "side" + | "align" + > { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const MenuSubContent = React.forwardRef< + MenuSubContentElement, + MenuSubContentProps +>( + (props: ScopedProps, forwardedRef) => { + const portalContext = usePortalContext(CONTENT_NAME, props.__scopeMenu); + const { forceMount = portalContext.forceMount, ...subContentProps } = props; + const context = useMenuContext(CONTENT_NAME, props.__scopeMenu); + const rootContext = useMenuRootContext(CONTENT_NAME, props.__scopeMenu); + const subContext = useMenuSubContext(SUB_CONTENT_NAME, props.__scopeMenu); + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + return ( + + + + { + // when opening a submenu, focus content for keyboard users only + if (rootContext.isUsingKeyboardRef.current) { + ref.current?.focus(); + } + event.preventDefault(); + }} + // The menu might close because of focusing another menu item in the parent menu. We + // don't want it to refocus the trigger in that case so we handle trigger focus ourselves. + onCloseAutoFocus={(event) => event.preventDefault()} + onFocusOutside={composeEventHandlers( + props.onFocusOutside, + (event) => { + // We prevent closing when the trigger is focused to avoid triggering a re-open animation + // on pointer interaction. + if (event.target !== subContext.trigger) { + context + .onOpenChange(false); + } + }, + )} + onEscapeKeyDown={composeEventHandlers( + props.onEscapeKeyDown, + (event) => { + rootContext.onClose(); + // ensure pressing escape in submenu doesn't escape full screen mode + event.preventDefault(); + }, + )} + onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + // Submenu key events bubble through portals. We only care about keys in this menu. + const isKeyDownInside = event.currentTarget.contains( + event.target as HTMLElement, + ); + const isCloseKey = SUB_CLOSE_KEYS[rootContext.dir].includes( + event.key, + ); + if (isKeyDownInside && isCloseKey) { + context.onOpenChange(false); + // We focus manually because we prevented it in `onCloseAutoFocus` + subContext.trigger?.focus(); + // prevent window from scrolling + event.preventDefault(); + } + })} + /> + + + + ); + }, +); + +MenuSubContent.displayName = SUB_CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +function getOpenState(open: boolean) { + return open ? "open" : "closed"; +} + +function isIndeterminate(checked?: CheckedState): checked is "indeterminate" { + return checked === "indeterminate"; +} + +function getCheckedState(checked: CheckedState) { + return isIndeterminate(checked) + ? "indeterminate" + : checked + ? "checked" + : "unchecked"; +} + +function focusFirst(candidates: HTMLElement[]) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + for (const candidate of candidates) { + // if focus is already where we want to go, we don't want to keep going through the candidates + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus(); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +/** + * Wraps an array around itself at a given start index + * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']` + */ +function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length]); +} + +/** + * This is the "meat" of the typeahead matching logic. It takes in all the values, + * the search and the current match, and returns the next match (or `undefined`). + * + * We normalize the search because if a user has repeatedly pressed a character, + * we want the exact same behavior as if we only had that one character + * (ie. cycle through options starting with that character) + * + * We also reorder the values by wrapping the array around the current match. + * This is so we always look forward from the current match, and picking the first + * match will always be the correct one. + * + * Finally, if the normalized search is exactly one character, we exclude the + * current match from the values because otherwise it would be the first to match always + * and focus would never move. This is as opposed to the regular case, where we + * don't want focus to move if the current match still matches. + */ +function getNextMatch(values: string[], search: string, currentMatch?: string) { + const isRepeated = search.length > 1 && + Array.from(search).every((char) => char === search[0]); + const normalizedSearch = isRepeated ? search[0] : search; + const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1; + let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0)); + const excludeCurrentMatch = normalizedSearch.length === 1; + if (excludeCurrentMatch) { + wrappedValues = wrappedValues.filter((v) => v !== currentMatch); + } + const nextMatch = wrappedValues.find((value) => + value.toLowerCase().startsWith(normalizedSearch.toLowerCase()) + ); + return nextMatch !== currentMatch ? nextMatch : undefined; +} + +type Point = { x: number; y: number }; +type Polygon = Point[]; +type Side = "left" | "right"; +type GraceIntent = { area: Polygon; side: Side }; + +// Determine if a point is inside of a polygon. +// Based on https://github.com/substack/point-in-polygon +function isPointInPolygon(point: Point, polygon: Polygon) { + const { x, y } = point; + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].x; + const yi = polygon[i].y; + const xj = polygon[j].x; + const yj = polygon[j].y; + + // prettier-ignore + const intersect = ((yi > y) !== (yj > y)) && + (x < (xj - xi) * (y - yi) / (yj - yi) + xi); + if (intersect) inside = !inside; + } + + return inside; +} + +function isPointerInGraceArea(event: React.PointerEvent, area?: Polygon) { + if (!area) return false; + const cursorPos = { x: event.clientX, y: event.clientY }; + return isPointInPolygon(cursorPos, area); +} + +function whenMouse( + handler: React.PointerEventHandler, +): React.PointerEventHandler { + return ( + event, + ) => (event.pointerType === "mouse" ? handler(event) : undefined); +} + +const Root = Menu; +const Anchor = MenuAnchor; +const Portal = MenuPortal; +const Content = MenuContent; +const Group = MenuGroup; +const Label = MenuLabel; +const Item = MenuItem; +const CheckboxItem = MenuCheckboxItem; +const RadioGroup = MenuRadioGroup; +const RadioItem = MenuRadioItem; +const ItemIndicator = MenuItemIndicator; +const Separator = MenuSeparator; +const Arrow = MenuArrow; +const Sub = MenuSub; +const SubTrigger = MenuSubTrigger; +const SubContent = MenuSubContent; + +export { + Anchor, + Arrow, + CheckboxItem, + Content, + createMenuScope, + Group, + Item, + ItemIndicator, + Label, + // + Menu, + MenuAnchor, + MenuArrow, + MenuCheckboxItem, + MenuContent, + MenuGroup, + MenuItem, + MenuItemIndicator, + MenuLabel, + MenuPortal, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubContent, + MenuSubTrigger, + Portal, + RadioGroup, + RadioItem, + // + Root, + Separator, + Sub, + SubContent, + SubTrigger, +}; +export type { + MenuAnchorProps, + MenuArrowProps, + MenuCheckboxItemProps, + MenuContentProps, + MenuGroupProps, + MenuItemIndicatorProps, + MenuItemProps, + MenuLabelProps, + MenuPortalProps, + MenuProps, + MenuRadioGroupProps, + MenuRadioItemProps, + MenuSeparatorProps, + MenuSubContentProps, + MenuSubProps, + MenuSubTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/menu/mod.ts b/pkg/radix-ui-primitives/preact/menu/mod.ts new file mode 100644 index 0000000..bee88e0 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/menu/mod.ts @@ -0,0 +1,55 @@ +export { + Anchor, + Arrow, + CheckboxItem, + Content, + createMenuScope, + Group, + Item, + ItemIndicator, + Label, + // + Menu, + MenuAnchor, + MenuArrow, + MenuCheckboxItem, + MenuContent, + MenuGroup, + MenuItem, + MenuItemIndicator, + MenuLabel, + MenuPortal, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubContent, + MenuSubTrigger, + Portal, + RadioGroup, + RadioItem, + // + Root, + Separator, + Sub, + SubContent, + SubTrigger, +} from "./Menu.tsx"; +export type { + MenuAnchorProps, + MenuArrowProps, + MenuCheckboxItemProps, + MenuContentProps, + MenuGroupProps, + MenuItemIndicatorProps, + MenuItemProps, + MenuLabelProps, + MenuPortalProps, + MenuProps, + MenuRadioGroupProps, + MenuRadioItemProps, + MenuSeparatorProps, + MenuSubContentProps, + MenuSubProps, + MenuSubTriggerProps, +} from "./Menu.tsx"; diff --git a/pkg/radix-ui-primitives/preact/menubar/Menubar.tsx b/pkg/radix-ui-primitives/preact/menubar/Menubar.tsx new file mode 100644 index 0000000..c3355e2 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/menubar/Menubar.tsx @@ -0,0 +1,935 @@ +import * as React from "preact/compat"; +import { createCollection } from "../collection/mod.ts"; +import { useDirection } from "../direction/mod.ts"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { useId } from "../id/mod.ts"; +import * as MenuPrimitive from "../menu/mod.ts"; +import { createMenuScope } from "../menu/mod.ts"; +import * as RovingFocusGroup from "../roving-focus/mod.ts"; +import { createRovingFocusGroupScope } from "../roving-focus/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; + +import type { Scope } from "../context/mod.ts"; +import type * as Radix from "../primitive/mod.ts"; + +type Direction = "ltr" | "rtl"; + +/* ------------------------------------------------------------------------------------------------- + * Menubar + * -----------------------------------------------------------------------------------------------*/ + +const MENUBAR_NAME = "Menubar"; + +type ItemData = { value: string; disabled: boolean }; +const [Collection, useCollection, createCollectionScope] = createCollection< + MenubarTriggerElement, + ItemData +>(MENUBAR_NAME); + +type ScopedProps

= P & { __scopeMenubar?: Scope }; +const [createMenubarContext, createMenubarScope] = createContextScope( + MENUBAR_NAME, + [ + createCollectionScope, + createRovingFocusGroupScope, + ], +); + +const useMenuScope = createMenuScope(); +const useRovingFocusGroupScope = createRovingFocusGroupScope(); + +type MenubarContextValue = { + value: string; + dir: Direction; + loop: boolean; + onMenuOpen(value: string): void; + onMenuClose(): void; + onMenuToggle(value: string): void; +}; + +const [MenubarContextProvider, useMenubarContext] = createMenubarContext< + MenubarContextValue +>(MENUBAR_NAME); + +type MenubarElement = React.ElementRef; +type RovingFocusGroupProps = Radix.ComponentPropsWithoutRef< + typeof RovingFocusGroup.Root +>; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface MenubarProps extends PrimitiveDivProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + loop?: RovingFocusGroupProps["loop"]; + dir?: RovingFocusGroupProps["dir"]; +} + +const Menubar = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { + __scopeMenubar, + value: valueProp, + onValueChange, + defaultValue, + loop = true, + dir, + ...menubarProps + } = props; + const direction = useDirection(dir); + const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeMenubar); + const [value = "", setValue] = useControllableState({ + prop: valueProp, + onChange: onValueChange, + defaultProp: defaultValue, + }); + + // We need to manage tab stop id manually as `RovingFocusGroup` updates the stop + // based on focus, and in some situations our triggers won't ever be given focus + // (e.g. click to open and then outside to close) + const [currentTabStopId, setCurrentTabStopId] = React.useState< + string | null + >(null); + + return ( + { + setValue(value); + setCurrentTabStopId(value); + }, + [setValue], + )} + onMenuClose={React.useCallback(() => setValue(""), [setValue])} + onMenuToggle={React.useCallback( + (value) => { + setValue((prevValue) => (Boolean(prevValue) ? "" : value)); + // `openMenuOpen` and `onMenuToggle` are called exclusively so we + // need to update the id in either case. + setCurrentTabStopId(value); + }, + [setValue], + )} + dir={direction} + loop={loop} + > + + + + + + + + + ); + }, +); + +Menubar.displayName = MENUBAR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarMenu + * -----------------------------------------------------------------------------------------------*/ + +const MENU_NAME = "MenubarMenu"; + +type MenubarMenuContextValue = { + value: string; + triggerId: string; + triggerRef: React.RefObject; + contentId: string; + wasKeyboardTriggerOpenRef: React.MutableRefObject; +}; + +const [MenubarMenuProvider, useMenubarMenuContext] = createMenubarContext< + MenubarMenuContextValue +>(MENU_NAME); + +interface MenubarMenuProps { + value?: string; + children?: React.ComponentChildren; +} + +const MenubarMenu = (props: ScopedProps) => { + const { __scopeMenubar, value: valueProp, ...menuProps } = props; + const autoValue = useId(); + // We need to provide an initial deterministic value as `useId` will return + // empty string on the first render and we don't want to match our internal "closed" value. + const value = valueProp || autoValue || "LEGACY_REACT_AUTO_VALUE"; + const context = useMenubarContext(MENU_NAME, __scopeMenubar); + const menuScope = useMenuScope(__scopeMenubar); + const triggerRef = React.useRef(null); + const wasKeyboardTriggerOpenRef = React.useRef(false); + const open = context.value === value; + + React.useEffect(() => { + if (!open) wasKeyboardTriggerOpenRef.current = false; + }, [open]); + + return ( + + { + // Menu only calls `onOpenChange` when dismissing so we + // want to close our MenuBar based on the same events. + if (!open) context.onMenuClose(); + }} + modal={false} + dir={context.dir} + {...menuProps} + /> + + ); +}; + +MenubarMenu.displayName = MENU_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "MenubarTrigger"; + +type MenubarTriggerElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface MenubarTriggerProps extends PrimitiveButtonProps {} + +const MenubarTrigger = React.forwardRef< + MenubarTriggerElement, + MenubarTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, disabled = false, ...triggerProps } = props; + const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeMenubar); + const menuScope = useMenuScope(__scopeMenubar); + const context = useMenubarContext(TRIGGER_NAME, __scopeMenubar); + const menuContext = useMenubarMenuContext(TRIGGER_NAME, __scopeMenubar); + const ref = React.useRef(null); + const composedRefs = useComposedRefs( + forwardedRef, + ref, + menuContext.triggerRef, + ); + const [isFocused, setIsFocused] = React.useState(false); + const open = context.value === menuContext.value; + + return ( + + + + { + // only call handler if it's the left button (mousedown gets triggered by all mouse buttons) + // but not when the control key is pressed (avoiding MacOS right click) + if ( + !disabled && event.button === 0 && event.ctrlKey === false + ) { + context.onMenuOpen(menuContext.value); + // prevent trigger focusing when opening + // this allows the content to be given focus without competition + if (!open) event.preventDefault(); + } + }, + )} + onPointerEnter={composeEventHandlers(props.onPointerEnter, () => { + const menubarOpen = Boolean(context.value); + if (menubarOpen && !open) { + context.onMenuOpen(menuContext.value); + ref.current?.focus(); + } + })} + onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + if (disabled) return; + if (["Enter", " "].includes(event.key)) { + context.onMenuToggle(menuContext.value); + } + if (event.key === "ArrowDown") { + context.onMenuOpen(menuContext.value); + } + // prevent keydown from scrolling window / first focused item to execute + // that keydown (inadvertently closing the menu) + if (["Enter", " ", "ArrowDown"].includes(event.key)) { + menuContext.wasKeyboardTriggerOpenRef.current = true; + event.preventDefault(); + } + })} + onFocus={composeEventHandlers(props.onFocus, () => + setIsFocused(true))} + onBlur={composeEventHandlers(props.onBlur, () => + setIsFocused(false))} + /> + + + + ); + }, +); + +MenubarTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "MenubarPortal"; + +type MenuPortalProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Portal +>; +interface MenubarPortalProps extends MenuPortalProps {} + +const MenubarPortal: React.FC = ( + props: ScopedProps, +) => { + const { __scopeMenubar, ...portalProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ; +}; + +MenubarPortal.displayName = PORTAL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "MenubarContent"; + +type MenubarContentElement = React.ElementRef; +type MenuContentProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Content +>; +interface MenubarContentProps extends Omit {} + +const MenubarContent = React.forwardRef< + MenubarContentElement, + MenubarContentProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, align = "start", ...contentProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + const context = useMenubarContext(CONTENT_NAME, __scopeMenubar); + const menuContext = useMenubarMenuContext(CONTENT_NAME, __scopeMenubar); + const getItems = useCollection(__scopeMenubar); + const hasInteractedOutsideRef = React.useRef(false); + + return ( + { + const menubarOpen = Boolean(context.value); + if (!menubarOpen && !hasInteractedOutsideRef.current) { + menuContext.triggerRef.current?.focus(); + } + + hasInteractedOutsideRef.current = false; + // Always prevent auto focus because we either focus manually or want user agent focus + event.preventDefault(); + }, + )} + onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => { + const target = event.target as HTMLElement; + const isMenubarTrigger = getItems().some((item) => + item.ref.current?.contains(target) + ); + if (isMenubarTrigger) event.preventDefault(); + })} + onInteractOutside={composeEventHandlers(props.onInteractOutside, () => { + hasInteractedOutsideRef.current = true; + })} + onEntryFocus={(event) => { + if (!menuContext.wasKeyboardTriggerOpenRef.current) { + event + .preventDefault(); + } + }} + onKeyDown={composeEventHandlers( + props.onKeyDown, + (event) => { + if (["ArrowRight", "ArrowLeft"].includes(event.key)) { + const target = event.target as HTMLElement; + const targetIsSubTrigger = target.hasAttribute( + "data-radix-menubar-subtrigger", + ); + const isKeyDownInsideSubMenu = + target.closest("[data-radix-menubar-content]") !== + event.currentTarget; + + const prevMenuKey = context.dir === "rtl" + ? "ArrowRight" + : "ArrowLeft"; + const isPrevKey = prevMenuKey === event.key; + const isNextKey = !isPrevKey; + + // Prevent navigation when we're opening a submenu + if (isNextKey && targetIsSubTrigger) return; + // or we're inside a submenu and are moving backwards to close it + if (isKeyDownInsideSubMenu && isPrevKey) return; + + const items = getItems().filter((item) => !item.disabled); + let candidateValues = items.map((item) => item.value); + if (isPrevKey) candidateValues.reverse(); + + const currentIndex = candidateValues.indexOf(menuContext.value); + + candidateValues = context.loop + ? wrapArray(candidateValues, currentIndex + 1) + : candidateValues.slice(currentIndex + 1); + + const [nextValue] = candidateValues; + if (nextValue) context.onMenuOpen(nextValue); + } + }, + { checkForDefaultPrevented: false }, + )} + style={{ + ...props.style, + // re-namespace exposed content custom properties + ...{ + "--radix-menubar-content-transform-origin": + "var(--radix-popper-transform-origin)", + "--radix-menubar-content-available-width": + "var(--radix-popper-available-width)", + "--radix-menubar-content-available-height": + "var(--radix-popper-available-height)", + "--radix-menubar-trigger-width": "var(--radix-popper-anchor-width)", + "--radix-menubar-trigger-height": + "var(--radix-popper-anchor-height)", + }, + }} + /> + ); + }, +); + +MenubarContent.displayName = CONTENT_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarGroup + * -----------------------------------------------------------------------------------------------*/ + +const GROUP_NAME = "MenubarGroup"; + +type MenubarGroupElement = React.ElementRef; +type MenuGroupProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Group +>; +interface MenubarGroupProps extends MenuGroupProps {} + +const MenubarGroup = React.forwardRef< + MenubarGroupElement, + MenubarGroupProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...groupProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarGroup.displayName = GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarLabel + * -----------------------------------------------------------------------------------------------*/ + +const LABEL_NAME = "MenubarLabel"; + +type MenubarLabelElement = React.ElementRef; +type MenuLabelProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Label +>; +interface MenubarLabelProps extends MenuLabelProps {} + +const MenubarLabel = React.forwardRef< + MenubarLabelElement, + MenubarLabelProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...labelProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarLabel.displayName = LABEL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarItem + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_NAME = "MenubarItem"; + +type MenubarItemElement = React.ElementRef; +type MenuItemProps = Radix.ComponentPropsWithoutRef; +interface MenubarItemProps extends MenuItemProps {} + +const MenubarItem = React.forwardRef< + MenubarItemElement, + MenubarItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...itemProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarItem.displayName = ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarCheckboxItem + * -----------------------------------------------------------------------------------------------*/ + +const CHECKBOX_ITEM_NAME = "MenubarCheckboxItem"; + +type MenubarCheckboxItemElement = React.ElementRef< + typeof MenuPrimitive.CheckboxItem +>; +type MenuCheckboxItemProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.CheckboxItem +>; +interface MenubarCheckboxItemProps extends MenuCheckboxItemProps {} + +const MenubarCheckboxItem = React.forwardRef< + MenubarCheckboxItemElement, + MenubarCheckboxItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...checkboxItemProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarCheckboxItem.displayName = CHECKBOX_ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarRadioGroup + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_GROUP_NAME = "MenubarRadioGroup"; + +type MenubarRadioGroupElement = React.ElementRef< + typeof MenuPrimitive.RadioGroup +>; +type MenuRadioGroupProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.RadioGroup +>; +interface MenubarRadioGroupProps extends MenuRadioGroupProps {} + +const MenubarRadioGroup = React.forwardRef< + MenubarRadioGroupElement, + MenubarRadioGroupProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...radioGroupProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarRadioGroup.displayName = RADIO_GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarRadioItem + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_ITEM_NAME = "MenubarRadioItem"; + +type MenubarRadioItemElement = React.ElementRef; +type MenuRadioItemProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.RadioItem +>; +interface MenubarRadioItemProps extends MenuRadioItemProps {} + +const MenubarRadioItem = React.forwardRef< + MenubarRadioItemElement, + MenubarRadioItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...radioItemProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarRadioItem.displayName = RADIO_ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarItemIndicator + * -----------------------------------------------------------------------------------------------*/ + +const INDICATOR_NAME = "MenubarItemIndicator"; + +type MenubarItemIndicatorElement = React.ElementRef< + typeof MenuPrimitive.ItemIndicator +>; +type MenuItemIndicatorProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.ItemIndicator +>; +interface MenubarItemIndicatorProps extends MenuItemIndicatorProps {} + +const MenubarItemIndicator = React.forwardRef< + MenubarItemIndicatorElement, + MenubarItemIndicatorProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...itemIndicatorProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); +}); + +MenubarItemIndicator.displayName = INDICATOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarSeparator + * -----------------------------------------------------------------------------------------------*/ + +const SEPARATOR_NAME = "MenubarSeparator"; + +type MenubarSeparatorElement = React.ElementRef; +type MenuSeparatorProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Separator +>; +interface MenubarSeparatorProps extends MenuSeparatorProps {} + +const MenubarSeparator = React.forwardRef< + MenubarSeparatorElement, + MenubarSeparatorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...separatorProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarSeparator.displayName = SEPARATOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarArrow + * -----------------------------------------------------------------------------------------------*/ + +const ARROW_NAME = "MenubarArrow"; + +type MenubarArrowElement = React.ElementRef; +type MenuArrowProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.Arrow +>; +interface MenubarArrowProps extends MenuArrowProps {} + +const MenubarArrow = React.forwardRef< + MenubarArrowElement, + MenubarArrowProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...arrowProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarArrow.displayName = ARROW_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarSub + * -----------------------------------------------------------------------------------------------*/ + +const SUB_NAME = "MenubarSub"; + +interface MenubarSubProps { + children?: React.ComponentChildren; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?(open: boolean): void; +} + +const MenubarSub: React.FC = ( + props: ScopedProps, +) => { + const { + __scopeMenubar, + children, + open: openProp, + onOpenChange, + defaultOpen, + } = props; + const menuScope = useMenuScope(__scopeMenubar); + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + return ( + + {children} + + ); +}; + +MenubarSub.displayName = SUB_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarSubTrigger + * -----------------------------------------------------------------------------------------------*/ + +const SUB_TRIGGER_NAME = "MenubarSubTrigger"; + +type MenubarSubTriggerElement = React.ElementRef< + typeof MenuPrimitive.SubTrigger +>; +type MenuSubTriggerProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.SubTrigger +>; +interface MenubarSubTriggerProps extends MenuSubTriggerProps {} + +const MenubarSubTrigger = React.forwardRef< + MenubarSubTriggerElement, + MenubarSubTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...subTriggerProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + return ( + + ); + }, +); + +MenubarSubTrigger.displayName = SUB_TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * MenubarSubContent + * -----------------------------------------------------------------------------------------------*/ + +const SUB_CONTENT_NAME = "MenubarSubContent"; + +type MenubarSubContentElement = React.ElementRef; +type MenuSubContentProps = Radix.ComponentPropsWithoutRef< + typeof MenuPrimitive.SubContent +>; +interface MenubarSubContentProps extends MenuSubContentProps {} + +const MenubarSubContent = React.forwardRef< + MenubarSubContentElement, + MenubarSubContentProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeMenubar, ...subContentProps } = props; + const menuScope = useMenuScope(__scopeMenubar); + + return ( + + ); + }, +); + +MenubarSubContent.displayName = SUB_CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +/** + * Wraps an array around itself at a given start index + * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']` + */ +function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length]); +} + +const Root = Menubar; +const Menu = MenubarMenu; +const Trigger = MenubarTrigger; +const Portal = MenubarPortal; +const Content = MenubarContent; +const Group = MenubarGroup; +const Label = MenubarLabel; +const Item = MenubarItem; +const CheckboxItem = MenubarCheckboxItem; +const RadioGroup = MenubarRadioGroup; +const RadioItem = MenubarRadioItem; +const ItemIndicator = MenubarItemIndicator; +const Separator = MenubarSeparator; +const Arrow = MenubarArrow; +const Sub = MenubarSub; +const SubTrigger = MenubarSubTrigger; +const SubContent = MenubarSubContent; + +export { + Arrow, + CheckboxItem, + Content, + createMenubarScope, + Group, + Item, + ItemIndicator, + Label, + Menu, + // + Menubar, + MenubarArrow, + MenubarCheckboxItem, + MenubarContent, + MenubarGroup, + MenubarItem, + MenubarItemIndicator, + MenubarLabel, + MenubarMenu, + MenubarPortal, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSeparator, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, + Portal, + RadioGroup, + RadioItem, + // + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, +}; +export type { + MenubarArrowProps, + MenubarCheckboxItemProps, + MenubarContentProps, + MenubarGroupProps, + MenubarItemIndicatorProps, + MenubarItemProps, + MenubarLabelProps, + MenubarMenuProps, + MenubarPortalProps, + MenubarProps, + MenubarRadioGroupProps, + MenubarRadioItemProps, + MenubarSeparatorProps, + MenubarSubContentProps, + MenubarSubProps, + MenubarSubTriggerProps, + MenubarTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/menubar/mod.ts b/pkg/radix-ui-primitives/preact/menubar/mod.ts new file mode 100644 index 0000000..a93bdc7 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/menubar/mod.ts @@ -0,0 +1,58 @@ +export { + Arrow, + CheckboxItem, + Content, + createMenubarScope, + Group, + Item, + ItemIndicator, + Label, + Menu, + // + Menubar, + MenubarArrow, + MenubarCheckboxItem, + MenubarContent, + MenubarGroup, + MenubarItem, + MenubarItemIndicator, + MenubarLabel, + MenubarMenu, + MenubarPortal, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSeparator, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, + Portal, + RadioGroup, + RadioItem, + // + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, +} from "./Menubar.tsx"; +export type { + MenubarArrowProps, + MenubarCheckboxItemProps, + MenubarContentProps, + MenubarGroupProps, + MenubarItemIndicatorProps, + MenubarItemProps, + MenubarLabelProps, + MenubarMenuProps, + MenubarPortalProps, + MenubarProps, + MenubarRadioGroupProps, + MenubarRadioItemProps, + MenubarSeparatorProps, + MenubarSubContentProps, + MenubarSubProps, + MenubarSubTriggerProps, + MenubarTriggerProps, +} from "./Menubar.tsx"; diff --git a/pkg/radix-ui-primitives/preact/navigation-menu/NavigationMenu.tsx b/pkg/radix-ui-primitives/preact/navigation-menu/NavigationMenu.tsx new file mode 100644 index 0000000..3026e15 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/navigation-menu/NavigationMenu.tsx @@ -0,0 +1,1491 @@ +/// + +import * as React from "preact/compat"; +import * as ReactDOM from "preact/compat"; +import { createContextScope } from "../context/mod.ts"; +import { composeEventHandlers } from "@radix-ui/primitive"; +import { dispatchDiscreteCustomEvent, Primitive } from "../primitive/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { composeRefs, useComposedRefs } from "../compose-refs/mod.ts"; +import { useDirection } from "../direction/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { useId } from "../id/mod.ts"; +import { createCollection } from "../collection/mod.ts"; +import { DismissableLayer } from "../dismissable-layer/mod.ts"; +import { usePrevious } from "../use-previous/mod.ts"; +import { useLayoutEffect } from "../use-layout-effect/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; +import * as VisuallyHiddenPrimitive from "../visually-hidden/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +type Orientation = "vertical" | "horizontal"; +type Direction = "ltr" | "rtl"; + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenu + * -----------------------------------------------------------------------------------------------*/ + +const NAVIGATION_MENU_NAME = "NavigationMenu"; + +const [Collection, useCollection, createCollectionScope] = createCollection< + NavigationMenuTriggerElement, + { value: string } +>(NAVIGATION_MENU_NAME); + +const [ + FocusGroupCollection, + useFocusGroupCollection, + createFocusGroupCollectionScope, +] = createCollection(NAVIGATION_MENU_NAME); + +type ScopedProps

= P & { __scopeNavigationMenu?: Scope }; +const [createNavigationMenuContext, createNavigationMenuScope] = + createContextScope( + NAVIGATION_MENU_NAME, + [createCollectionScope, createFocusGroupCollectionScope], + ); + +type ContentData = { + ref?: React.Ref; +} & ViewportContentMounterProps; + +type NavigationMenuContextValue = { + isRootMenu: boolean; + value: string; + previousValue: string; + baseId: string; + dir: Direction; + orientation: Orientation; + rootNavigationMenu: NavigationMenuElement | null; + indicatorTrack: HTMLDivElement | null; + onIndicatorTrackChange(indicatorTrack: HTMLDivElement | null): void; + viewport: NavigationMenuViewportElement | null; + onViewportChange(viewport: NavigationMenuViewportElement | null): void; + onViewportContentChange(contentValue: string, contentData: ContentData): void; + onViewportContentRemove(contentValue: string): void; + onTriggerEnter(itemValue: string): void; + onTriggerLeave(): void; + onContentEnter(): void; + onContentLeave(): void; + onItemSelect(itemValue: string): void; + onItemDismiss(): void; +}; + +const [NavigationMenuProviderImpl, useNavigationMenuContext] = + createNavigationMenuContext(NAVIGATION_MENU_NAME); + +const [ViewportContentProvider, useViewportContentContext] = + createNavigationMenuContext<{ + items: Map; + }>(NAVIGATION_MENU_NAME); + +type NavigationMenuElement = React.ElementRef; +type PrimitiveNavProps = Radix.ComponentPropsWithoutRef; +interface NavigationMenuProps + extends + Omit, + PrimitiveNavProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + dir?: Direction; + orientation?: Orientation; + /** + * The duration from when the pointer enters the trigger until the tooltip gets opened. + * @defaultValue 200 + */ + delayDuration?: number; + /** + * How much time a user has to enter another trigger without incurring a delay again. + * @defaultValue 300 + */ + skipDelayDuration?: number; +} + +const NavigationMenu = React.forwardRef< + NavigationMenuElement, + NavigationMenuProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopeNavigationMenu, + value: valueProp, + onValueChange, + defaultValue, + delayDuration = 200, + skipDelayDuration = 300, + orientation = "horizontal", + dir, + ...NavigationMenuProps + } = props; + const [navigationMenu, setNavigationMenu] = React.useState< + NavigationMenuElement | null + >(null); + const composedRef = useComposedRefs( + forwardedRef, + (node) => setNavigationMenu(node), + ); + const direction = useDirection(dir); + const openTimerRef = React.useRef(0); + const closeTimerRef = React.useRef(0); + const skipDelayTimerRef = React.useRef(0); + const [isOpenDelayed, setIsOpenDelayed] = React.useState(true); + const [value = "", setValue] = useControllableState({ + prop: valueProp, + onChange: (value) => { + const isOpen = value !== ""; + const hasSkipDelayDuration = skipDelayDuration > 0; + + if (isOpen) { + window.clearTimeout(skipDelayTimerRef.current); + if (hasSkipDelayDuration) setIsOpenDelayed(false); + } else { + window.clearTimeout(skipDelayTimerRef.current); + skipDelayTimerRef.current = window.setTimeout( + () => setIsOpenDelayed(true), + skipDelayDuration, + ); + } + + onValueChange?.(value); + }, + defaultProp: defaultValue, + }); + + const startCloseTimer = React.useCallback(() => { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = window.setTimeout(() => setValue(""), 150); + }, [setValue]); + + const handleOpen = React.useCallback( + (itemValue: string) => { + window.clearTimeout(closeTimerRef.current); + setValue(itemValue); + }, + [setValue], + ); + + const handleDelayedOpen = React.useCallback( + (itemValue: string) => { + const isOpenItem = value === itemValue; + if (isOpenItem) { + // If the item is already open (e.g. we're transitioning from the content to the trigger) + // then we want to clear the close timer immediately. + window.clearTimeout(closeTimerRef.current); + } else { + openTimerRef.current = window.setTimeout(() => { + window.clearTimeout(closeTimerRef.current); + setValue(itemValue); + }, delayDuration); + } + }, + [value, setValue, delayDuration], + ); + + React.useEffect(() => { + return () => { + window.clearTimeout(openTimerRef.current); + window.clearTimeout(closeTimerRef.current); + window.clearTimeout(skipDelayTimerRef.current); + }; + }, []); + + return ( + { + window.clearTimeout(openTimerRef.current); + if (isOpenDelayed) handleDelayedOpen(itemValue); + else handleOpen(itemValue); + }} + onTriggerLeave={() => { + window.clearTimeout(openTimerRef.current); + startCloseTimer(); + }} + onContentEnter={() => window.clearTimeout(closeTimerRef.current)} + onContentLeave={startCloseTimer} + onItemSelect={(itemValue) => { + setValue((prevValue) => (prevValue === itemValue ? "" : itemValue)); + }} + onItemDismiss={() => setValue("")} + > + + + ); + }, +); + +NavigationMenu.displayName = NAVIGATION_MENU_NAME; + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenuSub + * -----------------------------------------------------------------------------------------------*/ + +const SUB_NAME = "NavigationMenuSub"; + +type NavigationMenuSubElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface NavigationMenuSubProps + extends + Omit, + PrimitiveDivProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + orientation?: Orientation; +} + +const NavigationMenuSub = React.forwardRef< + NavigationMenuSubElement, + NavigationMenuSubProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopeNavigationMenu, + value: valueProp, + onValueChange, + defaultValue, + orientation = "horizontal", + ...subProps + } = props; + const context = useNavigationMenuContext(SUB_NAME, __scopeNavigationMenu); + const [value = "", setValue] = useControllableState({ + prop: valueProp, + onChange: onValueChange, + defaultProp: defaultValue, + }); + + return ( + setValue(itemValue)} + onItemSelect={(itemValue) => setValue(itemValue)} + onItemDismiss={() => setValue("")} + > + + + ); + }, +); + +NavigationMenuSub.displayName = SUB_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +interface NavigationMenuProviderPrivateProps { + isRootMenu: boolean; + scope: Scope; + children: React.ComponentChildren; + orientation: Orientation; + dir: Direction; + rootNavigationMenu: NavigationMenuElement | null; + value: string; + onTriggerEnter(itemValue: string): void; + onTriggerLeave?(): void; + onContentEnter?(): void; + onContentLeave?(): void; + onItemSelect(itemValue: string): void; + onItemDismiss(): void; +} + +interface NavigationMenuProviderProps + extends NavigationMenuProviderPrivateProps {} + +const NavigationMenuProvider: React.FC = ( + props: ScopedProps, +) => { + const { + scope, + isRootMenu, + rootNavigationMenu, + dir, + orientation, + children, + value, + onItemSelect, + onItemDismiss, + onTriggerEnter, + onTriggerLeave, + onContentEnter, + onContentLeave, + } = props; + const [viewport, setViewport] = React.useState< + NavigationMenuViewportElement | null + >(null); + const [viewportContent, setViewportContent] = React.useState< + Map + >(new Map()); + const [indicatorTrack, setIndicatorTrack] = React.useState< + HTMLDivElement | null + >(null); + + return ( + { + setViewportContent((prevContent) => { + prevContent.set(contentValue, contentData); + return new Map(prevContent); + }); + }, + [], + )} + onViewportContentRemove={React.useCallback((contentValue) => { + setViewportContent((prevContent) => { + if (!prevContent.has(contentValue)) return prevContent; + prevContent.delete(contentValue); + return new Map(prevContent); + }); + }, [])} + > + + + {children} + + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenuList + * -----------------------------------------------------------------------------------------------*/ + +const LIST_NAME = "NavigationMenuList"; + +type NavigationMenuListElement = React.ElementRef; +type PrimitiveUnorderedListProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.ul +>; +interface NavigationMenuListProps extends PrimitiveUnorderedListProps {} + +const NavigationMenuList = React.forwardRef< + NavigationMenuListElement, + NavigationMenuListProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeNavigationMenu, ...listProps } = props; + const context = useNavigationMenuContext(LIST_NAME, __scopeNavigationMenu); + + const list = ( + + ); + + return ( + + + {context.isRootMenu ? {list} : list} + + + ); + }, +); + +NavigationMenuList.displayName = LIST_NAME; + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenuItem + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_NAME = "NavigationMenuItem"; + +type FocusProxyElement = React.ElementRef; + +type NavigationMenuItemContextValue = { + value: string; + triggerRef: React.RefObject; + contentRef: React.RefObject; + focusProxyRef: React.RefObject; + wasEscapeCloseRef: React.MutableRefObject; + onEntryKeyDown(): void; + onFocusProxyEnter(side: "start" | "end"): void; + onRootContentClose(): void; + onContentFocusOutside(): void; +}; + +const [NavigationMenuItemContextProvider, useNavigationMenuItemContext] = + createNavigationMenuContext(ITEM_NAME); + +type NavigationMenuItemElement = React.ElementRef; +type PrimitiveListItemProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.li +>; +interface NavigationMenuItemProps extends PrimitiveListItemProps { + value?: string; +} + +const NavigationMenuItem = React.forwardRef< + NavigationMenuItemElement, + NavigationMenuItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeNavigationMenu, value: valueProp, ...itemProps } = props; + const autoValue = useId(); + // We need to provide an initial deterministic value as `useId` will return + // empty string on the first render and we don't want to match our internal "closed" value. + const value = valueProp || autoValue || "LEGACY_REACT_AUTO_VALUE"; + const contentRef = React.useRef(null); + const triggerRef = React.useRef(null); + const focusProxyRef = React.useRef(null); + const restoreContentTabOrderRef = React.useRef(() => {}); + const wasEscapeCloseRef = React.useRef(false); + + const handleContentEntry = React.useCallback((side = "start") => { + if (contentRef.current) { + restoreContentTabOrderRef.current(); + const candidates = getTabbableCandidates(contentRef.current); + if (candidates.length) { + focusFirst(side === "start" ? candidates : candidates.reverse()); + } + } + }, []); + + const handleContentExit = React.useCallback(() => { + if (contentRef.current) { + const candidates = getTabbableCandidates(contentRef.current); + if (candidates.length) { + restoreContentTabOrderRef.current = removeFromTabOrder(candidates); + } + } + }, []); + + return ( + + + + ); + }, +); + +NavigationMenuItem.displayName = ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenuTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "NavigationMenuTrigger"; + +type NavigationMenuTriggerElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface NavigationMenuTriggerProps extends PrimitiveButtonProps {} + +const NavigationMenuTrigger = React.forwardRef< + NavigationMenuTriggerElement, + NavigationMenuTriggerProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeNavigationMenu, disabled, ...triggerProps } = props; + const context = useNavigationMenuContext( + TRIGGER_NAME, + props.__scopeNavigationMenu, + ); + const itemContext = useNavigationMenuItemContext( + TRIGGER_NAME, + props.__scopeNavigationMenu, + ); + const ref = React.useRef(null); + const composedRefs = useComposedRefs( + ref, + itemContext.triggerRef, + forwardedRef, + ); + const triggerId = makeTriggerId(context.baseId, itemContext.value); + const contentId = makeContentId(context.baseId, itemContext.value); + const hasPointerMoveOpenedRef = React.useRef(false); + const wasClickCloseRef = React.useRef(false); + const open = itemContext.value === context.value; + + return ( + <> + + + { + wasClickCloseRef.current = false; + itemContext.wasEscapeCloseRef.current = false; + })} + onPointerMove={composeEventHandlers( + props.onPointerMove, + whenMouse(() => { + if ( + disabled || + wasClickCloseRef.current || + itemContext.wasEscapeCloseRef.current || + hasPointerMoveOpenedRef.current + ) { + return; + } + context.onTriggerEnter(itemContext.value); + hasPointerMoveOpenedRef.current = true; + }), + )} + onPointerLeave={composeEventHandlers( + props.onPointerLeave, + whenMouse(() => { + if (disabled) return; + context.onTriggerLeave(); + hasPointerMoveOpenedRef.current = false; + }), + )} + onClick={composeEventHandlers(props.onClick, () => { + context.onItemSelect(itemContext.value); + wasClickCloseRef.current = open; + })} + onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + const verticalEntryKey = context.dir === "rtl" + ? "ArrowLeft" + : "ArrowRight"; + const entryKey = + { horizontal: "ArrowDown", vertical: verticalEntryKey }[ + context.orientation + ]; + if (open && event.key === entryKey) { + itemContext.onEntryKeyDown(); + // Prevent FocusGroupItem from handling the event + event.preventDefault(); + } + })} + /> + + + + {/* Proxy tab order between trigger and content */} + {open && ( + <> + { + const content = itemContext.contentRef.current; + const prevFocusedElement = event.relatedTarget as + | HTMLElement + | null; + const wasTriggerFocused = prevFocusedElement === ref.current; + const wasFocusFromContent = content?.contains(prevFocusedElement); + + if (wasTriggerFocused || !wasFocusFromContent) { + itemContext.onFocusProxyEnter( + wasTriggerFocused ? "start" : "end", + ); + } + }} + /> + + {/* Restructure a11y tree to make content accessible to screen reader when using the viewport */} + {context.viewport && } + + )} + + ); +}); + +NavigationMenuTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenuLink + * -----------------------------------------------------------------------------------------------*/ + +const LINK_NAME = "NavigationMenuLink"; +const LINK_SELECT = "navigationMenu.linkSelect"; + +type NavigationMenuLinkElement = React.ElementRef; +type PrimitiveLinkProps = Radix.ComponentPropsWithoutRef; +interface NavigationMenuLinkProps extends Omit { + active?: boolean; + onSelect?: (event: Event) => void; +} + +const NavigationMenuLink = React.forwardRef< + NavigationMenuLinkElement, + NavigationMenuLinkProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeNavigationMenu, active, onSelect, ...linkProps } = props; + + return ( + + { + const target = event.target as HTMLElement; + const linkSelectEvent = new CustomEvent(LINK_SELECT, { + bubbles: true, + cancelable: true, + }); + target.addEventListener( + LINK_SELECT, + (event) => onSelect?.(event), + { once: true }, + ); + dispatchDiscreteCustomEvent(target, linkSelectEvent); + + if (!linkSelectEvent.defaultPrevented && !event.metaKey) { + const rootContentDismissEvent = new CustomEvent( + ROOT_CONTENT_DISMISS, + { + bubbles: true, + cancelable: true, + }, + ); + dispatchDiscreteCustomEvent(target, rootContentDismissEvent); + } + }, + { checkForDefaultPrevented: false }, + )} + /> + + ); + }, +); + +NavigationMenuLink.displayName = LINK_NAME; + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenuIndicator + * -----------------------------------------------------------------------------------------------*/ + +const INDICATOR_NAME = "NavigationMenuIndicator"; + +type NavigationMenuIndicatorElement = NavigationMenuIndicatorImplElement; +interface NavigationMenuIndicatorProps + extends NavigationMenuIndicatorImplProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const NavigationMenuIndicator = React.forwardRef< + NavigationMenuIndicatorElement, + NavigationMenuIndicatorProps +>((props: ScopedProps, forwardedRef) => { + const { forceMount, ...indicatorProps } = props; + const context = useNavigationMenuContext( + INDICATOR_NAME, + props.__scopeNavigationMenu, + ); + const isVisible = Boolean(context.value); + + return context.indicatorTrack + ? ReactDOM.createPortal( + + + , + context.indicatorTrack, + ) + : null; +}); + +NavigationMenuIndicator.displayName = INDICATOR_NAME; + +type NavigationMenuIndicatorImplElement = React.ElementRef< + typeof Primitive.div +>; +interface NavigationMenuIndicatorImplProps extends PrimitiveDivProps {} + +const NavigationMenuIndicatorImpl = React.forwardRef< + NavigationMenuIndicatorImplElement, + NavigationMenuIndicatorImplProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeNavigationMenu, ...indicatorProps } = props; + const context = useNavigationMenuContext( + INDICATOR_NAME, + __scopeNavigationMenu, + ); + const getItems = useCollection(__scopeNavigationMenu); + const [activeTrigger, setActiveTrigger] = React.useState< + NavigationMenuTriggerElement | null + >( + null, + ); + const [position, setPosition] = React.useState< + { size: number; offset: number } | null + >(null); + const isHorizontal = context.orientation === "horizontal"; + const isVisible = Boolean(context.value); + + React.useEffect(() => { + const items = getItems(); + const triggerNode = items.find((item) => item.value === context.value)?.ref + .current; + if (triggerNode) setActiveTrigger(triggerNode); + }, [getItems, context.value]); + + /** + * Update position when the indicator or parent track size changes + */ + const handlePositionChange = () => { + if (activeTrigger) { + setPosition({ + size: isHorizontal + ? activeTrigger.offsetWidth + : activeTrigger.offsetHeight, + offset: isHorizontal + ? activeTrigger.offsetLeft + : activeTrigger.offsetTop, + }); + } + }; + useResizeObserver(activeTrigger, handlePositionChange); + useResizeObserver(context.indicatorTrack, handlePositionChange); + + // We need to wait for the indicator position to be available before rendering to + // snap immediately into position rather than transitioning from initial + return position + ? ( + + ) + : null; +}); + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenuContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "NavigationMenuContent"; + +type NavigationMenuContentElement = NavigationMenuContentImplElement; +interface NavigationMenuContentProps extends + Omit< + NavigationMenuContentImplProps, + keyof NavigationMenuContentImplPrivateProps + > { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const NavigationMenuContent = React.forwardRef< + NavigationMenuContentElement, + NavigationMenuContentProps +>((props: ScopedProps, forwardedRef) => { + const { forceMount, ...contentProps } = props; + const context = useNavigationMenuContext( + CONTENT_NAME, + props.__scopeNavigationMenu, + ); + const itemContext = useNavigationMenuItemContext( + CONTENT_NAME, + props.__scopeNavigationMenu, + ); + const composedRefs = useComposedRefs(itemContext.contentRef, forwardedRef); + const open = itemContext.value === context.value; + + const commonProps = { + value: itemContext.value, + triggerRef: itemContext.triggerRef, + focusProxyRef: itemContext.focusProxyRef, + wasEscapeCloseRef: itemContext.wasEscapeCloseRef, + onContentFocusOutside: itemContext.onContentFocusOutside, + onRootContentClose: itemContext.onRootContentClose, + ...contentProps, + }; + + return !context.viewport + ? ( + + + + ) + : ( + + ); +}); + +NavigationMenuContent.displayName = CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +type ViewportContentMounterElement = NavigationMenuContentImplElement; +interface ViewportContentMounterProps extends NavigationMenuContentImplProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const ViewportContentMounter = React.forwardRef< + ViewportContentMounterElement, + ViewportContentMounterProps +>((props: ScopedProps, forwardedRef) => { + const context = useNavigationMenuContext( + CONTENT_NAME, + props.__scopeNavigationMenu, + ); + const { onViewportContentChange, onViewportContentRemove } = context; + + useLayoutEffect(() => { + onViewportContentChange(props.value, { + ref: forwardedRef, + ...props, + }); + }, [props, forwardedRef, onViewportContentChange]); + + useLayoutEffect(() => { + return () => onViewportContentRemove(props.value); + }, [props.value, onViewportContentRemove]); + + // Content is proxied into the viewport + return null; +}); + +/* -----------------------------------------------------------------------------------------------*/ + +const ROOT_CONTENT_DISMISS = "navigationMenu.rootContentDismiss"; + +type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end"; +type NavigationMenuContentImplElement = React.ElementRef< + typeof DismissableLayer +>; +type DismissableLayerProps = Radix.ComponentPropsWithoutRef< + typeof DismissableLayer +>; + +interface NavigationMenuContentImplPrivateProps { + value: string; + triggerRef: React.RefObject; + focusProxyRef: React.RefObject; + wasEscapeCloseRef: React.MutableRefObject; + onContentFocusOutside(): void; + onRootContentClose(): void; +} +interface NavigationMenuContentImplProps + extends + Omit, + NavigationMenuContentImplPrivateProps {} + +const NavigationMenuContentImpl = React.forwardRef< + NavigationMenuContentImplElement, + NavigationMenuContentImplProps +>((props: ScopedProps, forwardedRef) => { + const { + __scopeNavigationMenu, + value, + triggerRef, + focusProxyRef, + wasEscapeCloseRef, + onRootContentClose, + onContentFocusOutside, + ...contentProps + } = props; + const context = useNavigationMenuContext(CONTENT_NAME, __scopeNavigationMenu); + const ref = React.useRef(null); + const composedRefs = useComposedRefs(ref, forwardedRef); + const triggerId = makeTriggerId(context.baseId, value); + const contentId = makeContentId(context.baseId, value); + const getItems = useCollection(__scopeNavigationMenu); + const prevMotionAttributeRef = React.useRef( + null, + ); + + const { onItemDismiss } = context; + + React.useEffect(() => { + const content = ref.current; + + // Bubble dismiss to the root content node and focus its trigger + if (context.isRootMenu && content) { + const handleClose = () => { + onItemDismiss(); + onRootContentClose(); + if (content.contains(document.activeElement)) { + triggerRef.current?.focus(); + } + }; + content.addEventListener(ROOT_CONTENT_DISMISS, handleClose); + return () => + content.removeEventListener(ROOT_CONTENT_DISMISS, handleClose); + } + }, [ + context.isRootMenu, + props.value, + triggerRef, + onItemDismiss, + onRootContentClose, + ]); + + const motionAttribute = React.useMemo(() => { + const items = getItems(); + const values = items.map((item) => item.value); + if (context.dir === "rtl") values.reverse(); + const index = values.indexOf(context.value); + const prevIndex = values.indexOf(context.previousValue); + const isSelected = value === context.value; + const wasSelected = prevIndex === values.indexOf(value); + + // We only want to update selected and the last selected content + // this avoids animations being interrupted outside of that range + if (!isSelected && !wasSelected) return prevMotionAttributeRef.current; + + const attribute = (() => { + // Don't provide a direction on the initial open + if (index !== prevIndex) { + // If we're moving to this item from another + if (isSelected && prevIndex !== -1) { + return index > prevIndex ? "from-end" : "from-start"; + } + // If we're leaving this item for another + if (wasSelected && index !== -1) { + return index > prevIndex ? "to-start" : "to-end"; + } + } + // Otherwise we're entering from closed or leaving the list + // entirely and should not animate in any direction + return null; + })(); + + prevMotionAttributeRef.current = attribute; + return attribute; + }, [context.previousValue, context.value, context.dir, getItems, value]); + + return ( + + { + const rootContentDismissEvent = new Event(ROOT_CONTENT_DISMISS, { + bubbles: true, + cancelable: true, + }); + ref.current?.dispatchEvent(rootContentDismissEvent); + }} + onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => { + onContentFocusOutside(); + const target = event.target as HTMLElement; + // Only dismiss content when focus moves outside of the menu + if (context.rootNavigationMenu?.contains(target)) { + event.preventDefault(); + } + })} + onPointerDownOutside={composeEventHandlers( + props.onPointerDownOutside, + (event) => { + const target = event.target as HTMLElement; + const isTrigger = getItems().some((item) => + item.ref.current?.contains(target) + ); + const isRootViewport = context.isRootMenu && + context.viewport?.contains(target); + if (isTrigger || isRootViewport || !context.isRootMenu) { + event.preventDefault(); + } + }, + )} + onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + const isMetaKey = event.altKey || event.ctrlKey || event.metaKey; + const isTabKey = event.key === "Tab" && !isMetaKey; + if (isTabKey) { + const candidates = getTabbableCandidates(event.currentTarget); + const focusedElement = document.activeElement; + const index = candidates.findIndex((candidate) => + candidate === focusedElement + ); + const isMovingBackwards = event.shiftKey; + const nextCandidates = isMovingBackwards + ? candidates.slice(0, index).reverse() + : candidates.slice(index + 1, candidates.length); + + if (focusFirst(nextCandidates)) { + // prevent browser tab keydown because we've handled focus + event.preventDefault(); + } else { + // If we can't focus that means we're at the edges + // so focus the proxy and let browser handle + // tab/shift+tab keypress on the proxy instead + focusProxyRef.current?.focus(); + } + } + })} + onEscapeKeyDown={composeEventHandlers( + props.onEscapeKeyDown, + (event) => { + // prevent the dropdown from reopening + // after the escape key has been pressed + wasEscapeCloseRef.current = true; + }, + )} + /> + + ); +}); + +/* ------------------------------------------------------------------------------------------------- + * NavigationMenuViewport + * -----------------------------------------------------------------------------------------------*/ + +const VIEWPORT_NAME = "NavigationMenuViewport"; + +type NavigationMenuViewportElement = NavigationMenuViewportImplElement; +interface NavigationMenuViewportProps + extends + Omit { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const NavigationMenuViewport = React.forwardRef< + NavigationMenuViewportElement, + NavigationMenuViewportProps +>((props: ScopedProps, forwardedRef) => { + const { forceMount, ...viewportProps } = props; + const context = useNavigationMenuContext( + VIEWPORT_NAME, + props.__scopeNavigationMenu, + ); + const open = Boolean(context.value); + + return ( + + + + ); +}); + +NavigationMenuViewport.displayName = VIEWPORT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +type NavigationMenuViewportImplElement = React.ElementRef; +interface NavigationMenuViewportImplProps extends PrimitiveDivProps {} + +const NavigationMenuViewportImpl = React.forwardRef< + NavigationMenuViewportImplElement, + NavigationMenuViewportImplProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeNavigationMenu, children, ...viewportImplProps } = props; + const context = useNavigationMenuContext( + VIEWPORT_NAME, + __scopeNavigationMenu, + ); + const composedRefs = useComposedRefs(forwardedRef, context.onViewportChange); + const viewportContentContext = useViewportContentContext( + CONTENT_NAME, + props.__scopeNavigationMenu, + ); + const [size, setSize] = React.useState< + { width: number; height: number } | null + >(null); + const [content, setContent] = React.useState< + NavigationMenuContentElement | null + >(null); + const viewportWidth = size ? size?.width + "px" : undefined; + const viewportHeight = size ? size?.height + "px" : undefined; + const open = Boolean(context.value); + // We persist the last active content value as the viewport may be animating out + // and we want the content to remain mounted for the lifecycle of the viewport. + const activeContentValue = open ? context.value : context.previousValue; + + /** + * Update viewport size to match the active content node. + * We prefer offset dimensions over `getBoundingClientRect` as the latter respects CSS transform. + * For example, if content animates in from `scale(0.5)` the dimensions would be anything + * from `0.5` to `1` of the intended size. + */ + const handleSizeChange = () => { + if (content) { + setSize({ width: content.offsetWidth, height: content.offsetHeight }); + } + }; + useResizeObserver(content, handleSizeChange); + + return ( + + {Array.from(viewportContentContext.items).map( + ([value, { ref, forceMount, ...props }]) => { + const isActive = activeContentValue === value; + return ( + + { + // We only want to update the stored node when another is available + // as we need to smoothly transition between them. + if (isActive && node) setContent(node); + })} + /> + + ); + }, + )} + + ); +}); + +/* -----------------------------------------------------------------------------------------------*/ + +const FOCUS_GROUP_NAME = "FocusGroup"; + +type FocusGroupElement = React.ElementRef; +interface FocusGroupProps extends PrimitiveDivProps {} + +const FocusGroup = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeNavigationMenu, ...groupProps } = props; + const context = useNavigationMenuContext( + FOCUS_GROUP_NAME, + __scopeNavigationMenu, + ); + + return ( + + + + + + ); + }, +); + +/* -----------------------------------------------------------------------------------------------*/ + +const ARROW_KEYS = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"]; +const FOCUS_GROUP_ITEM_NAME = "FocusGroupItem"; + +type FocusGroupItemElement = React.ElementRef; +interface FocusGroupItemProps extends PrimitiveButtonProps {} + +const FocusGroupItem = React.forwardRef< + FocusGroupItemElement, + FocusGroupItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeNavigationMenu, ...groupProps } = props; + const getItems = useFocusGroupCollection(__scopeNavigationMenu); + const context = useNavigationMenuContext( + FOCUS_GROUP_ITEM_NAME, + __scopeNavigationMenu, + ); + + return ( + + { + const isFocusNavigationKey = ["Home", "End", ...ARROW_KEYS] + .includes(event.key); + if (isFocusNavigationKey) { + let candidateNodes = getItems().map((item) => item.ref.current!); + const prevItemKey = context.dir === "rtl" + ? "ArrowRight" + : "ArrowLeft"; + const prevKeys = [prevItemKey, "ArrowUp", "End"]; + if (prevKeys.includes(event.key)) candidateNodes.reverse(); + if (ARROW_KEYS.includes(event.key)) { + const currentIndex = candidateNodes.indexOf( + event.currentTarget, + ); + candidateNodes = candidateNodes.slice(currentIndex + 1); + } + /** + * Imperative focus during keydown is risky so we prevent React's batching updates + * to avoid potential bugs. See: https://github.com/facebook/react/issues/20332 + */ + setTimeout(() => focusFirst(candidateNodes)); + + // Prevent page scroll while navigating + event.preventDefault(); + } + })} + /> + + ); + }, +); + +/** + * Returns a list of potential tabbable candidates. + * + * NOTE: This is only a close approximation. For example it doesn't take into account cases like when + * elements are not visible. This cannot be worked out easily by just reading a property, but rather + * necessitate runtime knowledge (computed styles, etc). We deal with these cases separately. + * + * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker + * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1 + */ +function getTabbableCandidates(container: HTMLElement) { + const nodes: HTMLElement[] = []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node: any) => { + const isHiddenInput = node.tagName === "INPUT" && node.type === "hidden"; + if (node.disabled || node.hidden || isHiddenInput) { + return NodeFilter.FILTER_SKIP; + } + // `.tabIndex` is not the same as the `tabindex` attribute. It works on the + // runtime's understanding of tabbability, so this automatically accounts + // for any kind of element that could be tabbed to. + return node.tabIndex >= 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP; + }, + }); + while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement); + // we do not take into account the order of nodes with positive `tabIndex` as it + // hinders accessibility to have tab order different from visual order. + return nodes; +} + +function focusFirst(candidates: HTMLElement[]) { + const previouslyFocusedElement = document.activeElement; + return candidates.some((candidate) => { + // if focus is already where we want to go, we don't want to keep going through the candidates + if (candidate === previouslyFocusedElement) return true; + candidate.focus(); + return document.activeElement !== previouslyFocusedElement; + }); +} + +function removeFromTabOrder(candidates: HTMLElement[]) { + candidates.forEach((candidate) => { + candidate.dataset.tabindex = candidate.getAttribute("tabindex") || ""; + candidate.setAttribute("tabindex", "-1"); + }); + return () => { + candidates.forEach((candidate) => { + const prevTabIndex = candidate.dataset.tabindex as string; + candidate.setAttribute("tabindex", prevTabIndex); + }); + }; +} + +function useResizeObserver(element: HTMLElement | null, onResize: () => void) { + const handleResize = useCallbackRef(onResize); + useLayoutEffect(() => { + let rAF = 0; + if (element) { + /** + * Resize Observer will throw an often benign error that says `ResizeObserver loop + * completed with undelivered notifications`. This means that ResizeObserver was not + * able to deliver all observations within a single animation frame, so we use + * `requestAnimationFrame` to ensure we don't deliver unnecessary observations. + * Further reading: https://github.com/WICG/resize-observer/issues/38 + */ + const resizeObserver = new ResizeObserver(() => { + cancelAnimationFrame(rAF); + rAF = window.requestAnimationFrame(handleResize); + }); + resizeObserver.observe(element); + return () => { + window.cancelAnimationFrame(rAF); + resizeObserver.unobserve(element); + }; + } + }, [element, handleResize]); +} + +function getOpenState(open: boolean) { + return open ? "open" : "closed"; +} + +function makeTriggerId(baseId: string, value: string) { + return `${baseId}-trigger-${value}`; +} + +function makeContentId(baseId: string, value: string) { + return `${baseId}-content-${value}`; +} + +function whenMouse( + handler: React.PointerEventHandler, +): React.PointerEventHandler { + return ( + event, + ) => (event.pointerType === "mouse" ? handler(event) : undefined); +} + +/* -----------------------------------------------------------------------------------------------*/ + +const Root = NavigationMenu; +const Sub = NavigationMenuSub; +const List = NavigationMenuList; +const Item = NavigationMenuItem; +const Trigger = NavigationMenuTrigger; +const Link = NavigationMenuLink; +const Indicator = NavigationMenuIndicator; +const Content = NavigationMenuContent; +const Viewport = NavigationMenuViewport; + +export { + Content, + createNavigationMenuScope, + Indicator, + Item, + Link, + List, + // + NavigationMenu, + NavigationMenuContent, + NavigationMenuIndicator, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuSub, + NavigationMenuTrigger, + NavigationMenuViewport, + // + Root, + Sub, + Trigger, + Viewport, +}; +export type { + NavigationMenuContentProps, + NavigationMenuIndicatorProps, + NavigationMenuItemProps, + NavigationMenuLinkProps, + NavigationMenuListProps, + NavigationMenuProps, + NavigationMenuSubProps, + NavigationMenuTriggerProps, + NavigationMenuViewportProps, +}; diff --git a/pkg/radix-ui-primitives/preact/navigation-menu/mod.ts b/pkg/radix-ui-primitives/preact/navigation-menu/mod.ts new file mode 100644 index 0000000..a1f84fb --- /dev/null +++ b/pkg/radix-ui-primitives/preact/navigation-menu/mod.ts @@ -0,0 +1,34 @@ +export { + Content, + createNavigationMenuScope, + Indicator, + Item, + Link, + List, + // + NavigationMenu, + NavigationMenuContent, + NavigationMenuIndicator, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuSub, + NavigationMenuTrigger, + NavigationMenuViewport, + // + Root, + Sub, + Trigger, + Viewport, +} from "./NavigationMenu.tsx"; +export type { + NavigationMenuContentProps, + NavigationMenuIndicatorProps, + NavigationMenuItemProps, + NavigationMenuLinkProps, + NavigationMenuListProps, + NavigationMenuProps, + NavigationMenuSubProps, + NavigationMenuTriggerProps, + NavigationMenuViewportProps, +} from "./NavigationMenu.tsx"; diff --git a/pkg/radix-ui-primitives/preact/popover/Popover.tsx b/pkg/radix-ui-primitives/preact/popover/Popover.tsx new file mode 100644 index 0000000..fd5a103 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/popover/Popover.tsx @@ -0,0 +1,620 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "@radix-ui/primitive"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { DismissableLayer } from "../dismissable-layer/mod.ts"; +import { useFocusGuards } from "../focus-guards/mod.ts"; +import { FocusScope } from "../focus-scope/mod.ts"; +import { useId } from "../id/mod.ts"; +import * as PopperPrimitive from "../popper/mod.ts"; +import { createPopperScope } from "../popper/mod.ts"; +import { Portal as PortalPrimitive } from "../portal/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { Slot } from "../slot/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { hideOthers } from "aria-hidden"; +import { RemoveScroll } from "react-remove-scroll"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Popover + * -----------------------------------------------------------------------------------------------*/ + +const POPOVER_NAME = "Popover"; + +type ScopedProps

= P & { __scopePopover?: Scope }; +const [createPopoverContext, createPopoverScope] = createContextScope( + POPOVER_NAME, + [ + createPopperScope, + ], +); +const usePopperScope = createPopperScope(); + +type PopoverContextValue = { + triggerRef: React.RefObject; + contentId: string; + open: boolean; + onOpenChange(open: boolean): void; + onOpenToggle(): void; + hasCustomAnchor: boolean; + onCustomAnchorAdd(): void; + onCustomAnchorRemove(): void; + modal: boolean; +}; + +const [PopoverProvider, usePopoverContext] = createPopoverContext< + PopoverContextValue +>(POPOVER_NAME); + +interface PopoverProps { + children?: React.ComponentChildren; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + modal?: boolean; +} + +const Popover: React.FC = ( + props: ScopedProps, +) => { + const { + __scopePopover, + children, + open: openProp, + defaultOpen, + onOpenChange, + modal = false, + } = props; + const popperScope = usePopperScope(__scopePopover); + const triggerRef = React.useRef(null); + const [hasCustomAnchor, setHasCustomAnchor] = React.useState(false); + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + return ( + + setOpen((prevOpen) => !prevOpen), + [setOpen], + )} + hasCustomAnchor={hasCustomAnchor} + onCustomAnchorAdd={React.useCallback( + () => setHasCustomAnchor(true), + [], + )} + onCustomAnchorRemove={React.useCallback( + () => setHasCustomAnchor(false), + [], + )} + modal={modal} + > + {children} + + + ); +}; + +Popover.displayName = POPOVER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * PopoverAnchor + * -----------------------------------------------------------------------------------------------*/ + +const ANCHOR_NAME = "PopoverAnchor"; + +type PopoverAnchorElement = React.ElementRef; +type PopperAnchorProps = Radix.ComponentPropsWithoutRef< + typeof PopperPrimitive.Anchor +>; +interface PopoverAnchorProps extends PopperAnchorProps {} + +const PopoverAnchor = React.forwardRef< + PopoverAnchorElement, + PopoverAnchorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopePopover, ...anchorProps } = props; + const context = usePopoverContext(ANCHOR_NAME, __scopePopover); + const popperScope = usePopperScope(__scopePopover); + const { onCustomAnchorAdd, onCustomAnchorRemove } = context; + + React.useEffect(() => { + onCustomAnchorAdd(); + return () => onCustomAnchorRemove(); + }, [onCustomAnchorAdd, onCustomAnchorRemove]); + + return ( + + ); + }, +); + +PopoverAnchor.displayName = ANCHOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * PopoverTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "PopoverTrigger"; + +type PopoverTriggerElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface PopoverTriggerProps extends PrimitiveButtonProps {} + +const PopoverTrigger = React.forwardRef< + PopoverTriggerElement, + PopoverTriggerProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopePopover, ...triggerProps } = props; + const context = usePopoverContext(TRIGGER_NAME, __scopePopover); + const popperScope = usePopperScope(__scopePopover); + const composedTriggerRef = useComposedRefs( + forwardedRef, + context.triggerRef, + ); + + const trigger = ( + + ); + + return context.hasCustomAnchor + ? trigger + : ( + + {trigger} + + ); + }, +); + +PopoverTrigger.displayName = TRIGGER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * PopoverPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "PopoverPortal"; + +type PortalContextValue = { forceMount?: true }; +const [PortalProvider, usePortalContext] = createPopoverContext< + PortalContextValue +>(PORTAL_NAME, { + forceMount: undefined, +}); + +type PortalProps = Radix.ComponentPropsWithoutRef; +interface PopoverPortalProps { + children?: React.ComponentChildren; + /** + * Specify a container element to portal the content into. + */ + container?: PortalProps["container"]; + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const PopoverPortal: React.FC = ( + props: ScopedProps, +) => { + const { __scopePopover, forceMount, children, container } = props; + const context = usePopoverContext(PORTAL_NAME, __scopePopover); + return ( + + + + {children} + + + + ); +}; + +PopoverPortal.displayName = PORTAL_NAME; + +/* ------------------------------------------------------------------------------------------------- + * PopoverContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "PopoverContent"; + +interface PopoverContentProps extends PopoverContentTypeProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const PopoverContent = React.forwardRef< + PopoverContentTypeElement, + PopoverContentProps +>( + (props: ScopedProps, forwardedRef) => { + const portalContext = usePortalContext(CONTENT_NAME, props.__scopePopover); + const { forceMount = portalContext.forceMount, ...contentProps } = props; + const context = usePopoverContext(CONTENT_NAME, props.__scopePopover); + return ( + + {context.modal + ? + : } + + ); + }, +); + +PopoverContent.displayName = CONTENT_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +type PopoverContentTypeElement = PopoverContentImplElement; +interface PopoverContentTypeProps extends + Omit< + PopoverContentImplProps, + "trapFocus" | "disableOutsidePointerEvents" + > {} + +const PopoverContentModal = React.forwardRef< + PopoverContentTypeElement, + PopoverContentTypeProps +>( + (props: ScopedProps, forwardedRef) => { + const context = usePopoverContext(CONTENT_NAME, props.__scopePopover); + const contentRef = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, contentRef); + const isRightClickOutsideRef = React.useRef(false); + + // aria-hide everything except the content (better supported equivalent to setting aria-modal) + React.useEffect(() => { + const content = contentRef.current; + if (content) return hideOthers(content); + }, []); + + return ( + + { + event.preventDefault(); + if (!isRightClickOutsideRef.current) { + context.triggerRef.current + ?.focus(); + } + }, + )} + onPointerDownOutside={composeEventHandlers( + props.onPointerDownOutside, + (event) => { + const originalEvent = event.detail.originalEvent; + const ctrlLeftClick = originalEvent.button === 0 && + originalEvent.ctrlKey === true; + const isRightClick = originalEvent.button === 2 || ctrlLeftClick; + + isRightClickOutsideRef.current = isRightClick; + }, + { checkForDefaultPrevented: false }, + )} + // When focus is trapped, a `focusout` event may still happen. + // We make sure we don't trigger our `onDismiss` in such case. + onFocusOutside={composeEventHandlers( + props.onFocusOutside, + (event) => event.preventDefault(), + { checkForDefaultPrevented: false }, + )} + /> + + ); + }, +); + +const PopoverContentNonModal = React.forwardRef< + PopoverContentTypeElement, + PopoverContentTypeProps +>( + (props: ScopedProps, forwardedRef) => { + const context = usePopoverContext(CONTENT_NAME, props.__scopePopover); + const hasInteractedOutsideRef = React.useRef(false); + const hasPointerDownOutsideRef = React.useRef(false); + + return ( + { + props.onCloseAutoFocus?.(event); + + if (!event.defaultPrevented) { + if (!hasInteractedOutsideRef.current) { + context.triggerRef.current + ?.focus(); + } + // Always prevent auto focus because we either focus manually or want user agent focus + event.preventDefault(); + } + + hasInteractedOutsideRef.current = false; + hasPointerDownOutsideRef.current = false; + }} + onInteractOutside={(event) => { + props.onInteractOutside?.(event); + + if (!event.defaultPrevented) { + hasInteractedOutsideRef.current = true; + if (event.detail.originalEvent.type === "pointerdown") { + hasPointerDownOutsideRef.current = true; + } + } + + // Prevent dismissing when clicking the trigger. + // As the trigger is already setup to close, without doing so would + // cause it to close and immediately open. + const target = event.target as HTMLElement; + const targetIsTrigger = context.triggerRef.current?.contains(target); + if (targetIsTrigger) event.preventDefault(); + + // On Safari if the trigger is inside a container with tabIndex={0}, when clicked + // we will get the pointer down outside event on the trigger, but then a subsequent + // focus outside event on the container, we ignore any focus outside event when we've + // already had a pointer down outside event. + if ( + event.detail.originalEvent.type === "focusin" && + hasPointerDownOutsideRef.current + ) { + event.preventDefault(); + } + }} + /> + ); + }, +); + +/* -----------------------------------------------------------------------------------------------*/ + +type PopoverContentImplElement = React.ElementRef< + typeof PopperPrimitive.Content +>; +type FocusScopeProps = Radix.ComponentPropsWithoutRef; +type DismissableLayerProps = Radix.ComponentPropsWithoutRef< + typeof DismissableLayer +>; +type PopperContentProps = Radix.ComponentPropsWithoutRef< + typeof PopperPrimitive.Content +>; +interface PopoverContentImplProps + extends + Omit, + Omit { + /** + * Whether focus should be trapped within the `Popover` + * (default: false) + */ + trapFocus?: FocusScopeProps["trapped"]; + + /** + * Event handler called when auto-focusing on open. + * Can be prevented. + */ + onOpenAutoFocus?: FocusScopeProps["onMountAutoFocus"]; + + /** + * Event handler called when auto-focusing on close. + * Can be prevented. + */ + onCloseAutoFocus?: FocusScopeProps["onUnmountAutoFocus"]; +} + +const PopoverContentImpl = React.forwardRef< + PopoverContentImplElement, + PopoverContentImplProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopePopover, + trapFocus, + onOpenAutoFocus, + onCloseAutoFocus, + disableOutsidePointerEvents, + onEscapeKeyDown, + onPointerDownOutside, + onFocusOutside, + onInteractOutside, + ...contentProps + } = props; + const context = usePopoverContext(CONTENT_NAME, __scopePopover); + const popperScope = usePopperScope(__scopePopover); + + // Make sure the whole tree has focus guards as our `Popover` may be + // the last element in the DOM (beacuse of the `Portal`) + useFocusGuards(); + + return ( + + context.onOpenChange(false)} + > + + + + ); + }, +); + +/* ------------------------------------------------------------------------------------------------- + * PopoverClose + * -----------------------------------------------------------------------------------------------*/ + +const CLOSE_NAME = "PopoverClose"; + +type PopoverCloseElement = React.ElementRef; +interface PopoverCloseProps extends PrimitiveButtonProps {} + +const PopoverClose = React.forwardRef< + PopoverCloseElement, + PopoverCloseProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopePopover, ...closeProps } = props; + const context = usePopoverContext(CLOSE_NAME, __scopePopover); + return ( + + context.onOpenChange(false))} + /> + ); + }, +); + +PopoverClose.displayName = CLOSE_NAME; + +/* ------------------------------------------------------------------------------------------------- + * PopoverArrow + * -----------------------------------------------------------------------------------------------*/ + +const ARROW_NAME = "PopoverArrow"; + +type PopoverArrowElement = React.ElementRef; +type PopperArrowProps = Radix.ComponentPropsWithoutRef< + typeof PopperPrimitive.Arrow +>; +interface PopoverArrowProps extends PopperArrowProps {} + +const PopoverArrow = React.forwardRef< + PopoverArrowElement, + PopoverArrowProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopePopover, ...arrowProps } = props; + const popperScope = usePopperScope(__scopePopover); + return ( + + ); + }, +); + +PopoverArrow.displayName = ARROW_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +function getState(open: boolean) { + return open ? "open" : "closed"; +} + +const Root = Popover; +const Anchor = PopoverAnchor; +const Trigger = PopoverTrigger; +const Portal = PopoverPortal; +const Content = PopoverContent; +const Close = PopoverClose; +const Arrow = PopoverArrow; + +export { + Anchor, + Arrow, + Close, + Content, + createPopoverScope, + // + Popover, + PopoverAnchor, + PopoverArrow, + PopoverClose, + PopoverContent, + PopoverPortal, + PopoverTrigger, + Portal, + // + Root, + Trigger, +}; +export type { + PopoverAnchorProps, + PopoverArrowProps, + PopoverCloseProps, + PopoverContentProps, + PopoverPortalProps, + PopoverProps, + PopoverTriggerProps, +}; diff --git a/pkg/radix-ui-primitives/preact/popover/mod.ts b/pkg/radix-ui-primitives/preact/popover/mod.ts new file mode 100644 index 0000000..fc9e708 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/popover/mod.ts @@ -0,0 +1,28 @@ +export { + Anchor, + Arrow, + Close, + Content, + createPopoverScope, + // + Popover, + PopoverAnchor, + PopoverArrow, + PopoverClose, + PopoverContent, + PopoverPortal, + PopoverTrigger, + Portal, + // + Root, + Trigger, +} from "./Popover.tsx"; +export type { + PopoverAnchorProps, + PopoverArrowProps, + PopoverCloseProps, + PopoverContentProps, + PopoverPortalProps, + PopoverProps, + PopoverTriggerProps, +} from "./Popover.tsx"; diff --git a/pkg/radix-ui-primitives/preact/popper/Popper.tsx b/pkg/radix-ui-primitives/preact/popper/Popper.tsx new file mode 100644 index 0000000..06ac918 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/popper/Popper.tsx @@ -0,0 +1,476 @@ +import * as React from "preact/compat"; +import { + arrow as floatingUIarrow, + autoUpdate, + flip, + hide, + limitShift, + offset, + shift, + size, + useFloating, +} from "@floating-ui/react-dom"; +import * as ArrowPrimitive from "../arrow/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; +import { useLayoutEffect } from "../use-layout-effect/mod.ts"; +import { useSize } from "../use-size/mod.ts"; + +import type { Middleware, Placement } from "@floating-ui/react-dom"; +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; +import type { Measurable } from "../../core/rect/mod.ts"; + +const SIDE_OPTIONS = ["top", "right", "bottom", "left"] as const; +const ALIGN_OPTIONS = ["start", "center", "end"] as const; + +type Side = typeof SIDE_OPTIONS[number]; +type Align = typeof ALIGN_OPTIONS[number]; + +/* ------------------------------------------------------------------------------------------------- + * Popper + * -----------------------------------------------------------------------------------------------*/ + +const POPPER_NAME = "Popper"; + +type ScopedProps

= P & { __scopePopper?: Scope }; +const [createPopperContext, createPopperScope] = createContextScope( + POPPER_NAME, +); + +type PopperContextValue = { + anchor: Measurable | null; + onAnchorChange(anchor: Measurable | null): void; +}; +const [PopperProvider, usePopperContext] = createPopperContext< + PopperContextValue +>(POPPER_NAME); + +interface PopperProps { + children?: React.ComponentChildren; +} +const Popper: React.FC = ( + props: ScopedProps, +) => { + const { __scopePopper, children } = props; + const [anchor, setAnchor] = React.useState(null); + return ( + + {children} + + ); +}; + +Popper.displayName = POPPER_NAME; + +/* ------------------------------------------------------------------------------------------------- + * PopperAnchor + * -----------------------------------------------------------------------------------------------*/ + +const ANCHOR_NAME = "PopperAnchor"; + +type PopperAnchorElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface PopperAnchorProps extends PrimitiveDivProps { + virtualRef?: React.RefObject; +} + +const PopperAnchor = React.forwardRef< + PopperAnchorElement, + PopperAnchorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopePopper, virtualRef, ...anchorProps } = props; + const context = usePopperContext(ANCHOR_NAME, __scopePopper); + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + + React.useEffect(() => { + // Consumer can anchor the popper to something that isn't + // a DOM node e.g. pointer position, so we override the + // `anchorRef` with their virtual ref in this case. + context.onAnchorChange(virtualRef?.current || ref.current); + }); + + return virtualRef + ? null + : ; + }, +); + +PopperAnchor.displayName = ANCHOR_NAME; + +/* ------------------------------------------------------------------------------------------------- + * PopperContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "PopperContent"; + +type PopperContentContextValue = { + placedSide: Side; + onArrowChange(arrow: HTMLSpanElement | null): void; + arrowX?: number; + arrowY?: number; + shouldHideArrow: boolean; +}; + +const [PopperContentProvider, useContentContext] = createPopperContext< + PopperContentContextValue +>(CONTENT_NAME); + +type Boundary = Element | null; + +type PopperContentElement = React.ElementRef; +interface PopperContentProps extends PrimitiveDivProps { + side?: Side; + sideOffset?: number; + align?: Align; + alignOffset?: number; + arrowPadding?: number; + avoidCollisions?: boolean; + collisionBoundary?: Boundary | Boundary[]; + collisionPadding?: number | Partial>; + sticky?: "partial" | "always"; + hideWhenDetached?: boolean; + updatePositionStrategy?: "optimized" | "always"; + onPlaced?: () => void; +} + +const PopperContent = React.forwardRef< + PopperContentElement, + PopperContentProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopePopper, + side = "bottom", + sideOffset = 0, + align = "center", + alignOffset = 0, + arrowPadding = 0, + avoidCollisions = true, + collisionBoundary = [], + collisionPadding: collisionPaddingProp = 0, + sticky = "partial", + hideWhenDetached = false, + updatePositionStrategy = "optimized", + onPlaced, + ...contentProps + } = props; + + const context = usePopperContext(CONTENT_NAME, __scopePopper); + + const [content, setContent] = React.useState( + null, + ); + const composedRefs = useComposedRefs( + forwardedRef, + (node) => setContent(node), + ); + + const [arrow, setArrow] = React.useState(null); + const arrowSize = useSize(arrow); + const arrowWidth = arrowSize?.width ?? 0; + const arrowHeight = arrowSize?.height ?? 0; + + const desiredPlacement = + (side + (align !== "center" ? "-" + align : "")) as Placement; + + const collisionPadding = typeof collisionPaddingProp === "number" + ? collisionPaddingProp + : { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp }; + + const boundary = Array.isArray(collisionBoundary) + ? collisionBoundary + : [collisionBoundary]; + const hasExplicitBoundaries = boundary.length > 0; + + const detectOverflowOptions = { + padding: collisionPadding, + boundary: boundary.filter(isNotNull), + // with `strategy: 'fixed'`, this is the only way to get it to respect boundaries + altBoundary: hasExplicitBoundaries, + }; + + const { refs, floatingStyles, placement, isPositioned, middlewareData } = + useFloating({ + // default to `fixed` strategy so users don't have to pick and we also avoid focus scroll issues + strategy: "fixed", + placement: desiredPlacement, + whileElementsMounted: (...args) => { + const cleanup = autoUpdate(...args, { + animationFrame: updatePositionStrategy === "always", + }); + return cleanup; + }, + elements: { + reference: context.anchor, + }, + middleware: [ + offset({ + mainAxis: sideOffset + arrowHeight, + alignmentAxis: alignOffset, + }), + avoidCollisions && + shift({ + mainAxis: true, + crossAxis: false, + limiter: sticky === "partial" ? limitShift() : undefined, + ...detectOverflowOptions, + }), + avoidCollisions && flip({ ...detectOverflowOptions }), + size({ + ...detectOverflowOptions, + apply: ({ elements, rects, availableWidth, availableHeight }) => { + const { width: anchorWidth, height: anchorHeight } = + rects.reference; + const contentStyle = elements.floating.style; + contentStyle.setProperty( + "--radix-popper-available-width", + `${availableWidth}px`, + ); + contentStyle.setProperty( + "--radix-popper-available-height", + `${availableHeight}px`, + ); + contentStyle.setProperty( + "--radix-popper-anchor-width", + `${anchorWidth}px`, + ); + contentStyle.setProperty( + "--radix-popper-anchor-height", + `${anchorHeight}px`, + ); + }, + }), + arrow && floatingUIarrow({ element: arrow, padding: arrowPadding }), + transformOrigin({ arrowWidth, arrowHeight }), + hideWhenDetached && + hide({ strategy: "referenceHidden", ...detectOverflowOptions }), + ], + }); + + const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement); + + const handlePlaced = useCallbackRef(onPlaced); + useLayoutEffect(() => { + if (isPositioned) { + handlePlaced?.(); + } + }, [isPositioned, handlePlaced]); + + const arrowX = middlewareData.arrow?.x; + const arrowY = middlewareData.arrow?.y; + const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0; + + const [contentZIndex, setContentZIndex] = React.useState(); + useLayoutEffect(() => { + if (content) setContentZIndex(window.getComputedStyle(content).zIndex); + }, [content]); + + return ( +

+ + +
+ ); + }, +); + +PopperContent.displayName = CONTENT_NAME; + +/* ------------------------------------------------------------------------------------------------- + * PopperArrow + * -----------------------------------------------------------------------------------------------*/ + +const ARROW_NAME = "PopperArrow"; + +const OPPOSITE_SIDE: Record = { + top: "bottom", + right: "left", + bottom: "top", + left: "right", +}; + +type PopperArrowElement = React.ElementRef; +type ArrowProps = Radix.ComponentPropsWithoutRef; +interface PopperArrowProps extends ArrowProps {} + +const PopperArrow = React.forwardRef< + PopperArrowElement, + PopperArrowProps +>( + function PopperArrow( + props: ScopedProps, + forwardedRef, + ) { + const { __scopePopper, ...arrowProps } = props; + const contentContext = useContentContext(ARROW_NAME, __scopePopper); + const baseSide = OPPOSITE_SIDE[contentContext.placedSide]; + + return ( + // we have to use an extra wrapper because `ResizeObserver` (used by `useSize`) + // doesn't report size as we'd expect on SVG elements. + // it reports their bounding box which is effectively the largest path inside the SVG. + + + + ); + }, +); + +PopperArrow.displayName = ARROW_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +function isNotNull(value: T | null): value is T { + return value !== null; +} + +const transformOrigin = ( + options: { arrowWidth: number; arrowHeight: number }, +): Middleware => ({ + name: "transformOrigin", + options, + fn(data) { + const { placement, rects, middlewareData } = data; + + const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0; + const isArrowHidden = cannotCenterArrow; + const arrowWidth = isArrowHidden ? 0 : options.arrowWidth; + const arrowHeight = isArrowHidden ? 0 : options.arrowHeight; + + const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement); + const noArrowAlign = + { start: "0%", center: "50%", end: "100%" }[placedAlign]; + + const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2; + const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2; + + let x = ""; + let y = ""; + + if (placedSide === "bottom") { + x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`; + y = `${-arrowHeight}px`; + } else if (placedSide === "top") { + x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`; + y = `${rects.floating.height + arrowHeight}px`; + } else if (placedSide === "right") { + x = `${-arrowHeight}px`; + y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`; + } else if (placedSide === "left") { + x = `${rects.floating.width + arrowHeight}px`; + y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`; + } + return { data: { x, y } }; + }, +}); + +function getSideAndAlignFromPlacement(placement: Placement) { + const [side, align = "center"] = placement.split("-"); + return [side as Side, align as Align] as const; +} + +const Root = Popper; +const Anchor = PopperAnchor; +const Content = PopperContent; +const Arrow = PopperArrow; + +export { + ALIGN_OPTIONS, + Anchor, + Arrow, + Content, + createPopperScope, + // + Popper, + PopperAnchor, + PopperArrow, + PopperContent, + // + Root, + // + SIDE_OPTIONS, +}; +export type { + PopperAnchorProps, + PopperArrowProps, + PopperContentProps, + PopperProps, +}; diff --git a/pkg/radix-ui-primitives/preact/popper/mod.ts b/pkg/radix-ui-primitives/preact/popper/mod.ts new file mode 100644 index 0000000..8f954e2 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/popper/mod.ts @@ -0,0 +1,22 @@ +export { + ALIGN_OPTIONS, + Anchor, + Arrow, + Content, + createPopperScope, + // + Popper, + PopperAnchor, + PopperArrow, + PopperContent, + // + Root, + // + SIDE_OPTIONS, +} from "./Popper.tsx"; +export type { + PopperAnchorProps, + PopperArrowProps, + PopperContentProps, + PopperProps, +} from "./Popper.tsx"; diff --git a/pkg/radix-ui-primitives/preact/portal/Portal.tsx b/pkg/radix-ui-primitives/preact/portal/Portal.tsx new file mode 100644 index 0000000..01b4618 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/portal/Portal.tsx @@ -0,0 +1,45 @@ +import * as React from "preact/compat"; +import * as ReactDOM from "preact/compat"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Portal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "Portal"; + +type PortalElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface PortalProps extends PrimitiveDivProps { + /** + * An optional container where the portaled content should be appended. + */ + container?: HTMLElement | null; +} + +const Portal = React.forwardRef( + (props, forwardedRef) => { + const { container = globalThis?.document?.body, ...portalProps } = props; + return container + ? ReactDOM.createPortal( + , + container, + ) + : null; + }, +); + +Portal.displayName = PORTAL_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +const Root = Portal; + +export { + Portal, + // + Root, +}; +export type { PortalProps }; diff --git a/pkg/radix-ui-primitives/preact/portal/mod.ts b/pkg/radix-ui-primitives/preact/portal/mod.ts new file mode 100644 index 0000000..7441b25 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/portal/mod.ts @@ -0,0 +1,6 @@ +export { + Portal, + // + Root, +} from "./Portal.tsx"; +export type { PortalProps } from "./Portal.tsx"; diff --git a/pkg/radix-ui-primitives/preact/presence/Presence.tsx b/pkg/radix-ui-primitives/preact/presence/Presence.tsx new file mode 100644 index 0000000..bd0c48b --- /dev/null +++ b/pkg/radix-ui-primitives/preact/presence/Presence.tsx @@ -0,0 +1,157 @@ +import * as React from "preact/compat"; +import * as ReactDOM from "preact/compat"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { useLayoutEffect } from "../use-layout-effect/mod.ts"; +import { useStateMachine } from "./useStateMachine.tsx"; + +interface PresenceProps { + children: + | React.VNode + | ((props: { present: boolean }) => React.VNode); + present: boolean; +} + +const Presence: React.FC = (props) => { + const { present, children } = props; + const presence = usePresence(present); + + const child = ( + typeof children === "function" + ? children({ present: presence.isPresent }) + : children + ) as React.VNode; + + const ref = useComposedRefs(presence.ref, (child as any).ref); + const forceMount = typeof children === "function"; + return forceMount || presence.isPresent + ? React.cloneElement(child, { ref }) + : null; +}; + +Presence.displayName = "Presence"; + +/* ------------------------------------------------------------------------------------------------- + * usePresence + * -----------------------------------------------------------------------------------------------*/ + +function usePresence(present: boolean) { + const [node, setNode] = React.useState(); + const stylesRef = React.useRef({} as any); + const prevPresentRef = React.useRef(present); + const prevAnimationNameRef = React.useRef("none"); + const initialState = present ? "mounted" : "unmounted"; + const [state, send] = useStateMachine(initialState, { + mounted: { + UNMOUNT: "unmounted", + ANIMATION_OUT: "unmountSuspended", + }, + unmountSuspended: { + MOUNT: "mounted", + ANIMATION_END: "unmounted", + }, + unmounted: { + MOUNT: "mounted", + }, + }); + + React.useEffect(() => { + const currentAnimationName = getAnimationName(stylesRef.current); + prevAnimationNameRef.current = state === "mounted" + ? currentAnimationName + : "none"; + }, [state]); + + useLayoutEffect(() => { + const styles = stylesRef.current; + const wasPresent = prevPresentRef.current; + const hasPresentChanged = wasPresent !== present; + + if (hasPresentChanged) { + const prevAnimationName = prevAnimationNameRef.current; + const currentAnimationName = getAnimationName(styles); + + if (present) { + send("MOUNT"); + } else if ( + currentAnimationName === "none" || styles?.display === "none" + ) { + // If there is no exit animation or the element is hidden, animations won't run + // so we unmount instantly + send("UNMOUNT"); + } else { + /** + * When `present` changes to `false`, we check changes to animation-name to + * determine whether an animation has started. We chose this approach (reading + * computed styles) because there is no `animationrun` event and `animationstart` + * fires after `animation-delay` has expired which would be too late. + */ + const isAnimating = prevAnimationName !== currentAnimationName; + + if (wasPresent && isAnimating) { + send("ANIMATION_OUT"); + } else { + send("UNMOUNT"); + } + } + + prevPresentRef.current = present; + } + }, [present, send]); + + useLayoutEffect(() => { + if (node) { + /** + * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` + * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we + * make sure we only trigger ANIMATION_END for the currently active animation. + */ + const handleAnimationEnd = (event: AnimationEvent) => { + const currentAnimationName = getAnimationName(stylesRef.current); + const isCurrentAnimation = currentAnimationName.includes( + event.animationName, + ); + if (event.target === node && isCurrentAnimation) { + // With React 18 concurrency this update is applied + // a frame after the animation ends, creating a flash of visible content. + // By manually flushing we ensure they sync within a frame, removing the flash. + ReactDOM.flushSync(() => send("ANIMATION_END")); + } + }; + const handleAnimationStart = (event: AnimationEvent) => { + if (event.target === node) { + // if animation occurred, store its name as the previous animation. + prevAnimationNameRef.current = getAnimationName(stylesRef.current); + } + }; + node.addEventListener("animationstart", handleAnimationStart); + node.addEventListener("animationcancel", handleAnimationEnd); + node.addEventListener("animationend", handleAnimationEnd); + return () => { + node.removeEventListener("animationstart", handleAnimationStart); + node.removeEventListener("animationcancel", handleAnimationEnd); + node.removeEventListener("animationend", handleAnimationEnd); + }; + } else { + // Transition to the unmounted state if the node is removed prematurely. + // We avoid doing so during cleanup as the node may change but still exist. + send("ANIMATION_END"); + } + }, [node, send]); + + return { + isPresent: ["mounted", "unmountSuspended"].includes(state), + ref: React.useCallback((node: HTMLElement) => { + if (node) stylesRef.current = getComputedStyle(node); + setNode(node); + }, []), + }; +} + +/* -----------------------------------------------------------------------------------------------*/ + +function getAnimationName(styles?: CSSStyleDeclaration) { + return styles?.animationName || "none"; +} + +export { Presence }; +export type { PresenceProps }; diff --git a/pkg/radix-ui-primitives/preact/presence/mod.ts b/pkg/radix-ui-primitives/preact/presence/mod.ts new file mode 100644 index 0000000..0f50efb --- /dev/null +++ b/pkg/radix-ui-primitives/preact/presence/mod.ts @@ -0,0 +1,2 @@ +export { Presence } from "./Presence.tsx"; +export type { PresenceProps } from "./Presence.tsx"; diff --git a/pkg/radix-ui-primitives/preact/presence/useStateMachine.tsx b/pkg/radix-ui-primitives/preact/presence/useStateMachine.tsx new file mode 100644 index 0000000..fa129a5 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/presence/useStateMachine.tsx @@ -0,0 +1,23 @@ +import * as React from "preact/compat"; + +type Machine = { [k: string]: { [k: string]: S } }; +type MachineState = keyof T; +type MachineEvent = keyof UnionToIntersection; + +// 🤯 https://fettblog.eu/typescript-union-to-intersection/ +type UnionToIntersection = (T extends any ? (x: T) => any : never) extends + (x: infer R) => any ? R + : never; + +export function useStateMachine( + initialState: MachineState, + machine: M & Machine>, +) { + return React.useReducer( + (state: MachineState, event: MachineEvent): MachineState => { + const nextState = (machine[state] as any)[event]; + return nextState ?? state; + }, + initialState, + ); +} diff --git a/pkg/radix-ui-primitives/preact/primitive/Primitive.tsx b/pkg/radix-ui-primitives/preact/primitive/Primitive.tsx new file mode 100644 index 0000000..f09fc00 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/primitive/Primitive.tsx @@ -0,0 +1,128 @@ +import * as React from "preact/compat"; +import * as ReactDOM from "preact/compat"; +import { Slot } from "../slot/mod.ts"; + +const NODES = [ + "a", + "button", + "div", + "form", + "h2", + "h3", + "img", + "input", + "label", + "li", + "nav", + "ol", + "p", + "span", + "svg", + "ul", +] as const; + +// Temporary while we await merge of this fix: +// https://github.com/DefinitelyTyped/DefinitelyTyped/pull/55396 +// prettier-ignore +type PropsWithoutRef

= P extends any + ? ("ref" extends keyof P ? Pick> : P) + : P; +type ComponentPropsWithoutRef = PropsWithoutRef< + React.ComponentProps +>; + +type Primitives = { + [E in typeof NODES[number]]: PrimitiveForwardRefComponent; +}; +type PrimitivePropsWithRef = + & React.ComponentPropsWithRef + & { + asChild?: boolean; + }; + +interface PrimitiveForwardRefComponent + extends React.forwardRefExoticComponent> {} + +/* ------------------------------------------------------------------------------------------------- + * Primitive + * -----------------------------------------------------------------------------------------------*/ + +const Primitive = NODES.reduce((primitive, node) => { + const Node = React.forwardRef( + (props: PrimitivePropsWithRef, forwardedRef: any) => { + const { asChild, ...primitiveProps } = props; + const Comp: any = asChild ? Slot : node; + + React.useEffect(() => { + (window as any)[Symbol.for("radix-ui")] = true; + }, []); + + return ; + }, + ); + + Node.displayName = `Primitive.${node}`; + + return { ...primitive, [node]: Node }; +}, {} as Primitives); + +/* ------------------------------------------------------------------------------------------------- + * Utils + * -----------------------------------------------------------------------------------------------*/ + +/** + * Flush custom event dispatch + * https://github.com/radix-ui/primitives/pull/1378 + * + * React batches *all* event handlers since version 18, this introduces certain considerations when using custom event types. + * + * Internally, React prioritises events in the following order: + * - discrete + * - continuous + * - default + * + * https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L294-L350 + * + * `discrete` is an important distinction as updates within these events are applied immediately. + * React however, is not able to infer the priority of custom event types due to how they are detected internally. + * Because of this, it's possible for updates from custom events to be unexpectedly batched when + * dispatched by another `discrete` event. + * + * In order to ensure that updates from custom events are applied predictably, we need to manually flush the batch. + * This utility should be used when dispatching a custom event from within another `discrete` event, this utility + * is not nessesary when dispatching known event types, or if dispatching a custom type inside a non-discrete event. + * For example: + * + * dispatching a known click 👎 + * target.dispatchEvent(new Event(‘click’)) + * + * dispatching a custom type within a non-discrete event 👎 + * onScroll={(event) => event.target.dispatchEvent(new CustomEvent(‘customType’))} + * + * dispatching a custom type within a `discrete` event 👍 + * onPointerDown={(event) => dispatchDiscreteCustomEvent(event.target, new CustomEvent(‘customType’))} + * + * Note: though React classifies `focus`, `focusin` and `focusout` events as `discrete`, it's not recommended to use + * this utility with them. This is because it's possible for those handlers to be called implicitly during render + * e.g. when focus is within a component as it is unmounted, or when managing focus on mount. + */ + +function dispatchDiscreteCustomEvent( + target: E["target"], + event: E, +) { + if (target) ReactDOM.flushSync(() => target.dispatchEvent(event)); +} + +/* -----------------------------------------------------------------------------------------------*/ + +const Root = Primitive; + +export { + // + dispatchDiscreteCustomEvent, + Primitive, + // + Root, +}; +export type { ComponentPropsWithoutRef, PrimitivePropsWithRef }; diff --git a/pkg/radix-ui-primitives/preact/primitive/mod.ts b/pkg/radix-ui-primitives/preact/primitive/mod.ts new file mode 100644 index 0000000..1f98977 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/primitive/mod.ts @@ -0,0 +1,11 @@ +export { + // + dispatchDiscreteCustomEvent, + Primitive, + // + Root, +} from "./Primitive.tsx"; +export type { + ComponentPropsWithoutRef, + PrimitivePropsWithRef, +} from "./Primitive.tsx"; diff --git a/pkg/radix-ui-primitives/preact/progress/Progress.tsx b/pkg/radix-ui-primitives/preact/progress/Progress.tsx new file mode 100644 index 0000000..aa68712 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/progress/Progress.tsx @@ -0,0 +1,185 @@ +import * as React from "preact/compat"; +import { createContextScope } from "../context/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Progress + * -----------------------------------------------------------------------------------------------*/ + +const PROGRESS_NAME = "Progress"; +const DEFAULT_MAX = 100; + +type ScopedProps

= P & { __scopeProgress?: Scope }; +const [createProgressContext, createProgressScope] = createContextScope( + PROGRESS_NAME, +); + +type ProgressState = "indeterminate" | "complete" | "loading"; +type ProgressContextValue = { value: number | null; max: number }; +const [ProgressProvider, useProgressContext] = createProgressContext< + ProgressContextValue +>(PROGRESS_NAME); + +type ProgressElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface ProgressProps extends PrimitiveDivProps { + value?: number | null | undefined; + max?: number; + getValueLabel?(value: number, max: number): string; +} + +const Progress = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { + __scopeProgress, + value: valueProp, + max: maxProp, + getValueLabel = defaultGetValueLabel, + ...progressProps + } = props; + + const max = isValidMaxNumber(maxProp) ? maxProp : DEFAULT_MAX; + const value = isValidValueNumber(valueProp, max) ? valueProp : null; + const valueLabel = isNumber(value) ? getValueLabel(value, max) : undefined; + + return ( + + + + ); + }, +); + +Progress.displayName = PROGRESS_NAME; + +Progress.propTypes = { + max(props, propName, componentName) { + const propValue = props[propName]; + const strVal = String(propValue); + if (propValue && !isValidMaxNumber(propValue)) { + return new Error(getInvalidMaxError(strVal, componentName)); + } + return null; + }, + value(props, propName, componentName) { + const valueProp = props[propName]; + const strVal = String(valueProp); + const max = isValidMaxNumber(props.max) ? props.max : DEFAULT_MAX; + if (valueProp != null && !isValidValueNumber(valueProp, max)) { + return new Error(getInvalidValueError(strVal, componentName)); + } + return null; + }, +}; + +/* ------------------------------------------------------------------------------------------------- + * ProgressIndicator + * -----------------------------------------------------------------------------------------------*/ + +const INDICATOR_NAME = "ProgressIndicator"; + +type ProgressIndicatorElement = React.ElementRef; +interface ProgressIndicatorProps extends PrimitiveDivProps {} + +const ProgressIndicator = React.forwardRef< + ProgressIndicatorElement, + ProgressIndicatorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeProgress, ...indicatorProps } = props; + const context = useProgressContext(INDICATOR_NAME, __scopeProgress); + return ( + + ); + }, +); + +ProgressIndicator.displayName = INDICATOR_NAME; + +/* ---------------------------------------------------------------------------------------------- */ + +function defaultGetValueLabel(value: number, max: number) { + return `${Math.round((value / max) * 100)}%`; +} + +function getProgressState( + value: number | undefined | null, + maxValue: number, +): ProgressState { + return value == null + ? "indeterminate" + : value === maxValue + ? "complete" + : "loading"; +} + +function isNumber(value: any): value is number { + return typeof value === "number"; +} + +function isValidMaxNumber(max: any): max is number { + // prettier-ignore + return ( + isNumber(max) && + !isNaN(max) && + max > 0 + ); +} + +function isValidValueNumber(value: any, max: number): value is number { + // prettier-ignore + return ( + isNumber(value) && + !isNaN(value) && + value <= max && + value >= 0 + ); +} + +// Split this out for clearer readability of the error message. +function getInvalidMaxError(propValue: string, componentName: string) { + return `Invalid prop \`max\` of value \`${propValue}\` supplied to \`${componentName}\`. Only numbers greater than 0 are valid max values. Defaulting to \`${DEFAULT_MAX}\`.`; +} + +function getInvalidValueError(propValue: string, componentName: string) { + return `Invalid prop \`value\` of value \`${propValue}\` supplied to \`${componentName}\`. The \`value\` prop must be: + - a positive number + - less than the value passed to \`max\` (or ${DEFAULT_MAX} if no \`max\` prop is set) + - \`null\` if the progress is indeterminate. + +Defaulting to \`null\`.`; +} + +const Root = Progress; +const Indicator = ProgressIndicator; + +export { + createProgressScope, + Indicator, + // + Progress, + ProgressIndicator, + // + Root, +}; +export type { ProgressIndicatorProps, ProgressProps }; diff --git a/pkg/radix-ui-primitives/preact/progress/mod.ts b/pkg/radix-ui-primitives/preact/progress/mod.ts new file mode 100644 index 0000000..fe9b04e --- /dev/null +++ b/pkg/radix-ui-primitives/preact/progress/mod.ts @@ -0,0 +1,10 @@ +export { + createProgressScope, + Indicator, + // + Progress, + ProgressIndicator, + // + Root, +} from "./Progress.tsx"; +export type { ProgressIndicatorProps, ProgressProps } from "./Progress.tsx"; diff --git a/pkg/radix-ui-primitives/preact/radio-group/Radio.tsx b/pkg/radix-ui-primitives/preact/radio-group/Radio.tsx new file mode 100644 index 0000000..5b8b06f --- /dev/null +++ b/pkg/radix-ui-primitives/preact/radio-group/Radio.tsx @@ -0,0 +1,210 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { useSize } from "../use-size/mod.ts"; +import { usePrevious } from "../use-previous/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Radio + * -----------------------------------------------------------------------------------------------*/ + +const RADIO_NAME = "Radio"; + +type ScopedProps

= P & { __scopeRadio?: Scope }; +const [createRadioContext, createRadioScope] = createContextScope(RADIO_NAME); + +type RadioContextValue = { checked: boolean; disabled?: boolean }; +const [RadioProvider, useRadioContext] = createRadioContext( + RADIO_NAME, +); + +type RadioElement = React.ElementRef; +type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef< + typeof Primitive.button +>; +interface RadioProps extends PrimitiveButtonProps { + checked?: boolean; + required?: boolean; + onCheck?(): void; +} + +const Radio = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { + __scopeRadio, + name, + checked = false, + required, + disabled, + value = "on", + onCheck, + ...radioProps + } = props; + const [button, setButton] = React.useState( + null, + ); + const composedRefs = useComposedRefs( + forwardedRef, + (node) => setButton(node), + ); + const hasConsumerStoppedPropagationRef = React.useRef(false); + // We set this to true by default so that events bubble to forms without JS (SSR) + const isFormControl = button ? Boolean(button.closest("form")) : true; + + return ( + + { + // radios cannot be unchecked so we only communicate a checked state + if (!checked) { + onCheck?.(); + } + if (isFormControl) { + hasConsumerStoppedPropagationRef.current = event + .isPropagationStopped(); + // if radio is in a form, stop propagation from the button so that we only propagate + // one click event (from the input). We propagate changes from an input so that native + // form validation works and form events reflect radio updates. + if (!hasConsumerStoppedPropagationRef.current) { + event.stopPropagation(); + } + } + })} + /> + {isFormControl && ( + + )} + + ); + }, +); + +Radio.displayName = RADIO_NAME; + +/* ------------------------------------------------------------------------------------------------- + * RadioIndicator + * -----------------------------------------------------------------------------------------------*/ + +const INDICATOR_NAME = "RadioIndicator"; + +type RadioIndicatorElement = React.ElementRef; +type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef; +export interface RadioIndicatorProps extends PrimitiveSpanProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true; +} + +const RadioIndicator = React.forwardRef< + RadioIndicatorElement, + RadioIndicatorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeRadio, forceMount, ...indicatorProps } = props; + const context = useRadioContext(INDICATOR_NAME, __scopeRadio); + return ( + + + + ); + }, +); + +RadioIndicator.displayName = INDICATOR_NAME; + +/* ---------------------------------------------------------------------------------------------- */ + +type InputProps = Radix.ComponentPropsWithoutRef<"input">; +interface BubbleInputProps extends Omit { + checked: boolean; + control: HTMLElement | null; + bubbles: boolean; +} + +const BubbleInput = (props: BubbleInputProps) => { + const { control, checked, bubbles = true, ...inputProps } = props; + const ref = React.useRef(null); + const prevChecked = usePrevious(checked); + const controlSize = useSize(control); + + // Bubble checked change to parents (e.g form change event) + React.useEffect(() => { + const input = ref.current!; + const inputProto = window.HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor( + inputProto, + "checked", + ) as PropertyDescriptor; + const setChecked = descriptor.set; + if (prevChecked !== checked && setChecked) { + const event = new Event("click", { bubbles }); + setChecked.call(input, checked); + input.dispatchEvent(event); + } + }, [prevChecked, checked, bubbles]); + + return ( + + ); +}; + +function getState(checked: boolean) { + return checked ? "checked" : "unchecked"; +} + +export { + createRadioScope, + // + Radio, + RadioIndicator, +}; +export type { RadioProps }; diff --git a/pkg/radix-ui-primitives/preact/radio-group/RadioGroup.tsx b/pkg/radix-ui-primitives/preact/radio-group/RadioGroup.tsx new file mode 100644 index 0000000..854f070 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/radio-group/RadioGroup.tsx @@ -0,0 +1,240 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import * as RovingFocusGroup from "../roving-focus/mod.ts"; +import { createRovingFocusGroupScope } from "../roving-focus/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { useDirection } from "../direction/mod.ts"; +import { createRadioScope, Radio, RadioIndicator } from "./Radio.tsx"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; + +/* ------------------------------------------------------------------------------------------------- + * RadioGroup + * -----------------------------------------------------------------------------------------------*/ +const RADIO_GROUP_NAME = "RadioGroup"; + +type ScopedProps

= P & { __scopeRadioGroup?: Scope }; +const [createRadioGroupContext, createRadioGroupScope] = createContextScope( + RADIO_GROUP_NAME, + [ + createRovingFocusGroupScope, + createRadioScope, + ], +); +const useRovingFocusGroupScope = createRovingFocusGroupScope(); +const useRadioScope = createRadioScope(); + +type RadioGroupContextValue = { + name?: string; + required: boolean; + disabled: boolean; + value?: string; + onValueChange(value: string): void; +}; + +const [RadioGroupProvider, useRadioGroupContext] = createRadioGroupContext< + RadioGroupContextValue +>(RADIO_GROUP_NAME); + +type RadioGroupElement = React.ElementRef; +type RovingFocusGroupProps = Radix.ComponentPropsWithoutRef< + typeof RovingFocusGroup.Root +>; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface RadioGroupProps extends PrimitiveDivProps { + name?: RadioGroupContextValue["name"]; + required?: Radix.ComponentPropsWithoutRef["required"]; + disabled?: Radix.ComponentPropsWithoutRef["disabled"]; + dir?: RovingFocusGroupProps["dir"]; + orientation?: RovingFocusGroupProps["orientation"]; + loop?: RovingFocusGroupProps["loop"]; + defaultValue?: string; + value?: RadioGroupContextValue["value"]; + onValueChange?: RadioGroupContextValue["onValueChange"]; +} + +const RadioGroup = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { + __scopeRadioGroup, + name, + defaultValue, + value: valueProp, + required = false, + disabled = false, + orientation, + dir, + loop = true, + onValueChange, + ...groupProps + } = props; + const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeRadioGroup); + const direction = useDirection(dir); + const [value, setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue, + onChange: onValueChange, + }); + + return ( + + + + + + ); + }, +); + +RadioGroup.displayName = RADIO_GROUP_NAME; + +/* ------------------------------------------------------------------------------------------------- + * RadioGroupItem + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_NAME = "RadioGroupItem"; + +type RadioGroupItemElement = React.ElementRef; +type RadioProps = Radix.ComponentPropsWithoutRef; +interface RadioGroupItemProps extends Omit { + value: string; +} + +const RadioGroupItem = React.forwardRef< + RadioGroupItemElement, + RadioGroupItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeRadioGroup, disabled, ...itemProps } = props; + const context = useRadioGroupContext(ITEM_NAME, __scopeRadioGroup); + const isDisabled = context.disabled || disabled; + const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeRadioGroup); + const radioScope = useRadioScope(__scopeRadioGroup); + const ref = React.useRef>(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + const checked = context.value === itemProps.value; + const isArrowKeyPressedRef = React.useRef(false); + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (ARROW_KEYS.includes(event.key)) { + isArrowKeyPressedRef.current = true; + } + }; + const handleKeyUp = () => (isArrowKeyPressedRef.current = false); + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keyup", handleKeyUp); + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keyup", handleKeyUp); + }; + }, []); + + return ( + + context.onValueChange(itemProps.value)} + onKeyDown={composeEventHandlers((event) => { + // According to WAI ARIA, radio groups don't activate items on enter keypress + if (event.key === "Enter") event.preventDefault(); + })} + onFocus={composeEventHandlers(itemProps.onFocus, () => { + /** + * Our `RovingFocusGroup` will focus the radio when navigating with arrow keys + * and we need to "check" it in that case. We click it to "check" it (instead + * of updating `context.value`) so that the radio change event fires. + */ + if (isArrowKeyPressedRef.current) ref.current?.click(); + })} + /> + + ); + }, +); + +RadioGroupItem.displayName = ITEM_NAME; + +/* ------------------------------------------------------------------------------------------------- + * RadioGroupIndicator + * -----------------------------------------------------------------------------------------------*/ + +const INDICATOR_NAME = "RadioGroupIndicator"; + +type RadioGroupIndicatorElement = React.ElementRef; +type RadioIndicatorProps = Radix.ComponentPropsWithoutRef< + typeof RadioIndicator +>; +interface RadioGroupIndicatorProps extends RadioIndicatorProps {} + +const RadioGroupIndicator = React.forwardRef< + RadioGroupIndicatorElement, + RadioGroupIndicatorProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeRadioGroup, ...indicatorProps } = props; + const radioScope = useRadioScope(__scopeRadioGroup); + return ( + + ); + }, +); + +RadioGroupIndicator.displayName = INDICATOR_NAME; + +/* ---------------------------------------------------------------------------------------------- */ + +const Root = RadioGroup; +const Item = RadioGroupItem; +const Indicator = RadioGroupIndicator; + +export { + createRadioGroupScope, + Indicator, + Item, + // + RadioGroup, + RadioGroupIndicator, + RadioGroupItem, + // + Root, +}; +export type { RadioGroupIndicatorProps, RadioGroupItemProps, RadioGroupProps }; diff --git a/pkg/radix-ui-primitives/preact/radio-group/mod.ts b/pkg/radix-ui-primitives/preact/radio-group/mod.ts new file mode 100644 index 0000000..051c6b8 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/radio-group/mod.ts @@ -0,0 +1,16 @@ +export { + createRadioGroupScope, + Indicator, + Item, + // + RadioGroup, + RadioGroupIndicator, + RadioGroupItem, + // + Root, +} from "./RadioGroup.tsx"; +export type { + RadioGroupIndicatorProps, + RadioGroupItemProps, + RadioGroupProps, +} from "./RadioGroup.tsx"; diff --git a/pkg/radix-ui-primitives/preact/roving-focus/RovingFocusGroup.tsx b/pkg/radix-ui-primitives/preact/roving-focus/RovingFocusGroup.tsx new file mode 100644 index 0000000..503256c --- /dev/null +++ b/pkg/radix-ui-primitives/preact/roving-focus/RovingFocusGroup.tsx @@ -0,0 +1,392 @@ +import * as React from "preact/compat"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { createCollection } from "../collection/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { useId } from "../id/mod.ts"; +import { Primitive } from "../primitive/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; +import { useControllableState } from "../use-controllable-state/mod.ts"; +import { useDirection } from "../direction/mod.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +const ENTRY_FOCUS = "rovingFocusGroup.onEntryFocus"; +const EVENT_OPTIONS = { bubbles: false, cancelable: true }; + +/* ------------------------------------------------------------------------------------------------- + * RovingFocusGroup + * -----------------------------------------------------------------------------------------------*/ + +const GROUP_NAME = "RovingFocusGroup"; + +type ItemData = { id: string; focusable: boolean; active: boolean }; +const [Collection, useCollection, createCollectionScope] = createCollection< + HTMLSpanElement, + ItemData +>(GROUP_NAME); + +type ScopedProps

= P & { __scopeRovingFocusGroup?: Scope }; +const [createRovingFocusGroupContext, createRovingFocusGroupScope] = + createContextScope( + GROUP_NAME, + [createCollectionScope], + ); + +type Orientation = React.AriaAttributes["aria-orientation"]; +type Direction = "ltr" | "rtl"; + +interface RovingFocusGroupOptions { + /** + * The orientation of the group. + * Mainly so arrow navigation is done accordingly (left & right vs. up & down) + */ + orientation?: Orientation; + /** + * The direction of navigation between items. + */ + dir?: Direction; + /** + * Whether keyboard navigation should loop around + * @defaultValue false + */ + loop?: boolean; +} + +type RovingContextValue = RovingFocusGroupOptions & { + currentTabStopId: string | null; + onItemFocus(tabStopId: string): void; + onItemShiftTab(): void; + onFocusableItemAdd(): void; + onFocusableItemRemove(): void; +}; + +const [RovingFocusProvider, useRovingFocusContext] = + createRovingFocusGroupContext(GROUP_NAME); + +type RovingFocusGroupElement = RovingFocusGroupImplElement; +interface RovingFocusGroupProps extends RovingFocusGroupImplProps {} + +const RovingFocusGroup = React.forwardRef< + RovingFocusGroupElement, + RovingFocusGroupProps +>( + (props: ScopedProps, forwardedRef) => { + return ( + + + + + + ); + }, +); + +RovingFocusGroup.displayName = GROUP_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +type RovingFocusGroupImplElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface RovingFocusGroupImplProps + extends Omit, RovingFocusGroupOptions { + currentTabStopId?: string | null; + defaultCurrentTabStopId?: string; + onCurrentTabStopIdChange?: (tabStopId: string | null) => void; + onEntryFocus?: (event: Event) => void; +} + +const RovingFocusGroupImpl = React.forwardRef< + RovingFocusGroupImplElement, + RovingFocusGroupImplProps +>((props: ScopedProps, forwardedRef) => { + const { + __scopeRovingFocusGroup, + orientation, + loop = false, + dir, + currentTabStopId: currentTabStopIdProp, + defaultCurrentTabStopId, + onCurrentTabStopIdChange, + onEntryFocus, + ...groupProps + } = props; + const ref = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + const direction = useDirection(dir); + const [currentTabStopId = null, setCurrentTabStopId] = useControllableState({ + prop: currentTabStopIdProp, + defaultProp: defaultCurrentTabStopId, + onChange: onCurrentTabStopIdChange, + }); + const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); + const handleEntryFocus = useCallbackRef(onEntryFocus); + const getItems = useCollection(__scopeRovingFocusGroup); + const isClickFocusRef = React.useRef(false); + const [focusableItemsCount, setFocusableItemsCount] = React.useState(0); + + React.useEffect(() => { + const node = ref.current; + if (node) { + node.addEventListener(ENTRY_FOCUS, handleEntryFocus); + return () => node.removeEventListener(ENTRY_FOCUS, handleEntryFocus); + } + }, [handleEntryFocus]); + + return ( + setCurrentTabStopId(tabStopId), + [setCurrentTabStopId], + )} + onItemShiftTab={React.useCallback( + () => setIsTabbingBackOut(true), + [], + )} + onFocusableItemAdd={React.useCallback( + () => setFocusableItemsCount((prevCount) => prevCount + 1), + [], + )} + onFocusableItemRemove={React.useCallback( + () => setFocusableItemsCount((prevCount) => prevCount - 1), + [], + )} + > + { + isClickFocusRef.current = true; + })} + onFocus={composeEventHandlers(props.onFocus, (event) => { + // We normally wouldn't need this check, because we already check + // that the focus is on the current target and not bubbling to it. + // We do this because Safari doesn't focus buttons when clicked, and + // instead, the wrapper will get focused and not through a bubbling event. + const isKeyboardFocus = !isClickFocusRef.current; + + if ( + event.target === event.currentTarget && isKeyboardFocus && + !isTabbingBackOut + ) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); + event.currentTarget.dispatchEvent(entryFocusEvent); + + if (!entryFocusEvent.defaultPrevented) { + const items = getItems().filter((item) => item.focusable); + const activeItem = items.find((item) => item.active); + const currentItem = items.find((item) => + item.id === currentTabStopId + ); + const candidateItems = [activeItem, currentItem, ...items].filter( + Boolean, + ) as typeof items; + const candidateNodes = candidateItems.map((item) => + item.ref.current! + ); + focusFirst(candidateNodes); + } + } + + isClickFocusRef.current = false; + })} + onBlur={composeEventHandlers( + props.onBlur, + () => setIsTabbingBackOut(false), + )} + /> + + ); +}); + +/* ------------------------------------------------------------------------------------------------- + * RovingFocusGroupItem + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_NAME = "RovingFocusGroupItem"; + +type RovingFocusItemElement = React.ElementRef; +type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef; +interface RovingFocusItemProps extends PrimitiveSpanProps { + tabStopId?: string; + focusable?: boolean; + active?: boolean; +} + +const RovingFocusGroupItem = React.forwardRef< + RovingFocusItemElement, + RovingFocusItemProps +>( + (props: ScopedProps, forwardedRef) => { + const { + __scopeRovingFocusGroup, + focusable = true, + active = false, + tabStopId, + ...itemProps + } = props; + const autoId = useId(); + const id = tabStopId || autoId; + const context = useRovingFocusContext(ITEM_NAME, __scopeRovingFocusGroup); + const isCurrentTabStop = context.currentTabStopId === id; + const getItems = useCollection(__scopeRovingFocusGroup); + + const { onFocusableItemAdd, onFocusableItemRemove } = context; + + React.useEffect(() => { + if (focusable) { + onFocusableItemAdd(); + return () => onFocusableItemRemove(); + } + }, [focusable, onFocusableItemAdd, onFocusableItemRemove]); + + return ( + + { + // We prevent focusing non-focusable items on `mousedown`. + // Even though the item has tabIndex={-1}, that only means take it out of the tab order. + if (!focusable) event.preventDefault(); + // Safari doesn't focus a button when clicked so we run our logic on mousedown also + else context.onItemFocus(id); + })} + onFocus={composeEventHandlers(props.onFocus, () => + context.onItemFocus(id))} + onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + if (event.key === "Tab" && event.shiftKey) { + context.onItemShiftTab(); + return; + } + + if (event.target !== event.currentTarget) { + return; + } + + const focusIntent = getFocusIntent( + event, + context.orientation, + context.dir, + ); + + if (focusIntent !== undefined) { + event.preventDefault(); + const items = getItems().filter((item) => + item.focusable + ); + let candidateNodes = items.map((item) => item.ref.current!); + + if (focusIntent === "last") candidateNodes.reverse(); + else if (focusIntent === "prev" || focusIntent === "next") { + if (focusIntent === "prev") candidateNodes.reverse(); + const currentIndex = candidateNodes.indexOf( + event.currentTarget, + ); + candidateNodes = context.loop + ? wrapArray(candidateNodes, currentIndex + 1) + : candidateNodes.slice(currentIndex + 1); + } + + /** + * Imperative focus during keydown is risky so we prevent React's batching updates + * to avoid potential bugs. See: https://github.com/facebook/react/issues/20332 + */ + setTimeout(() => focusFirst(candidateNodes)); + } + })} + /> + + ); + }, +); + +RovingFocusGroupItem.displayName = ITEM_NAME; + +/* -----------------------------------------------------------------------------------------------*/ + +// prettier-ignore +const MAP_KEY_TO_FOCUS_INTENT: Record = { + ArrowLeft: "prev", + ArrowUp: "prev", + ArrowRight: "next", + ArrowDown: "next", + PageUp: "first", + Home: "first", + PageDown: "last", + End: "last", +}; + +function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== "rtl") return key; + return key === "ArrowLeft" + ? "ArrowRight" + : key === "ArrowRight" + ? "ArrowLeft" + : key; +} + +type FocusIntent = "first" | "last" | "prev" | "next"; + +function getFocusIntent( + event: React.KeyboardEvent, + orientation?: Orientation, + dir?: Direction, +) { + const key = getDirectionAwareKey(event.key, dir); + if (orientation === "vertical" && ["ArrowLeft", "ArrowRight"].includes(key)) { + return undefined; + } + if (orientation === "horizontal" && ["ArrowUp", "ArrowDown"].includes(key)) { + return undefined; + } + return MAP_KEY_TO_FOCUS_INTENT[key]; +} + +function focusFirst(candidates: HTMLElement[]) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + for (const candidate of candidates) { + // if focus is already where we want to go, we don't want to keep going through the candidates + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus(); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +/** + * Wraps an array around itself at a given start index + * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']` + */ +function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length]); +} + +const Root = RovingFocusGroup; +const Item = RovingFocusGroupItem; + +export { + createRovingFocusGroupScope, + Item, + // + Root, + // + RovingFocusGroup, + RovingFocusGroupItem, +}; +export type { RovingFocusGroupProps, RovingFocusItemProps }; diff --git a/pkg/radix-ui-primitives/preact/roving-focus/mod.ts b/pkg/radix-ui-primitives/preact/roving-focus/mod.ts new file mode 100644 index 0000000..08321f2 --- /dev/null +++ b/pkg/radix-ui-primitives/preact/roving-focus/mod.ts @@ -0,0 +1,13 @@ +export { + createRovingFocusGroupScope, + Item, + // + Root, + // + RovingFocusGroup, + RovingFocusGroupItem, +} from "./RovingFocusGroup.tsx"; +export type { + RovingFocusGroupProps, + RovingFocusItemProps, +} from "./RovingFocusGroup.tsx"; diff --git a/pkg/radix-ui-primitives/preact/scroll-area/ScrollArea.tsx b/pkg/radix-ui-primitives/preact/scroll-area/ScrollArea.tsx new file mode 100644 index 0000000..b7ae49d --- /dev/null +++ b/pkg/radix-ui-primitives/preact/scroll-area/ScrollArea.tsx @@ -0,0 +1,1232 @@ +/// + +import * as React from "preact/compat"; +import { Primitive } from "../primitive/mod.ts"; +import { Presence } from "../presence/mod.ts"; +import { createContextScope } from "../context/mod.ts"; +import { useComposedRefs } from "../compose-refs/mod.ts"; +import { useCallbackRef } from "../use-callback-ref/mod.ts"; +import { useDirection } from "../direction/mod.ts"; +import { useLayoutEffect } from "../use-layout-effect/mod.ts"; +import { clamp } from "../../core/number/mod.ts"; +import { composeEventHandlers } from "../../core/primitive/mod.ts"; +import { useStateMachine } from "./useStateMachine.ts"; + +import type * as Radix from "../primitive/mod.ts"; +import type { Scope } from "../context/mod.ts"; + +type Direction = "ltr" | "rtl"; +type Sizes = { + content: number; + viewport: number; + scrollbar: { + size: number; + paddingStart: number; + paddingEnd: number; + }; +}; + +/* ------------------------------------------------------------------------------------------------- + * ScrollArea + * -----------------------------------------------------------------------------------------------*/ + +const SCROLL_AREA_NAME = "ScrollArea"; + +type ScopedProps

= P & { __scopeScrollArea?: Scope }; +const [createScrollAreaContext, createScrollAreaScope] = createContextScope( + SCROLL_AREA_NAME, +); + +type ScrollAreaContextValue = { + type: "auto" | "always" | "scroll" | "hover"; + dir: Direction; + scrollHideDelay: number; + scrollArea: ScrollAreaElement | null; + viewport: ScrollAreaViewportElement | null; + onViewportChange(viewport: ScrollAreaViewportElement | null): void; + content: HTMLDivElement | null; + onContentChange(content: HTMLDivElement): void; + scrollbarX: ScrollAreaScrollbarElement | null; + onScrollbarXChange(scrollbar: ScrollAreaScrollbarElement | null): void; + scrollbarXEnabled: boolean; + onScrollbarXEnabledChange(rendered: boolean): void; + scrollbarY: ScrollAreaScrollbarElement | null; + onScrollbarYChange(scrollbar: ScrollAreaScrollbarElement | null): void; + scrollbarYEnabled: boolean; + onScrollbarYEnabledChange(rendered: boolean): void; + onCornerWidthChange(width: number): void; + onCornerHeightChange(height: number): void; +}; + +const [ScrollAreaProvider, useScrollAreaContext] = createScrollAreaContext< + ScrollAreaContextValue +>(SCROLL_AREA_NAME); + +type ScrollAreaElement = React.ElementRef; +type PrimitiveDivProps = Radix.ComponentPropsWithoutRef; +interface ScrollAreaProps extends PrimitiveDivProps { + type?: ScrollAreaContextValue["type"]; + dir?: ScrollAreaContextValue["dir"]; + scrollHideDelay?: number; +} + +const ScrollArea = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { + __scopeScrollArea, + type = "hover", + dir, + scrollHideDelay = 600, + ...scrollAreaProps + } = props; + const [scrollArea, setScrollArea] = React.useState< + ScrollAreaElement | null + >(null); + const [viewport, setViewport] = React.useState< + ScrollAreaViewportElement | null + >(null); + const [content, setContent] = React.useState( + null, + ); + const [scrollbarX, setScrollbarX] = React.useState< + ScrollAreaScrollbarElement | null + >(null); + const [scrollbarY, setScrollbarY] = React.useState< + ScrollAreaScrollbarElement | null + >(null); + const [cornerWidth, setCornerWidth] = React.useState(0); + const [cornerHeight, setCornerHeight] = React.useState(0); + const [scrollbarXEnabled, setScrollbarXEnabled] = React.useState( + false, + ); + const [scrollbarYEnabled, setScrollbarYEnabled] = React.useState( + false, + ); + const composedRefs = useComposedRefs( + forwardedRef, + (node) => setScrollArea(node), + ); + const direction = useDirection(dir); + + return ( + + + + ); + }, +); + +ScrollArea.displayName = SCROLL_AREA_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ScrollAreaViewport + * -----------------------------------------------------------------------------------------------*/ + +const VIEWPORT_NAME = "ScrollAreaViewport"; + +type ScrollAreaViewportElement = React.ElementRef; +interface ScrollAreaViewportProps extends PrimitiveDivProps {} + +const ScrollAreaViewport = React.forwardRef< + ScrollAreaViewportElement, + ScrollAreaViewportProps +>( + (props: ScopedProps, forwardedRef) => { + const { __scopeScrollArea, children, ...viewportProps } = props; + const context = useScrollAreaContext(VIEWPORT_NAME, __scopeScrollArea); + const ref = React.useRef(null); + const composedRefs = useComposedRefs( + forwardedRef, + ref, + context.onViewportChange, + ); + return ( + <> + {/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */} +